diff --git a/prisma/auth.schema.prisma b/prisma/auth.schema.prisma index 2939638..8eb97cf 100644 --- a/prisma/auth.schema.prisma +++ b/prisma/auth.schema.prisma @@ -71,11 +71,26 @@ model License { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) userLicenses UserLicense[] + appPermissions LicenseAppPermission[] @@index([isActive]) @@map("licenses") } +model LicenseAppPermission { + id String @id @default(uuid()) @db.Uuid + licenseId String @map("license_id") @db.Uuid + appKey String @map("app_key") + canLaunch Boolean @default(true) @map("can_launch") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + license License @relation(fields: [licenseId], references: [id], onDelete: Cascade) + + @@unique([licenseId, appKey]) + @@index([appKey]) + @@map("license_app_permissions") +} + model UserLicense { id String @id @default(uuid()) @db.Uuid userId String @map("user_id") @db.Uuid @@ -97,6 +112,7 @@ model Organization { name String type OrganizationType @default(OTHER) domain String? + defaultLicenseCode String? @map("default_license_code") isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) diff --git a/prisma/migrations/20260710104000_add_organization_default_license/migration.sql b/prisma/migrations/20260710104000_add_organization_default_license/migration.sql new file mode 100644 index 0000000..adb6c4d --- /dev/null +++ b/prisma/migrations/20260710104000_add_organization_default_license/migration.sql @@ -0,0 +1,7 @@ +DO $$ +BEGIN + IF to_regclass('public.organizations') IS NOT NULL THEN + ALTER TABLE "organizations" + ADD COLUMN IF NOT EXISTS "default_license_code" TEXT; + END IF; +END $$; diff --git a/prisma/migrations/20260710105500_add_license_app_permissions/migration.sql b/prisma/migrations/20260710105500_add_license_app_permissions/migration.sql new file mode 100644 index 0000000..5680603 --- /dev/null +++ b/prisma/migrations/20260710105500_add_license_app_permissions/migration.sql @@ -0,0 +1,19 @@ +DO $$ +BEGIN + IF to_regclass('public.licenses') IS NOT NULL THEN + CREATE TABLE IF NOT EXISTS "license_app_permissions" ( + "id" UUID NOT NULL, + "license_id" UUID NOT NULL, + "app_key" TEXT NOT NULL, + "can_launch" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "license_app_permissions_pkey" PRIMARY KEY ("id"), + CONSTRAINT "license_app_permissions_license_id_fkey" FOREIGN KEY ("license_id") REFERENCES "licenses"("id") ON DELETE CASCADE ON UPDATE CASCADE + ); + + CREATE UNIQUE INDEX IF NOT EXISTS "license_app_permissions_license_id_app_key_key" ON "license_app_permissions"("license_id", "app_key"); + CREATE INDEX IF NOT EXISTS "license_app_permissions_app_key_idx" ON "license_app_permissions"("app_key"); + END IF; +END $$; diff --git a/src/admins/admins.controller.ts b/src/admins/admins.controller.ts index ae47162..1d701b8 100644 --- a/src/admins/admins.controller.ts +++ b/src/admins/admins.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { CreateAdminSshKeyDto, CreateAdminDto, + ListAdminsQuery, ResetAdminPasswordDto, UpdateAdminAppPermissionsDto, UpdateAdminRoleDto, @@ -23,8 +24,8 @@ export class AdminsController { } @Get() - list() { - return this.adminsService.list(); + list(@Query() query: ListAdminsQuery) { + return this.adminsService.list(query); } @Post() diff --git a/src/admins/admins.service.ts b/src/admins/admins.service.ts index 06039b5..18a4af1 100644 --- a/src/admins/admins.service.ts +++ b/src/admins/admins.service.ts @@ -11,6 +11,7 @@ import { AdminRole } from './admin-role'; import { CreateAdminSshKeyDto, CreateAdminDto, + ListAdminsQuery, ResetAdminPasswordDto, UpdateAdminAppPermissionsDto, UpdateAdminRoleDto, @@ -43,9 +44,29 @@ export class AdminsService { }); } - async list() { - const admins = await this.prisma.admin.findMany({ + async list(query: ListAdminsQuery = {}) { + const page = toPositiveInt(query.page, 1); + const limit = Math.min(toPositiveInt(query.limit, 20), 100); + const keyword = query.keyword?.trim(); + const where = { + ...(query.role ? { role: query.role as AdminRole } : {}), + ...(query.state === 'active' ? { isActive: true } : query.state === 'inactive' ? { isActive: false } : {}), + ...(keyword + ? { + OR: [ + { email: { contains: keyword, mode: 'insensitive' as const } }, + { name: { contains: keyword, mode: 'insensitive' as const } }, + ], + } + : {}), + }; + + const [admins, total] = await this.prisma.$transaction([ + this.prisma.admin.findMany({ + where, orderBy: [{ role: 'asc' }, { createdAt: 'desc' }], + skip: (page - 1) * limit, + take: limit, select: { id: true, email: true, @@ -73,7 +94,9 @@ export class AdminsService { select: { appId: true }, }, }, - }); + }), + this.prisma.admin.count({ where }), + ]); const users = await this.authPrisma.user.findMany({ where: { email: { in: admins.map((admin) => admin.email) } }, @@ -89,13 +112,19 @@ export class AdminsService { ?? this.configService.get('AUTH_EXTERNAL_URL') ?? 'https://auth.autonomystudio.net'; - return admins.map((admin) => { + return { + admins: admins.map((admin, index) => { const user = userByEmail.get(admin.email); return { ...admin, + no: total - ((page - 1) * limit + index), avatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl), }; - }); + }), + total, + page, + limit, + }; } async create(body: CreateAdminDto) { @@ -124,7 +153,12 @@ export class AdminsService { } async updateStatus(id: string, body: UpdateAdminStatusDto) { - await this.ensureExists(id); + const existing = await this.ensureExists(id); + + await this.giteaService.updateUserLoginDisabled( + createGiteaUsername(existing.email), + !body.isActive, + ); const admin = await this.prisma.admin.update({ where: { id }, @@ -345,6 +379,12 @@ function hashPassword(password: string) { return `scrypt:${salt}:${hash}`; } +function toPositiveInt(value: string | number | undefined, fallback: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.floor(parsed); +} + function createGiteaUsername(email: string) { const account = email.split('@')[0] || email; const username = account diff --git a/src/admins/dto.ts b/src/admins/dto.ts index 3bc939f..adbefcb 100644 --- a/src/admins/dto.ts +++ b/src/admins/dto.ts @@ -1,6 +1,28 @@ import { IsArray, IsBoolean, IsEmail, IsEnum, IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; import { AdminRole } from './admin-role'; +export class ListAdminsQuery { + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; + + @IsOptional() + @IsString() + keyword?: string; + + @IsOptional() + @IsString() + role?: string; + + @IsOptional() + @IsString() + state?: string; +} + export class CreateAdminDto { @IsEmail() email!: string; diff --git a/src/gitea/gitea.service.ts b/src/gitea/gitea.service.ts index 5bc0178..52fe4e3 100644 --- a/src/gitea/gitea.service.ts +++ b/src/gitea/gitea.service.ts @@ -257,6 +257,22 @@ export class GiteaService { ); } + async updateUserLoginDisabled(username: string, disabled: boolean) { + this.assertToken(); + + await this.request( + `/api/v1/admin/users/${encodeURIComponent(username)}`, + { + method: 'PATCH', + body: JSON.stringify({ + login_name: username, + prohibit_login: disabled, + }), + allowedStatuses: [404], + }, + ); + } + async updateUserAvatar(username: string, imageData: Buffer, mimeType: string) { this.assertToken(); diff --git a/src/licenses/dto.ts b/src/licenses/dto.ts index 50c7293..b80de6f 100644 --- a/src/licenses/dto.ts +++ b/src/licenses/dto.ts @@ -1,8 +1,17 @@ -import { IsEnum, IsISO8601, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsISO8601, IsOptional, IsString, Min, ValidateNested } from 'class-validator'; import { LicenseCode } from './license-enums'; export class ListUserLicensesQuery { + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; + @IsOptional() @IsString() keyword?: string; @@ -10,6 +19,10 @@ export class ListUserLicensesQuery { @IsOptional() @IsEnum(LicenseCode) licenseCode?: LicenseCode; + + @IsOptional() + @IsString() + status?: string; } export class AssignLicenseDto { @@ -28,3 +41,44 @@ export class AssignLicenseDto { @IsString() assignedById?: string; } + +export class UpdateLicenseAppPermissionDto { + @IsString() + appKey!: string; + + @IsBoolean() + canLaunch!: boolean; +} + +export class UpdateLicenseAppPermissionsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UpdateLicenseAppPermissionDto) + permissions!: UpdateLicenseAppPermissionDto[]; +} + +export class UpdateLicensePlanDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + defaultDurationDays?: number | null; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UpdateLicenseAppPermissionDto) + permissions?: UpdateLicenseAppPermissionDto[]; +} diff --git a/src/licenses/licenses.controller.ts b/src/licenses/licenses.controller.ts index 7d7fb63..0c269de 100644 --- a/src/licenses/licenses.controller.ts +++ b/src/licenses/licenses.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; -import { AssignLicenseDto, ListUserLicensesQuery } from './dto'; +import { AssignLicenseDto, ListUserLicensesQuery, UpdateLicenseAppPermissionsDto, UpdateLicensePlanDto } from './dto'; import { LicensesService } from './licenses.service'; @Controller('licenses') @@ -12,11 +12,31 @@ export class LicensesController { return this.licensesService.listPlans(); } + @Get('plans/:code/app-permissions') + listPlanAppPermissions(@Param('code') code: string) { + return this.licensesService.listPlanAppPermissions(code); + } + + @Put('plans/:code/app-permissions') + updatePlanAppPermissions(@Param('code') code: string, @Body() body: UpdateLicenseAppPermissionsDto) { + return this.licensesService.updatePlanAppPermissions(code, body); + } + + @Patch('plans/:code') + updatePlan(@Param('code') code: string, @Body() body: UpdateLicensePlanDto) { + return this.licensesService.updatePlan(code, body); + } + @Get('users') listUserLicenses(@Query() query: ListUserLicensesQuery) { return this.licensesService.listUserLicenses(query); } + @Get('assignments') + listAssignments(@Query() query: ListUserLicensesQuery) { + return this.licensesService.listAssignments(query); + } + @Post('users/:userId') assign(@Param('userId') userId: string, @Body() body: AssignLicenseDto) { return this.licensesService.assign(userId, body); diff --git a/src/licenses/licenses.module.ts b/src/licenses/licenses.module.ts index f67221d..2b3e2a2 100644 --- a/src/licenses/licenses.module.ts +++ b/src/licenses/licenses.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module'; +import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module'; import { LicensesController } from './licenses.controller'; import { LicensesService } from './licenses.service'; @Module({ - imports: [AuthPrismaModule], + imports: [AuthPrismaModule, LauncherPrismaModule], controllers: [LicensesController], providers: [LicensesService], }) diff --git a/src/licenses/licenses.service.ts b/src/licenses/licenses.service.ts index c1e721b..361ad52 100644 --- a/src/licenses/licenses.service.ts +++ b/src/licenses/licenses.service.ts @@ -1,11 +1,24 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; -import { AssignLicenseDto, ListUserLicensesQuery } from './dto'; +import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service'; +import { AssignLicenseDto, ListUserLicensesQuery, UpdateLicenseAppPermissionsDto, UpdateLicensePlanDto } from './dto'; + +type LauncherAppPermissionRow = { + id: string; + app_key: string; + display_name: string; + description: string | null; + sort_order: number; + is_enabled: boolean; +}; @Injectable() export class LicensesService { - constructor(private readonly authPrisma: AuthPrismaService) {} + constructor( + private readonly authPrisma: AuthPrismaService, + private readonly launcherPrisma: LauncherPrismaService, + ) {} async listPlans() { const now = new Date(); @@ -91,6 +104,181 @@ export class LicensesService { }; } + async listAssignments(query: ListUserLicensesQuery) { + const keyword = query.keyword?.trim(); + const page = toPositiveInt(query.page, 1); + const limit = Math.min(toPositiveInt(query.limit, 20), 100); + const now = new Date(); + const where = { + ...(query.licenseCode ? { license: { code: query.licenseCode } } : {}), + ...(keyword + ? { + OR: [ + { user: { email: { contains: keyword, mode: 'insensitive' as const } } }, + { user: { name: { contains: keyword, mode: 'insensitive' as const } } }, + { license: { name: { contains: keyword, mode: 'insensitive' as const } } }, + ], + } + : {}), + ...(query.status === 'active' + ? { + startsAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gte: now } }], + } + : query.status === 'expired' + ? { expiresAt: { lt: now } } + : {}), + }; + + const [items, total] = await this.authPrisma.$transaction([ + this.authPrisma.userLicense.findMany({ + where, + orderBy: { startsAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + include: { license: true, user: true }, + }), + this.authPrisma.userLicense.count({ where }), + ]); + + return { + assignments: items.map((userLicense, index) => ({ + id: userLicense.id, + no: total - ((page - 1) * limit + index), + userId: userLicense.userId, + user: userLicense.user.email, + name: userLicense.user.name, + role: userLicense.user.role, + code: userLicense.license.code, + licenseName: userLicense.license.name, + startsAt: userLicense.startsAt, + expiresAt: userLicense.expiresAt, + assignedBy: userLicense.assignedById, + status: isActiveLicense(userLicense.startsAt, userLicense.expiresAt, now) ? '활성' : '만료', + })), + total, + page, + limit, + }; + } + + async listPlanAppPermissions(code: string) { + const license = await this.findLicenseByCode(code); + const apps = await this.listEnabledLauncherApps(); + const policies = await this.authPrisma.licenseAppPermission.findMany({ + where: { licenseId: license.id }, + }); + const policyByAppKey = new Map(policies.map((policy) => [policy.appKey, policy])); + + return { + license: { + id: license.id, + code: license.code, + name: license.name, + }, + permissions: apps.map((app) => { + const policy = policyByAppKey.get(app.app_key); + return { + appId: app.id, + appKey: app.app_key, + displayName: app.display_name, + description: app.description, + sortOrder: app.sort_order, + isEnabled: app.is_enabled, + policyId: policy?.id ?? null, + canView: true, + canLaunch: policy?.canLaunch ?? true, + }; + }), + }; + } + + async updatePlanAppPermissions(code: string, body: UpdateLicenseAppPermissionsDto) { + const license = await this.findLicenseByCode(code); + const updatedUserCount = await this.savePlanAppPermissions(license.id, body.permissions); + + return { + ...(await this.listPlanAppPermissions(code)), + updatedUserCount, + }; + } + + async updatePlan(code: string, body: UpdateLicensePlanDto) { + const license = await this.findLicenseByCode(code); + const data: { + name?: string; + description?: string | null; + defaultDurationDays?: number | null; + isActive?: boolean; + } = {}; + + if (body.name !== undefined) data.name = body.name.trim(); + if (body.description !== undefined) data.description = body.description?.trim() || null; + if (body.defaultDurationDays !== undefined) data.defaultDurationDays = body.defaultDurationDays; + if (body.isActive !== undefined) data.isActive = body.isActive; + + const [planResult, updatedUserCount] = await Promise.all([ + this.authPrisma.license.update({ + where: { id: license.id }, + data, + }), + body.permissions ? this.savePlanAppPermissions(license.id, body.permissions) : Promise.resolve(0), + ]); + + return { + plan: { + id: planResult.id, + code: planResult.code, + name: planResult.name, + description: planResult.description, + defaultDurationDays: planResult.defaultDurationDays, + isActive: planResult.isActive, + createdAt: planResult.createdAt, + updatedAt: planResult.updatedAt, + }, + updatedUserCount, + }; + } + + private async savePlanAppPermissions( + licenseId: string, + permissions: Array<{ appKey: string; canLaunch: boolean }>, + ) { + const apps = await this.listEnabledLauncherApps(); + const appByKey = new Map(apps.map((app) => [app.app_key, app])); + const inputByAppKey = new Map( + permissions.map((permission) => [permission.appKey, permission.canLaunch]), + ); + const invalidKeys = [...inputByAppKey.keys()].filter((appKey) => !appByKey.has(appKey)); + + if (invalidKeys.length) { + throw new BadRequestException(`알 수 없는 앱입니다: ${invalidKeys.join(', ')}`); + } + + await this.authPrisma.$transaction(async (tx) => { + await tx.licenseAppPermission.deleteMany({ where: { licenseId } }); + await tx.licenseAppPermission.createMany({ + data: apps.map((app) => ({ + licenseId, + appKey: app.app_key, + canLaunch: inputByAppKey.get(app.app_key) ?? true, + })), + }); + }); + + const userIds = await this.listActiveLicenseUserIds(licenseId); + await this.applyAppPermissionsToUsers( + userIds, + apps.map((app) => ({ + appId: app.id, + appKey: app.app_key, + canLaunch: inputByAppKey.get(app.app_key) ?? true, + })), + ); + + return userIds.length; + } + async assign(userId: string, body: AssignLicenseDto) { const user = await this.authPrisma.user.findUnique({ where: { id: userId }, @@ -138,6 +326,8 @@ export class LicensesService { include: { license: true, user: true }, }); + await this.applyLicensePolicyToUsers(license.id, [userId]); + return { userLicense }; } @@ -175,13 +365,15 @@ export class LicensesService { include: { license: true, user: true }, }); + await this.applyLicensePolicyToUsers(license.id, [userLicense.userId]); + return { userLicense }; } async revoke(id: string) { const userLicense = await this.authPrisma.userLicense.findUnique({ where: { id }, - select: { id: true }, + select: { id: true, userId: true }, }); if (!userLicense) { @@ -192,8 +384,117 @@ export class LicensesService { where: { id }, }); + await this.refreshUserAppPermissionsByCurrentLicense(userLicense.userId); + return { ok: true }; } + + private async findLicenseByCode(code: string) { + const license = await this.authPrisma.license.findUnique({ + where: { code: code as never }, + select: { id: true, code: true, name: true }, + }); + + if (!license) { + throw new NotFoundException('라이센스 플랜을 찾을 수 없습니다.'); + } + + return license; + } + + private listEnabledLauncherApps() { + return this.launcherPrisma.$queryRawUnsafe(` + SELECT id, app_key, display_name, description, sort_order, is_enabled + FROM launcher_apps + WHERE is_enabled = true + ORDER BY sort_order ASC, display_name ASC + `); + } + + private async listActiveLicenseUserIds(licenseId: string) { + const now = new Date(); + const rows = await this.authPrisma.userLicense.findMany({ + where: { + licenseId, + startsAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gte: now } }], + user: { isActive: true }, + }, + select: { userId: true }, + distinct: ['userId'], + }); + + return rows.map((row) => row.userId); + } + + private async applyLicensePolicyToUsers(licenseId: string, userIds: string[]) { + if (!userIds.length) return; + + const apps = await this.listEnabledLauncherApps(); + const policies = await this.authPrisma.licenseAppPermission.findMany({ + where: { licenseId }, + }); + const policyByAppKey = new Map(policies.map((policy) => [policy.appKey, policy.canLaunch])); + + await this.applyAppPermissionsToUsers( + userIds, + apps.map((app) => ({ + appId: app.id, + appKey: app.app_key, + canLaunch: policyByAppKey.get(app.app_key) ?? true, + })), + ); + } + + private async refreshUserAppPermissionsByCurrentLicense(userId: string) { + const now = new Date(); + const activeLicense = await this.authPrisma.userLicense.findFirst({ + where: { + userId, + startsAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gte: now } }], + }, + orderBy: { startsAt: 'desc' }, + select: { licenseId: true }, + }); + + if (activeLicense) { + await this.applyLicensePolicyToUsers(activeLicense.licenseId, [userId]); + return; + } + + await this.launcherPrisma.$executeRawUnsafe( + `DELETE FROM launcher_user_apps WHERE auth_user_id = $1::uuid`, + userId, + ); + } + + private async applyAppPermissionsToUsers( + userIds: string[], + permissions: Array<{ appId: string; appKey: string; canLaunch: boolean }>, + ) { + if (!userIds.length || !permissions.length) return; + + for (const userId of userIds) { + for (const permission of permissions) { + await this.launcherPrisma.$executeRawUnsafe( + `INSERT INTO launcher_user_apps ( + auth_user_id, app_id, can_view, can_launch, assigned_at, created_at, updated_at + ) VALUES ( + $1::uuid, $2::uuid, true, $3, now(), now(), now() + ) + ON CONFLICT (auth_user_id, app_id) DO UPDATE SET + can_view = true, + can_launch = EXCLUDED.can_launch, + assigned_at = now(), + updated_at = now()`, + userId, + permission.appId, + permission.canLaunch, + ); + } + } + } } function calculateExpiresAt(startsAt: Date, durationDays?: number | null) { @@ -218,3 +519,9 @@ function expiresSoon(expiresAt: Date | null, now: Date) { const diffDays = (expiresAt.getTime() - now.getTime()) / 1000 / 60 / 60 / 24; return diffDays >= 0 && diffDays <= 30; } + +function toPositiveInt(value: string | number | undefined, fallback: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.floor(parsed); +} diff --git a/src/notices/dto.ts b/src/notices/dto.ts index 335ed42..6c1f9e5 100644 --- a/src/notices/dto.ts +++ b/src/notices/dto.ts @@ -8,6 +8,14 @@ import { } from 'class-validator'; export class ListLauncherNoticesQuery { + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; + @IsOptional() @IsString() appKey?: string; diff --git a/src/notices/notices.service.ts b/src/notices/notices.service.ts index db57ae3..978ed89 100644 --- a/src/notices/notices.service.ts +++ b/src/notices/notices.service.ts @@ -65,33 +65,44 @@ export class NoticesService { async list(query: ListLauncherNoticesQuery) { const keyword = query.keyword?.trim(); const now = new Date(); + const page = toPositiveInt(query.page, 1); + const limit = Math.min(toPositiveInt(query.limit, 20), 100); + const where = { + appKey: query.appKey || undefined, + isActive: + query.state === 'active' + ? true + : query.state === 'inactive' + ? false + : undefined, + OR: keyword + ? [ + { title: { contains: keyword, mode: 'insensitive' as const } }, + { body: { contains: keyword, mode: 'insensitive' as const } }, + { appKey: { contains: keyword, mode: 'insensitive' as const } }, + ] + : undefined, + }; - const notices = await this.launcherPrisma.launcherNotice.findMany({ - where: { - appKey: query.appKey || undefined, - isActive: - query.state === 'active' - ? true - : query.state === 'inactive' - ? false - : undefined, - OR: keyword - ? [ - { title: { contains: keyword, mode: 'insensitive' } }, - { body: { contains: keyword, mode: 'insensitive' } }, - { appKey: { contains: keyword, mode: 'insensitive' } }, - ] - : undefined, - }, + const [notices, total] = await this.launcherPrisma.$transaction([ + this.launcherPrisma.launcherNotice.findMany({ + where, orderBy: { createdAt: 'desc' }, - }); + skip: (page - 1) * limit, + take: limit, + }), + this.launcherPrisma.launcherNotice.count({ where }), + ]); return { notices: await this.withCreatorAvatars(notices.map((notice, index) => ({ ...notice, - no: notices.length - index, + no: total - ((page - 1) * limit + index), status: getNoticeStatus(notice, now), }))), + total, + page, + limit, }; } @@ -199,3 +210,9 @@ function getNoticeStatus( return '게시중'; } + +function toPositiveInt(value: string | number | undefined, fallback: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.floor(parsed); +} diff --git a/src/organizations/dto.ts b/src/organizations/dto.ts index 94f9ad1..024f33a 100644 --- a/src/organizations/dto.ts +++ b/src/organizations/dto.ts @@ -1,5 +1,23 @@ import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; +export class ListOrganizationsQuery { + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; + + @IsOptional() + @IsString() + keyword?: string; + + @IsOptional() + @IsString() + type?: string; +} + export class CreateOrganizationDto { @IsString() @MaxLength(120) @@ -29,3 +47,9 @@ export class UpdateOrganizationDto { @MaxLength(120) domain?: string | null; } + +export class UpdateOrganizationLicenseDto { + @IsOptional() + @IsIn(['FREE', 'EDU', 'PRO']) + licenseCode?: 'FREE' | 'EDU' | 'PRO' | null; +} diff --git a/src/organizations/organizations.controller.ts b/src/organizations/organizations.controller.ts index 856a110..97f53bb 100644 --- a/src/organizations/organizations.controller.ts +++ b/src/organizations/organizations.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req } from '@nestjs/common'; -import { CreateOrganizationDto, UpdateOrganizationDto } from './dto'; +import { CreateOrganizationDto, ListOrganizationsQuery, UpdateOrganizationDto, UpdateOrganizationLicenseDto } from './dto'; import { OrganizationsService } from './organizations.service'; @Controller('organizations') @@ -8,8 +8,8 @@ export class OrganizationsController { constructor(private readonly organizationsService: OrganizationsService) {} @Get() - list() { - return this.organizationsService.list(); + list(@Query() query: ListOrganizationsQuery) { + return this.organizationsService.list(query); } @Post() @@ -22,6 +22,15 @@ export class OrganizationsController { return this.organizationsService.update(id, body); } + @Patch(':id/license') + updateLicense( + @Param('id') id: string, + @Body() body: UpdateOrganizationLicenseDto, + @Req() request: { admin?: { id: string } }, + ) { + return this.organizationsService.updateLicense(id, body, request.admin?.id); + } + @Delete(':id') remove(@Param('id') id: string) { return this.organizationsService.remove(id); diff --git a/src/organizations/organizations.service.ts b/src/organizations/organizations.service.ts index be82c61..54557b8 100644 --- a/src/organizations/organizations.service.ts +++ b/src/organizations/organizations.service.ts @@ -2,26 +2,61 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common import { Prisma } from '@prisma/auth-client'; import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; -import { CreateOrganizationDto, UpdateOrganizationDto } from './dto'; +import { CreateOrganizationDto, ListOrganizationsQuery, UpdateOrganizationDto, UpdateOrganizationLicenseDto } from './dto'; @Injectable() export class OrganizationsService { constructor(private readonly authPrisma: AuthPrismaService) {} - async list() { - const organizations = await this.authPrisma.organization.findMany({ - where: { isActive: true }, + async list(query: ListOrganizationsQuery = {}) { + const page = toPositiveInt(query.page, 1); + const limit = Math.min(toPositiveInt(query.limit, 20), 100); + const keyword = query.keyword?.trim(); + const where = { + isActive: true, + ...(query.type ? { type: query.type as 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER' } : {}), + ...(keyword + ? { + OR: [ + { name: { contains: keyword, mode: 'insensitive' as const } }, + { domain: { contains: keyword, mode: 'insensitive' as const } }, + ], + } + : {}), + }; + + const [organizations, total, licenses] = await this.authPrisma.$transaction([ + this.authPrisma.organization.findMany({ + where, orderBy: [{ type: 'asc' }, { name: 'asc' }], + skip: (page - 1) * limit, + take: limit, select: { id: true, name: true, type: true, domain: true, + defaultLicenseCode: true, createdAt: true, }, - }); + }), + this.authPrisma.organization.count({ where }), + this.authPrisma.license.findMany({ + select: { code: true, name: true }, + }), + ]); + const licenseNameByCode = new Map(licenses.map((license) => [String(license.code), license.name])); - return { organizations }; + return { + organizations: organizations.map((organization, index) => ({ + ...organization, + defaultLicenseName: organization.defaultLicenseCode ? licenseNameByCode.get(organization.defaultLicenseCode) ?? organization.defaultLicenseCode : null, + no: total - ((page - 1) * limit + index), + })), + total, + page, + limit, + }; } async create(body: CreateOrganizationDto) { @@ -37,6 +72,7 @@ export class OrganizationsService { name: true, type: true, domain: true, + defaultLicenseCode: true, createdAt: true, }, }); @@ -68,6 +104,7 @@ export class OrganizationsService { name: true, type: true, domain: true, + defaultLicenseCode: true, createdAt: true, }, }); @@ -111,4 +148,98 @@ export class OrganizationsService { throw error; } } + + async updateLicense(id: string, body: UpdateOrganizationLicenseDto, assignedById?: string) { + const licenseCode = body.licenseCode ?? null; + const now = new Date(); + + const organization = await this.authPrisma.organization.findUnique({ + where: { id }, + select: { id: true, defaultLicenseCode: true }, + }); + + if (!organization) { + throw new NotFoundException('조직을 찾을 수 없습니다.'); + } + + const license = licenseCode + ? await this.authPrisma.license.findUnique({ + where: { code: licenseCode }, + select: { id: true, code: true, name: true, defaultDurationDays: true }, + }) + : null; + + if (licenseCode && !license) { + throw new NotFoundException('라이센스 플랜을 찾을 수 없습니다.'); + } + + const userIds = await this.authPrisma.userAffiliation.findMany({ + where: { + organizationId: id, + OR: [{ endsAt: null }, { endsAt: { gt: now } }], + }, + distinct: ['userId'], + select: { userId: true }, + }); + const targetUserIds = userIds.map((item) => item.userId); + + const updated = await this.authPrisma.$transaction(async (tx) => { + const nextOrganization = await tx.organization.update({ + where: { id }, + data: { defaultLicenseCode: licenseCode }, + select: { + id: true, + name: true, + type: true, + domain: true, + defaultLicenseCode: true, + createdAt: true, + }, + }); + + if (license && targetUserIds.length > 0) { + await tx.userLicense.updateMany({ + where: { + userId: { in: targetUserIds }, + startsAt: { lte: now }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + data: { expiresAt: now }, + }); + + await tx.userLicense.createMany({ + data: targetUserIds.map((userId) => ({ + userId, + licenseId: license.id, + startsAt: now, + expiresAt: calculateExpiresAt(now, license.defaultDurationDays), + assignedById, + })), + }); + } + + return nextOrganization; + }); + + return { + organization: { + ...updated, + defaultLicenseName: license?.name ?? null, + }, + updatedUserCount: license ? targetUserIds.length : 0, + }; + } +} + +function toPositiveInt(value: string | number | undefined, fallback: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.floor(parsed); +} + +function calculateExpiresAt(startsAt: Date, durationDays?: number | null) { + if (!durationDays) return null; + const expiresAt = new Date(startsAt); + expiresAt.setDate(expiresAt.getDate() + durationDays); + return expiresAt; } diff --git a/src/users/dto.ts b/src/users/dto.ts index 6e102bd..058604e 100644 --- a/src/users/dto.ts +++ b/src/users/dto.ts @@ -1,6 +1,14 @@ import { IsEmail, IsIn, IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; export class ListUserSessionsQuery { + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; + @IsOptional() @IsString() keyword?: string; diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 08af140..8c81b9c 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -42,6 +42,8 @@ export class UsersService { async listSessions(query: ListUserSessionsQuery) { const keyword = query.keyword?.trim(); const now = new Date(); + const page = toPositiveInt(query.page, 1); + const limit = Math.min(toPositiveInt(query.limit, 20), 100); const users = await this.authPrisma.user.findMany({ where: keyword @@ -140,12 +142,16 @@ export class UsersService { const filteredRows = query.state ? rows.filter((row) => row.state === query.state) : rows; + const pageRows = filteredRows.slice((page - 1) * limit, page * limit); return { - sessions: filteredRows.map((row, index) => ({ + sessions: pageRows.map((row, index) => ({ ...row, - no: filteredRows.length - index, + no: filteredRows.length - ((page - 1) * limit + index), })), + total: filteredRows.length, + page, + limit, }; } @@ -325,3 +331,9 @@ function getSessionState( return '유효토큰'; } + +function toPositiveInt(value: string | number | undefined, fallback: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.floor(parsed); +}