This commit is contained in:
@@ -71,11 +71,26 @@ model License {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
userLicenses UserLicense[]
|
userLicenses UserLicense[]
|
||||||
|
appPermissions LicenseAppPermission[]
|
||||||
|
|
||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
@@map("licenses")
|
@@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 {
|
model UserLicense {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
@@ -97,6 +112,7 @@ model Organization {
|
|||||||
name String
|
name String
|
||||||
type OrganizationType @default(OTHER)
|
type OrganizationType @default(OTHER)
|
||||||
domain String?
|
domain String?
|
||||||
|
defaultLicenseCode String? @map("default_license_code")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_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 {
|
import {
|
||||||
CreateAdminSshKeyDto,
|
CreateAdminSshKeyDto,
|
||||||
CreateAdminDto,
|
CreateAdminDto,
|
||||||
|
ListAdminsQuery,
|
||||||
ResetAdminPasswordDto,
|
ResetAdminPasswordDto,
|
||||||
UpdateAdminAppPermissionsDto,
|
UpdateAdminAppPermissionsDto,
|
||||||
UpdateAdminRoleDto,
|
UpdateAdminRoleDto,
|
||||||
@@ -23,8 +24,8 @@ export class AdminsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list() {
|
list(@Query() query: ListAdminsQuery) {
|
||||||
return this.adminsService.list();
|
return this.adminsService.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AdminRole } from './admin-role';
|
|||||||
import {
|
import {
|
||||||
CreateAdminSshKeyDto,
|
CreateAdminSshKeyDto,
|
||||||
CreateAdminDto,
|
CreateAdminDto,
|
||||||
|
ListAdminsQuery,
|
||||||
ResetAdminPasswordDto,
|
ResetAdminPasswordDto,
|
||||||
UpdateAdminAppPermissionsDto,
|
UpdateAdminAppPermissionsDto,
|
||||||
UpdateAdminRoleDto,
|
UpdateAdminRoleDto,
|
||||||
@@ -43,9 +44,29 @@ export class AdminsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async list() {
|
async list(query: ListAdminsQuery = {}) {
|
||||||
const admins = await this.prisma.admin.findMany({
|
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' }],
|
orderBy: [{ role: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
@@ -73,7 +94,9 @@ export class AdminsService {
|
|||||||
select: { appId: true },
|
select: { appId: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
this.prisma.admin.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
const users = await this.authPrisma.user.findMany({
|
const users = await this.authPrisma.user.findMany({
|
||||||
where: { email: { in: admins.map((admin) => admin.email) } },
|
where: { email: { in: admins.map((admin) => admin.email) } },
|
||||||
@@ -89,13 +112,19 @@ export class AdminsService {
|
|||||||
?? this.configService.get<string>('AUTH_EXTERNAL_URL')
|
?? this.configService.get<string>('AUTH_EXTERNAL_URL')
|
||||||
?? 'https://auth.autonomystudio.net';
|
?? 'https://auth.autonomystudio.net';
|
||||||
|
|
||||||
return admins.map((admin) => {
|
return {
|
||||||
|
admins: admins.map((admin, index) => {
|
||||||
const user = userByEmail.get(admin.email);
|
const user = userByEmail.get(admin.email);
|
||||||
return {
|
return {
|
||||||
...admin,
|
...admin,
|
||||||
|
no: total - ((page - 1) * limit + index),
|
||||||
avatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl),
|
avatarUrl: getUserAvatarUrl(user?.id ?? '', user?.profileImageMime, authBaseUrl),
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(body: CreateAdminDto) {
|
async create(body: CreateAdminDto) {
|
||||||
@@ -124,7 +153,12 @@ export class AdminsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(id: string, body: UpdateAdminStatusDto) {
|
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({
|
const admin = await this.prisma.admin.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -345,6 +379,12 @@ function hashPassword(password: string) {
|
|||||||
return `scrypt:${salt}:${hash}`;
|
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) {
|
function createGiteaUsername(email: string) {
|
||||||
const account = email.split('@')[0] || email;
|
const account = email.split('@')[0] || email;
|
||||||
const username = account
|
const username = account
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
import { IsArray, IsBoolean, IsEmail, IsEnum, IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
import { IsArray, IsBoolean, IsEmail, IsEnum, IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
||||||
import { AdminRole } from './admin-role';
|
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 {
|
export class CreateAdminDto {
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email!: string;
|
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) {
|
async updateUserAvatar(username: string, imageData: Buffer, mimeType: string) {
|
||||||
this.assertToken();
|
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';
|
import { LicenseCode } from './license-enums';
|
||||||
|
|
||||||
export class ListUserLicensesQuery {
|
export class ListUserLicensesQuery {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
page?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
limit?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
@@ -10,6 +19,10 @@ export class ListUserLicensesQuery {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(LicenseCode)
|
@IsEnum(LicenseCode)
|
||||||
licenseCode?: LicenseCode;
|
licenseCode?: LicenseCode;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AssignLicenseDto {
|
export class AssignLicenseDto {
|
||||||
@@ -28,3 +41,44 @@ export class AssignLicenseDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
assignedById?: string;
|
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';
|
import { LicensesService } from './licenses.service';
|
||||||
|
|
||||||
@Controller('licenses')
|
@Controller('licenses')
|
||||||
@@ -12,11 +12,31 @@ export class LicensesController {
|
|||||||
return this.licensesService.listPlans();
|
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')
|
@Get('users')
|
||||||
listUserLicenses(@Query() query: ListUserLicensesQuery) {
|
listUserLicenses(@Query() query: ListUserLicensesQuery) {
|
||||||
return this.licensesService.listUserLicenses(query);
|
return this.licensesService.listUserLicenses(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('assignments')
|
||||||
|
listAssignments(@Query() query: ListUserLicensesQuery) {
|
||||||
|
return this.licensesService.listAssignments(query);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('users/:userId')
|
@Post('users/:userId')
|
||||||
assign(@Param('userId') userId: string, @Body() body: AssignLicenseDto) {
|
assign(@Param('userId') userId: string, @Body() body: AssignLicenseDto) {
|
||||||
return this.licensesService.assign(userId, body);
|
return this.licensesService.assign(userId, body);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
|
import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module';
|
||||||
|
import { LauncherPrismaModule } from '../launcher-prisma/launcher-prisma.module';
|
||||||
import { LicensesController } from './licenses.controller';
|
import { LicensesController } from './licenses.controller';
|
||||||
import { LicensesService } from './licenses.service';
|
import { LicensesService } from './licenses.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthPrismaModule],
|
imports: [AuthPrismaModule, LauncherPrismaModule],
|
||||||
controllers: [LicensesController],
|
controllers: [LicensesController],
|
||||||
providers: [LicensesService],
|
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 { 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()
|
@Injectable()
|
||||||
export class LicensesService {
|
export class LicensesService {
|
||||||
constructor(private readonly authPrisma: AuthPrismaService) {}
|
constructor(
|
||||||
|
private readonly authPrisma: AuthPrismaService,
|
||||||
|
private readonly launcherPrisma: LauncherPrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async listPlans() {
|
async listPlans() {
|
||||||
const now = new Date();
|
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) {
|
async assign(userId: string, body: AssignLicenseDto) {
|
||||||
const user = await this.authPrisma.user.findUnique({
|
const user = await this.authPrisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -138,6 +326,8 @@ export class LicensesService {
|
|||||||
include: { license: true, user: true },
|
include: { license: true, user: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.applyLicensePolicyToUsers(license.id, [userId]);
|
||||||
|
|
||||||
return { userLicense };
|
return { userLicense };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,13 +365,15 @@ export class LicensesService {
|
|||||||
include: { license: true, user: true },
|
include: { license: true, user: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.applyLicensePolicyToUsers(license.id, [userLicense.userId]);
|
||||||
|
|
||||||
return { userLicense };
|
return { userLicense };
|
||||||
}
|
}
|
||||||
|
|
||||||
async revoke(id: string) {
|
async revoke(id: string) {
|
||||||
const userLicense = await this.authPrisma.userLicense.findUnique({
|
const userLicense = await this.authPrisma.userLicense.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { id: true },
|
select: { id: true, userId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userLicense) {
|
if (!userLicense) {
|
||||||
@@ -192,8 +384,117 @@ export class LicensesService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.refreshUserAppPermissionsByCurrentLicense(userLicense.userId);
|
||||||
|
|
||||||
return { ok: true };
|
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) {
|
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;
|
const diffDays = (expiresAt.getTime() - now.getTime()) / 1000 / 60 / 60 / 24;
|
||||||
return diffDays >= 0 && diffDays <= 30;
|
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';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class ListLauncherNoticesQuery {
|
export class ListLauncherNoticesQuery {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
page?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
limit?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
appKey?: string;
|
appKey?: string;
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ export class NoticesService {
|
|||||||
async list(query: ListLauncherNoticesQuery) {
|
async list(query: ListLauncherNoticesQuery) {
|
||||||
const keyword = query.keyword?.trim();
|
const keyword = query.keyword?.trim();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const page = toPositiveInt(query.page, 1);
|
||||||
const notices = await this.launcherPrisma.launcherNotice.findMany({
|
const limit = Math.min(toPositiveInt(query.limit, 20), 100);
|
||||||
where: {
|
const where = {
|
||||||
appKey: query.appKey || undefined,
|
appKey: query.appKey || undefined,
|
||||||
isActive:
|
isActive:
|
||||||
query.state === 'active'
|
query.state === 'active'
|
||||||
@@ -77,21 +77,32 @@ export class NoticesService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
OR: keyword
|
OR: keyword
|
||||||
? [
|
? [
|
||||||
{ title: { contains: keyword, mode: 'insensitive' } },
|
{ title: { contains: keyword, mode: 'insensitive' as const } },
|
||||||
{ body: { contains: keyword, mode: 'insensitive' } },
|
{ body: { contains: keyword, mode: 'insensitive' as const } },
|
||||||
{ appKey: { contains: keyword, mode: 'insensitive' } },
|
{ appKey: { contains: keyword, mode: 'insensitive' as const } },
|
||||||
]
|
]
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const [notices, total] = await this.launcherPrisma.$transaction([
|
||||||
|
this.launcherPrisma.launcherNotice.findMany({
|
||||||
|
where,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.launcherPrisma.launcherNotice.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notices: await this.withCreatorAvatars(notices.map((notice, index) => ({
|
notices: await this.withCreatorAvatars(notices.map((notice, index) => ({
|
||||||
...notice,
|
...notice,
|
||||||
no: notices.length - index,
|
no: total - ((page - 1) * limit + index),
|
||||||
status: getNoticeStatus(notice, now),
|
status: getNoticeStatus(notice, now),
|
||||||
}))),
|
}))),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,3 +210,9 @@ function getNoticeStatus(
|
|||||||
|
|
||||||
return '게시중';
|
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';
|
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 {
|
export class CreateOrganizationDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
@@ -29,3 +47,9 @@ export class UpdateOrganizationDto {
|
|||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
domain?: string | null;
|
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';
|
import { OrganizationsService } from './organizations.service';
|
||||||
|
|
||||||
@Controller('organizations')
|
@Controller('organizations')
|
||||||
@@ -8,8 +8,8 @@ export class OrganizationsController {
|
|||||||
constructor(private readonly organizationsService: OrganizationsService) {}
|
constructor(private readonly organizationsService: OrganizationsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list() {
|
list(@Query() query: ListOrganizationsQuery) {
|
||||||
return this.organizationsService.list();
|
return this.organizationsService.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -22,6 +22,15 @@ export class OrganizationsController {
|
|||||||
return this.organizationsService.update(id, body);
|
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')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.organizationsService.remove(id);
|
return this.organizationsService.remove(id);
|
||||||
|
|||||||
@@ -2,26 +2,61 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
|||||||
import { Prisma } from '@prisma/auth-client';
|
import { Prisma } from '@prisma/auth-client';
|
||||||
|
|
||||||
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
|
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
|
||||||
import { CreateOrganizationDto, UpdateOrganizationDto } from './dto';
|
import { CreateOrganizationDto, ListOrganizationsQuery, UpdateOrganizationDto, UpdateOrganizationLicenseDto } from './dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsService {
|
export class OrganizationsService {
|
||||||
constructor(private readonly authPrisma: AuthPrismaService) {}
|
constructor(private readonly authPrisma: AuthPrismaService) {}
|
||||||
|
|
||||||
async list() {
|
async list(query: ListOrganizationsQuery = {}) {
|
||||||
const organizations = await this.authPrisma.organization.findMany({
|
const page = toPositiveInt(query.page, 1);
|
||||||
where: { isActive: true },
|
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' }],
|
orderBy: [{ type: 'asc' }, { name: 'asc' }],
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
type: true,
|
type: true,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
defaultLicenseCode: true,
|
||||||
createdAt: 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) {
|
async create(body: CreateOrganizationDto) {
|
||||||
@@ -37,6 +72,7 @@ export class OrganizationsService {
|
|||||||
name: true,
|
name: true,
|
||||||
type: true,
|
type: true,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
defaultLicenseCode: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -68,6 +104,7 @@ export class OrganizationsService {
|
|||||||
name: true,
|
name: true,
|
||||||
type: true,
|
type: true,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
defaultLicenseCode: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -111,4 +148,98 @@ export class OrganizationsService {
|
|||||||
throw error;
|
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';
|
import { IsEmail, IsIn, IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class ListUserSessionsQuery {
|
export class ListUserSessionsQuery {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
page?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
limit?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export class UsersService {
|
|||||||
async listSessions(query: ListUserSessionsQuery) {
|
async listSessions(query: ListUserSessionsQuery) {
|
||||||
const keyword = query.keyword?.trim();
|
const keyword = query.keyword?.trim();
|
||||||
const now = new Date();
|
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({
|
const users = await this.authPrisma.user.findMany({
|
||||||
where: keyword
|
where: keyword
|
||||||
@@ -140,12 +142,16 @@ export class UsersService {
|
|||||||
const filteredRows = query.state
|
const filteredRows = query.state
|
||||||
? rows.filter((row) => row.state === query.state)
|
? rows.filter((row) => row.state === query.state)
|
||||||
: rows;
|
: rows;
|
||||||
|
const pageRows = filteredRows.slice((page - 1) * limit, page * limit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessions: filteredRows.map((row, index) => ({
|
sessions: pageRows.map((row, index) => ({
|
||||||
...row,
|
...row,
|
||||||
no: filteredRows.length - index,
|
no: filteredRows.length - ((page - 1) * limit + index),
|
||||||
})),
|
})),
|
||||||
|
total: filteredRows.length,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,3 +331,9 @@ function getSessionState(
|
|||||||
|
|
||||||
return '유효토큰';
|
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