This commit is contained in:
@@ -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 $$;
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,6 +8,14 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListLauncherNoticesQuery {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
page?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
limit?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appKey?: string;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user