manage license app launch policies
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-10 13:14:29 +09:00
parent 6311f8a553
commit ada3309db4
18 changed files with 760 additions and 48 deletions
+16
View File
@@ -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)
@@ -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 $$;
@@ -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 $$;
+4 -3
View File
@@ -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()
+46 -6
View File
@@ -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<string>('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
+22
View File
@@ -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;
+16
View File
@@ -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();
+55 -1
View File
@@ -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[];
}
+22 -2
View File
@@ -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);
+2 -1
View File
@@ -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],
})
+311 -4
View File
@@ -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<LauncherAppPermissionRow[]>(`
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);
}
+8
View File
@@ -8,6 +8,14 @@ import {
} from 'class-validator';
export class ListLauncherNoticesQuery {
@IsOptional()
@IsString()
page?: string;
@IsOptional()
@IsString()
limit?: string;
@IsOptional()
@IsString()
appKey?: string;
+36 -19
View File
@@ -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);
}
+24
View File
@@ -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;
}
+13 -4
View File
@@ -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);
+137 -6
View File
@@ -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;
}
+8
View File
@@ -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;
+14 -2
View File
@@ -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);
}