add organization management and refine source repository view
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-07 11:56:42 +09:00
parent 321b7c316e
commit 895e56088c
7 changed files with 268 additions and 0 deletions
+43
View File
@@ -14,6 +14,14 @@ enum UserRole {
USER USER
} }
enum OrganizationType {
COMPANY
SCHOOL
GOVERNMENT
LAB
OTHER
}
enum LicenseCode { enum LicenseCode {
FREE FREE
EDU EDU
@@ -32,6 +40,7 @@ model User {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
userLicenses UserLicense[] userLicenses UserLicense[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
affiliations UserAffiliation[]
@@map("users") @@map("users")
} }
@@ -79,3 +88,37 @@ model UserLicense {
@@index([licenseId]) @@index([licenseId])
@@map("user_licenses") @@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")
}
+2
View File
@@ -12,6 +12,7 @@ import { HealthModule } from './health/health.module';
import { LauncherModule } from './launcher/launcher.module'; import { LauncherModule } from './launcher/launcher.module';
import { LicensesModule } from './licenses/licenses.module'; import { LicensesModule } from './licenses/licenses.module';
import { NoticesModule } from './notices/notices.module'; import { NoticesModule } from './notices/notices.module';
import { OrganizationsModule } from './organizations/organizations.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { RuntimeModule } from './runtime/runtime.module'; import { RuntimeModule } from './runtime/runtime.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
@@ -37,6 +38,7 @@ import { WoodpeckerModule } from './woodpecker/woodpecker.module';
LauncherModule, LauncherModule,
LicensesModule, LicensesModule,
NoticesModule, NoticesModule,
OrganizationsModule,
PrismaModule, PrismaModule,
RuntimeModule, RuntimeModule,
UsersModule, UsersModule,
+31
View File
@@ -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;
}
@@ -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);
}
}
+12
View File
@@ -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 {}
+114
View File
@@ -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;
}
}
}
+37
View File
@@ -15,6 +15,14 @@ type UserSessionRow = {
expiresAt: Date | null; expiresAt: Date | null;
lastSeen: Date | null; lastSeen: Date | null;
state: string; state: string;
primaryAffiliation: {
id: string;
organizationId: string;
organizationName: string;
organizationType: string;
department: string | null;
position: string | null;
} | null;
}; };
@Injectable() @Injectable()
@@ -43,10 +51,37 @@ export class UsersService {
}, },
orderBy: { createdAt: 'desc' }, 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 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) { if (user.refreshTokens.length === 0) {
return [ return [
{ {
@@ -61,6 +96,7 @@ export class UsersService {
expiresAt: null, expiresAt: null,
lastSeen: user.lastLoginAt, lastSeen: user.lastLoginAt,
state: user.isActive ? '세션 없음' : '잠김', state: user.isActive ? '세션 없음' : '잠김',
primaryAffiliation,
}, },
]; ];
} }
@@ -77,6 +113,7 @@ export class UsersService {
expiresAt: token.expiresAt, expiresAt: token.expiresAt,
lastSeen: user.lastLoginAt ?? token.createdAt, lastSeen: user.lastLoginAt ?? token.createdAt,
state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now), state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now),
primaryAffiliation,
})); }));
}); });