diff --git a/prisma/auth.schema.prisma b/prisma/auth.schema.prisma index 4acd67c..e62e858 100644 --- a/prisma/auth.schema.prisma +++ b/prisma/auth.schema.prisma @@ -14,6 +14,14 @@ enum UserRole { USER } +enum OrganizationType { + COMPANY + SCHOOL + GOVERNMENT + LAB + OTHER +} + enum LicenseCode { FREE EDU @@ -32,6 +40,7 @@ model User { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) userLicenses UserLicense[] refreshTokens RefreshToken[] + affiliations UserAffiliation[] @@map("users") } @@ -79,3 +88,37 @@ model UserLicense { @@index([licenseId]) @@map("user_licenses") } + +model Organization { + id String @id @default(uuid()) @db.Uuid + name String + type OrganizationType @default(OTHER) + domain String? + 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) + affiliations UserAffiliation[] + + @@unique([name, type]) + @@index([isActive, type]) + @@map("organizations") +} + +model UserAffiliation { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + department String? + position String? + isPrimary Boolean @default(false) @map("is_primary") + startsAt DateTime? @map("starts_at") @db.Timestamptz(3) + endsAt DateTime? @map("ends_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + + @@index([userId, isPrimary]) + @@index([organizationId]) + @@map("user_affiliations") +} diff --git a/src/app.module.ts b/src/app.module.ts index 0070228..85bede1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { HealthModule } from './health/health.module'; import { LauncherModule } from './launcher/launcher.module'; import { LicensesModule } from './licenses/licenses.module'; import { NoticesModule } from './notices/notices.module'; +import { OrganizationsModule } from './organizations/organizations.module'; import { PrismaModule } from './prisma/prisma.module'; import { RuntimeModule } from './runtime/runtime.module'; import { UsersModule } from './users/users.module'; @@ -37,6 +38,7 @@ import { WoodpeckerModule } from './woodpecker/woodpecker.module'; LauncherModule, LicensesModule, NoticesModule, + OrganizationsModule, PrismaModule, RuntimeModule, UsersModule, diff --git a/src/organizations/dto.ts b/src/organizations/dto.ts new file mode 100644 index 0000000..94f9ad1 --- /dev/null +++ b/src/organizations/dto.ts @@ -0,0 +1,31 @@ +import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateOrganizationDto { + @IsString() + @MaxLength(120) + name!: string; + + @IsIn(['COMPANY', 'SCHOOL', 'GOVERNMENT', 'LAB', 'OTHER']) + type!: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; + + @IsOptional() + @IsString() + @MaxLength(120) + domain?: string | null; +} + +export class UpdateOrganizationDto { + @IsOptional() + @IsString() + @MaxLength(120) + name?: string; + + @IsOptional() + @IsIn(['COMPANY', 'SCHOOL', 'GOVERNMENT', 'LAB', 'OTHER']) + type?: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; + + @IsOptional() + @IsString() + @MaxLength(120) + domain?: string | null; +} diff --git a/src/organizations/organizations.controller.ts b/src/organizations/organizations.controller.ts new file mode 100644 index 0000000..856a110 --- /dev/null +++ b/src/organizations/organizations.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; + +import { CreateOrganizationDto, UpdateOrganizationDto } from './dto'; +import { OrganizationsService } from './organizations.service'; + +@Controller('organizations') +export class OrganizationsController { + constructor(private readonly organizationsService: OrganizationsService) {} + + @Get() + list() { + return this.organizationsService.list(); + } + + @Post() + create(@Body() body: CreateOrganizationDto) { + return this.organizationsService.create(body); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() body: UpdateOrganizationDto) { + return this.organizationsService.update(id, body); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.organizationsService.remove(id); + } +} diff --git a/src/organizations/organizations.module.ts b/src/organizations/organizations.module.ts new file mode 100644 index 0000000..4b71e16 --- /dev/null +++ b/src/organizations/organizations.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { AuthPrismaModule } from '../auth-prisma/auth-prisma.module'; +import { OrganizationsController } from './organizations.controller'; +import { OrganizationsService } from './organizations.service'; + +@Module({ + imports: [AuthPrismaModule], + controllers: [OrganizationsController], + providers: [OrganizationsService], +}) +export class OrganizationsModule {} diff --git a/src/organizations/organizations.service.ts b/src/organizations/organizations.service.ts new file mode 100644 index 0000000..be82c61 --- /dev/null +++ b/src/organizations/organizations.service.ts @@ -0,0 +1,114 @@ +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'; + +@Injectable() +export class OrganizationsService { + constructor(private readonly authPrisma: AuthPrismaService) {} + + async list() { + const organizations = await this.authPrisma.organization.findMany({ + where: { isActive: true }, + orderBy: [{ type: 'asc' }, { name: 'asc' }], + select: { + id: true, + name: true, + type: true, + domain: true, + createdAt: true, + }, + }); + + return { organizations }; + } + + async create(body: CreateOrganizationDto) { + try { + const organization = await this.authPrisma.organization.create({ + data: { + name: body.name.trim(), + type: body.type, + domain: body.domain?.trim() || null, + }, + select: { + id: true, + name: true, + type: true, + domain: true, + createdAt: true, + }, + }); + + return { organization }; + } 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, + 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; + } + } +} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index e3455ad..1c1af9f 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -15,6 +15,14 @@ type UserSessionRow = { expiresAt: Date | null; lastSeen: Date | null; state: string; + primaryAffiliation: { + id: string; + organizationId: string; + organizationName: string; + organizationType: string; + department: string | null; + position: string | null; + } | null; }; @Injectable() @@ -43,10 +51,37 @@ export class UsersService { }, orderBy: { createdAt: 'desc' }, }, + affiliations: { + where: { + OR: [{ endsAt: null }, { endsAt: { gt: now } }], + }, + orderBy: [{ isPrimary: 'desc' }, { createdAt: 'desc' }], + take: 1, + include: { + organization: { + select: { + id: true, + name: true, + type: true, + }, + }, + }, + }, }, }); const rows: UserSessionRow[] = users.flatMap((user): UserSessionRow[] => { + const primaryAffiliation = user.affiliations[0] + ? { + id: user.affiliations[0].id, + organizationId: user.affiliations[0].organizationId, + organizationName: user.affiliations[0].organization.name, + organizationType: user.affiliations[0].organization.type, + department: user.affiliations[0].department, + position: user.affiliations[0].position, + } + : null; + if (user.refreshTokens.length === 0) { return [ { @@ -61,6 +96,7 @@ export class UsersService { expiresAt: null, lastSeen: user.lastLoginAt, state: user.isActive ? '세션 없음' : '잠김', + primaryAffiliation, }, ]; } @@ -77,6 +113,7 @@ export class UsersService { expiresAt: token.expiresAt, lastSeen: user.lastLoginAt ?? token.createdAt, state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now), + primaryAffiliation, })); });