import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/auth-client'; import { getAdminAvatarUrl } from '../admins/admin-avatar'; import { AuthPrismaService } from '../auth-prisma/auth-prisma.service'; import { CreateOrganizationDto, ListOrganizationsQuery, UpdateOrganizationDto, UpdateOrganizationLicenseDto } from './dto'; type AdminRequester = { id: string; name: string; email: string; }; @Injectable() export class OrganizationsService { constructor( private readonly authPrisma: AuthPrismaService, private readonly configService: ConfigService, ) {} 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, createdById: true, createdByName: true, createdByEmail: 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: organizations.map((organization, index) => ({ ...organization, defaultLicenseName: organization.defaultLicenseCode ? licenseNameByCode.get(organization.defaultLicenseCode) ?? organization.defaultLicenseCode : null, createdByAvatarUrl: getAdminAvatarUrl(organization.createdById, this.configService), no: total - ((page - 1) * limit + index), })), total, page, limit, }; } async create(body: CreateOrganizationDto, requester?: AdminRequester) { try { const organization = await this.authPrisma.organization.create({ data: { name: body.name.trim(), type: body.type, domain: body.domain?.trim() || null, ...(requester ? { createdById: requester.id, createdByName: requester.name, createdByEmail: requester.email, } : {}), }, select: { id: true, name: true, type: true, domain: true, defaultLicenseCode: true, createdById: true, createdByName: true, createdByEmail: true, createdAt: true, }, }); return { organization: { ...organization, createdByAvatarUrl: getAdminAvatarUrl(organization.createdById, this.configService), }, }; } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { throw new ConflictException('이미 등록된 조직입니다.'); } throw error; } } async update(id: string, body: UpdateOrganizationDto) { try { const organization = await this.authPrisma.organization.update({ where: { id }, data: { ...(body.name !== undefined ? { name: body.name.trim() } : {}), ...(body.type !== undefined ? { type: body.type } : {}), ...(body.domain !== undefined ? { domain: body.domain?.trim() || null } : {}), }, select: { id: true, name: true, type: true, domain: true, defaultLicenseCode: true, createdById: true, createdByName: true, createdByEmail: true, createdAt: true, }, }); return { organization }; } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025' ) { throw new NotFoundException('조직을 찾을 수 없습니다.'); } if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { throw new ConflictException('이미 등록된 조직입니다.'); } throw error; } } async remove(id: string) { try { await this.authPrisma.organization.update({ where: { id }, data: { isActive: false }, }); return { ok: true }; } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025' ) { throw new NotFoundException('조직을 찾을 수 없습니다.'); } 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; }