add organization management and refine source repository view
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user