279 lines
8.0 KiB
TypeScript
279 lines
8.0 KiB
TypeScript
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;
|
|
}
|