init studio admin backend

This commit is contained in:
2026-07-01 17:30:04 +09:00
commit 4437d9980d
144 changed files with 44964 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
import { IsEnum, IsISO8601, IsOptional, IsString } from 'class-validator';
import { LicenseCode } from './license-enums';
export class ListUserLicensesQuery {
@IsOptional()
@IsString()
keyword?: string;
@IsOptional()
@IsEnum(LicenseCode)
licenseCode?: LicenseCode;
}
export class AssignLicenseDto {
@IsEnum(LicenseCode)
licenseCode!: LicenseCode;
@IsOptional()
@IsISO8601()
startsAt?: string;
@IsOptional()
@IsISO8601()
expiresAt?: string;
@IsOptional()
@IsString()
assignedById?: string;
}
+7
View File
@@ -0,0 +1,7 @@
export const LicenseCode = {
FREE: 'FREE',
EDU: 'EDU',
PRO: 'PRO',
} as const;
export type LicenseCode = (typeof LicenseCode)[keyof typeof LicenseCode];
+34
View File
@@ -0,0 +1,34 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { AssignLicenseDto, ListUserLicensesQuery } from './dto';
import { LicensesService } from './licenses.service';
@Controller('licenses')
export class LicensesController {
constructor(private readonly licensesService: LicensesService) {}
@Get('plans')
listPlans() {
return this.licensesService.listPlans();
}
@Get('users')
listUserLicenses(@Query() query: ListUserLicensesQuery) {
return this.licensesService.listUserLicenses(query);
}
@Post('users/:userId')
assign(@Param('userId') userId: string, @Body() body: AssignLicenseDto) {
return this.licensesService.assign(userId, body);
}
@Patch('user-licenses/:id')
update(@Param('id') id: string, @Body() body: AssignLicenseDto) {
return this.licensesService.update(id, body);
}
@Delete('user-licenses/:id')
revoke(@Param('id') id: string) {
return this.licensesService.revoke(id);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
import { LicensesController } from './licenses.controller';
import { LicensesService } from './licenses.service';
@Module({
imports: [AuthPrismaModule],
controllers: [LicensesController],
providers: [LicensesService],
})
export class LicensesModule {}
+220
View File
@@ -0,0 +1,220 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
import { AssignLicenseDto, ListUserLicensesQuery } from './dto';
@Injectable()
export class LicensesService {
constructor(private readonly authPrisma: AuthPrismaService) {}
async listPlans() {
const now = new Date();
const plans = await this.authPrisma.license.findMany({
orderBy: { code: 'asc' },
include: {
userLicenses: {
select: {
id: true,
startsAt: true,
expiresAt: true,
},
},
},
});
return {
plans: plans.map((plan) => ({
id: plan.id,
code: plan.code,
name: plan.name,
description: plan.description,
defaultDurationDays: plan.defaultDurationDays,
isActive: plan.isActive,
assignedCount: plan.userLicenses.length,
activeUserCount: plan.userLicenses.filter((license) =>
isActiveLicense(license.startsAt, license.expiresAt, now),
).length,
expiringSoonCount: plan.userLicenses.filter((license) =>
expiresSoon(license.expiresAt, now),
).length,
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
})),
};
}
async listUserLicenses(query: ListUserLicensesQuery) {
const keyword = query.keyword?.trim();
const users = await this.authPrisma.user.findMany({
where: {
OR: keyword
? [
{ email: { contains: keyword, mode: 'insensitive' } },
{ name: { contains: keyword, mode: 'insensitive' } },
]
: undefined,
userLicenses: query.licenseCode
? { some: { license: { code: query.licenseCode } } }
: undefined,
},
orderBy: { createdAt: 'desc' },
include: {
userLicenses: {
orderBy: { startsAt: 'desc' },
include: { license: true },
},
},
});
const now = new Date();
return {
users: users.map((user) => ({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isActive: user.isActive,
currentLicense:
user.userLicenses.find((userLicense) =>
isActiveLicense(userLicense.startsAt, userLicense.expiresAt, now),
) ?? null,
licenses: user.userLicenses.map((userLicense) => ({
...userLicense,
status: isActiveLicense(userLicense.startsAt, userLicense.expiresAt, now)
? '활성'
: '만료',
})),
})),
};
}
async assign(userId: string, body: AssignLicenseDto) {
const user = await this.authPrisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!user) {
throw new NotFoundException('사용자를 찾을 수 없습니다.');
}
const activeLicense = await this.authPrisma.userLicense.findFirst({
where: {
userId,
startsAt: { lte: new Date() },
OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }],
},
select: { id: true },
});
if (activeLicense) {
throw new ConflictException('이미 활성 라이센스가 있습니다. 기존 이력을 수정하세요.');
}
const license = await this.authPrisma.license.findUnique({
where: { code: body.licenseCode },
});
if (!license) {
throw new NotFoundException('라이센스 플랜을 찾을 수 없습니다.');
}
const startsAt = body.startsAt ? new Date(body.startsAt) : new Date();
const expiresAt = body.expiresAt
? new Date(body.expiresAt)
: calculateExpiresAt(startsAt, license.defaultDurationDays);
const userLicense = await this.authPrisma.userLicense.create({
data: {
userId,
licenseId: license.id,
startsAt,
expiresAt,
assignedById: body.assignedById,
},
include: { license: true, user: true },
});
return { userLicense };
}
async update(id: string, body: AssignLicenseDto) {
const current = await this.authPrisma.userLicense.findUnique({
where: { id },
select: { id: true },
});
if (!current) {
throw new NotFoundException('사용자 라이센스를 찾을 수 없습니다.');
}
const license = await this.authPrisma.license.findUnique({
where: { code: body.licenseCode },
});
if (!license) {
throw new NotFoundException('라이센스 플랜을 찾을 수 없습니다.');
}
const startsAt = body.startsAt ? new Date(body.startsAt) : new Date();
const expiresAt = body.expiresAt
? new Date(body.expiresAt)
: calculateExpiresAt(startsAt, license.defaultDurationDays);
const userLicense = await this.authPrisma.userLicense.update({
where: { id },
data: {
licenseId: license.id,
startsAt,
expiresAt,
assignedById: body.assignedById,
},
include: { license: true, user: true },
});
return { userLicense };
}
async revoke(id: string) {
const userLicense = await this.authPrisma.userLicense.findUnique({
where: { id },
select: { id: true },
});
if (!userLicense) {
throw new NotFoundException('사용자 라이센스를 찾을 수 없습니다.');
}
await this.authPrisma.userLicense.delete({
where: { id },
});
return { ok: true };
}
}
function calculateExpiresAt(startsAt: Date, durationDays?: number | null) {
if (!durationDays) {
return null;
}
const expiresAt = new Date(startsAt);
expiresAt.setDate(expiresAt.getDate() + durationDays);
return expiresAt;
}
function isActiveLicense(startsAt: Date, expiresAt: Date | null, now: Date) {
return startsAt <= now && (!expiresAt || expiresAt >= now);
}
function expiresSoon(expiresAt: Date | null, now: Date) {
if (!expiresAt) {
return false;
}
const diffDays = (expiresAt.getTime() - now.getTime()) / 1000 / 60 / 60 / 24;
return diffDays >= 0 && diffDays <= 30;
}