feat: 전사 공유 일정(주간 캘린더) 기능 추가

상단 nav 독립 메뉴 '일정' — 주간 타임라인(간트형) 캘린더.
카테고리=레인, 일정=막대(다중일 지원), 우측 상세 패널, 일정/카테고리 CRUD.

백엔드(Schedule 모듈):
- ScheduleCategory/ScheduleEvent 엔티티 + 참석자(다대다) + 작성자
- 카테고리/일정 CRUD, 기간 겹침 조회, 참석자 풀(전사 사용자)
- 기본 카테고리 12종 onModuleInit 멱등 시드
- 인증 사용자 누구나 관리(전역 일정, 글로벌 관리자 개념 없음)
- 운영 마이그레이션(AddSchedule) 추가, @nestjs/schedule 임포트는 CronModule 로 alias

프론트:
- /schedule 라우트 + 사이드바 nav 항목
- 주간 네비(이전/다음/오늘), 유형 필터·카테고리 관리 드롭다운
- 타임라인/상세 패널/일정 모달/카테고리 모달(hue 슬라이더)/참석자 멀티셀렉트
- schedule.store + useSchedule + scheduleDate 유틸

런타임 검증: dev 부팅·시드(12건)·라우트 매핑 정상, 인증 게이트(401),
일정 생성/조회/수정/삭제 + 참석자/전직원/다중일 스모크 전부 정상.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 21:23:10 +09:00
parent 62df92e6eb
commit 346ec75553
22 changed files with 3889 additions and 5 deletions
+4 -2
View File
@@ -1,7 +1,7 @@
import { Logger, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleModule as CronModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { createKeyv } from '@keyv/redis';
@@ -18,10 +18,11 @@ import { ActivityModule } from './modules/activity/activity.module';
import { NotificationModule } from './modules/notification/notification.module';
import { AiModule } from './modules/ai/ai.module';
import { ProjectModule } from './modules/project/project.module';
import { ScheduleModule } from './modules/schedule/schedule.module';
@Module({
imports: [
ScheduleModule.forRoot(),
CronModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
@@ -98,6 +99,7 @@ import { ProjectModule } from './modules/project/project.module';
NotificationModule,
AiModule,
ProjectModule,
ScheduleModule,
],
controllers: [AppController],
providers: [
@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 전사 공유 일정(캘린더) — 카테고리/일정/참석자 테이블 추가.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// (기본 카테고리 시드는 ScheduleService.onModuleInit 가 멱등 처리하므로 여기서 넣지 않는다.)
export class AddSchedule1782300000000 implements MigrationInterface {
name = 'AddSchedule1782300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 카테고리(레인)
await queryRunner.query(
`CREATE TABLE "schedule_categories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "label" character varying NOT NULL, "color" character varying NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_schedule_categories" PRIMARY KEY ("id"))`,
);
// 일정(이벤트)
await queryRunner.query(
`CREATE TABLE "schedule_events" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "time" character varying NOT NULL DEFAULT '', "place" character varying, "description" text, "all_hands" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "category_id" uuid, "created_by" uuid, CONSTRAINT "PK_schedule_events" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_events_dates" ON "schedule_events" ("start_date", "end_date")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_events_category" ON "schedule_events" ("category_id")`,
);
// 참석자(다대다 조인)
await queryRunner.query(
`CREATE TABLE "schedule_event_attendees" ("event_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_schedule_event_attendees" PRIMARY KEY ("event_id", "user_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_event_attendees_event" ON "schedule_event_attendees" ("event_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_event_attendees_user" ON "schedule_event_attendees" ("user_id")`,
);
// FK
await queryRunner.query(
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_category" FOREIGN KEY ("category_id") REFERENCES "schedule_categories"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_created_by" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" ADD CONSTRAINT "FK_schedule_event_attendees_event" FOREIGN KEY ("event_id") REFERENCES "schedule_events"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" ADD CONSTRAINT "FK_schedule_event_attendees_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" DROP CONSTRAINT "FK_schedule_event_attendees_user"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" DROP CONSTRAINT "FK_schedule_event_attendees_event"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_created_by"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_category"`,
);
await queryRunner.query(`DROP TABLE "schedule_event_attendees"`);
await queryRunner.query(`DROP TABLE "schedule_events"`);
await queryRunner.query(`DROP TABLE "schedule_categories"`);
}
}
@@ -0,0 +1,23 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
// 일정 카테고리 생성 DTO
export class CreateScheduleCategoryDto {
@ApiProperty({ description: '카테고리 이름', example: '회의' })
@IsString()
@IsNotEmpty({ message: '카테고리 이름을 입력해 주세요.' })
@MaxLength(40, { message: '카테고리 이름은 최대 40자입니다.' })
label!: string;
@ApiProperty({ description: '카테고리 색(hex)', example: '#1d4ed8' })
@IsString()
@Matches(/^#[0-9a-fA-F]{6}$/, {
message: '색상은 #RRGGBB 형식의 hex 값이어야 합니다.',
})
color!: string;
}
// 일정 카테고리 수정 DTO — 생성 DTO 의 부분 집합
export class UpdateScheduleCategoryDto extends PartialType(
CreateScheduleCategoryDto,
) {}
@@ -0,0 +1,71 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
// 일정 생성 DTO
export class CreateScheduleEventDto {
@ApiProperty({ description: '일정 제목', example: '주간 팀 스탠드업' })
@IsString()
@IsNotEmpty({ message: '일정 제목을 입력해 주세요.' })
@MaxLength(200, { message: '제목은 최대 200자입니다.' })
title!: string;
@ApiProperty({ description: '카테고리 id(uuid)' })
@IsUUID('4', { message: '올바른 카테고리를 선택해 주세요.' })
categoryId!: string;
@ApiProperty({ description: '시작일(YYYY-MM-DD)', example: '2026-06-22' })
@IsDateString({}, { message: '시작일 형식이 올바르지 않습니다.' })
start!: string;
@ApiProperty({ description: '종료일(YYYY-MM-DD)', example: '2026-06-22' })
@IsDateString({}, { message: '종료일 형식이 올바르지 않습니다.' })
end!: string;
@ApiPropertyOptional({
description: '표시용 시간 텍스트',
example: '09:30 10:00',
})
@IsOptional()
@IsString()
@MaxLength(100)
time?: string;
@ApiPropertyOptional({ description: '장소', example: '3층 포커스룸 A' })
@IsOptional()
@IsString()
@MaxLength(200)
place?: string;
@ApiPropertyOptional({ description: '설명/메모' })
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
@ApiPropertyOptional({ description: '전 직원 대상 여부', example: false })
@IsOptional()
@IsBoolean()
allHands?: boolean;
@ApiPropertyOptional({ description: '참석자 사용자 id 목록', type: [String] })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
@ArrayUnique()
attendeeIds?: string[];
}
// 일정 수정 DTO — 생성 DTO 의 부분 집합
export class UpdateScheduleEventDto extends PartialType(
CreateScheduleEventDto,
) {}
@@ -0,0 +1,29 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
// 일정 카테고리(타임라인 레인) — 전사 공유. 사용자가 추가·수정·삭제 가능.
// 음영색(weak/border)은 color 로부터 프론트가 color-mix 로 파생한다(저장하지 않음).
@Entity('schedule_categories')
export class ScheduleCategory {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 표시 이름(예: 회의, 휴가·부재)
@Column({ type: 'varchar' })
label!: string;
// 카테고리 색(hex, 예: #1d4ed8)
@Column({ type: 'varchar' })
color!: string;
// 표시 순서(레인/필터 정렬) — 작을수록 위
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,75 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { ScheduleCategory } from './schedule-category.entity';
// 일정(이벤트) — 전사 공유 캘린더의 단일 항목. 종일 기준, 다중일(start≠end) 지원.
@Entity('schedule_events')
// 주간 범위 조회(겹침 검색)에 사용되는 복합 인덱스
@Index(['startDate', 'endDate'])
export class ScheduleEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 카테고리(레인) — 카테고리 삭제 시 일정도 함께 삭제
// Relation<> 래퍼: 엔티티 순환참조 회피
@Index()
@ManyToOne(() => ScheduleCategory, { onDelete: 'CASCADE', eager: true })
@JoinColumn({ name: 'category_id' })
category!: Relation<ScheduleCategory>;
@Column({ type: 'varchar' })
title!: string;
// 시작·종료일(YYYY-MM-DD, 종일 기준). date 타입이라 문자열로 다룬다.
@Column({ name: 'start_date', type: 'date' })
startDate!: string;
@Column({ name: 'end_date', type: 'date' })
endDate!: string;
// 표시용 시간 텍스트(예: "09:30 10:00", "종일") — 프론트 모달이 조립
@Column({ type: 'varchar', default: '' })
time!: string;
@Column({ type: 'varchar', nullable: true })
place!: string | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
// 전 직원 대상 여부 — true 면 개별 참석자 대신 "전 직원"으로 표시
@Column({ name: 'all_hands', type: 'boolean', default: false })
allHands!: boolean;
// 개별 참석자(전 직원이 아닐 때). 조인 테이블로 다대다 매핑.
@ManyToMany(() => User)
@JoinTable({
name: 'schedule_event_attendees',
joinColumn: { name: 'event_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
})
attendees!: User[];
// 작성자 — 사용자 삭제 시에도 일정은 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'created_by' })
createdBy!: User | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,140 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import {
ScheduleService,
type ScheduleAttendee,
type ScheduleCategoryResponse,
type ScheduleEventResponse,
} from './schedule.service';
import {
CreateScheduleCategoryDto,
UpdateScheduleCategoryDto,
} from './dto/schedule-category.dto';
import {
CreateScheduleEventDto,
UpdateScheduleEventDto,
} from './dto/schedule-event.dto';
// YYYY-MM-DD 형식 검증(쿼리 파라미터용)
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// 일정(전사 공유 캘린더) 컨트롤러 — 인증 사용자면 누구나 조회·관리 가능.
@ApiTags('Schedule')
@Controller('schedule')
@UseGuards(JwtAuthGuard)
export class ScheduleController {
constructor(private readonly scheduleService: ScheduleService) {}
// ----- 카테고리 -----
@Get('categories')
@ApiOperation({ summary: '일정 카테고리 목록' })
@ApiResponse({ status: 200, description: '조회 성공' })
listCategories(): Promise<ScheduleCategoryResponse[]> {
return this.scheduleService.listCategories();
}
@Post('categories')
@ApiOperation({ summary: '일정 카테고리 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
createCategory(
@Body() dto: CreateScheduleCategoryDto,
): Promise<ScheduleCategoryResponse> {
return this.scheduleService.createCategory(dto);
}
@Patch('categories/:id')
@ApiOperation({ summary: '일정 카테고리 수정' })
@ApiResponse({ status: 200, description: '수정 성공' })
updateCategory(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleCategoryDto,
): Promise<ScheduleCategoryResponse> {
return this.scheduleService.updateCategory(id, dto);
}
@Delete('categories/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '일정 카테고리 삭제(포함 일정도 삭제)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteCategory(
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ success: boolean }> {
await this.scheduleService.deleteCategory(id);
return { success: true };
}
// ----- 참석자 선택 풀 -----
@Get('people')
@ApiOperation({ summary: '참석자 선택용 사용자 목록' })
@ApiResponse({ status: 200, description: '조회 성공' })
listPeople(): Promise<ScheduleAttendee[]> {
return this.scheduleService.listPeople();
}
// ----- 일정 -----
@Get('events')
@ApiOperation({ summary: '일정 목록(기간 겹침)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listEvents(
@Query('start') start: string,
@Query('end') end: string,
): Promise<ScheduleEventResponse[]> {
if (!start || !end || !DATE_RE.test(start) || !DATE_RE.test(end)) {
throw new BadRequestException(
'start, end 쿼리(YYYY-MM-DD)가 필요합니다.',
);
}
return this.scheduleService.listEvents(start, end);
}
@Post('events')
@ApiOperation({ summary: '일정 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
createEvent(
@CurrentUser() user: PublicUser,
@Body() dto: CreateScheduleEventDto,
): Promise<ScheduleEventResponse> {
return this.scheduleService.createEvent(dto, user.id);
}
@Patch('events/:id')
@ApiOperation({ summary: '일정 수정' })
@ApiResponse({ status: 200, description: '수정 성공' })
updateEvent(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleEventDto,
): Promise<ScheduleEventResponse> {
return this.scheduleService.updateEvent(id, dto);
}
@Delete('events/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '일정 삭제' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteEvent(
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ success: boolean }> {
await this.scheduleService.deleteEvent(id);
return { success: true };
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { ScheduleCategory } from './entities/schedule-category.entity';
import { ScheduleEvent } from './entities/schedule-event.entity';
import { ScheduleService } from './schedule.service';
import { ScheduleController } from './schedule.controller';
// 일정(전사 공유 캘린더) 모듈 — 카테고리/일정 CRUD. UserModule 로 참석자 풀 조회.
@Module({
imports: [
TypeOrmModule.forFeature([ScheduleCategory, ScheduleEvent]),
UserModule,
],
controllers: [ScheduleController],
providers: [ScheduleService],
exports: [ScheduleService],
})
export class ScheduleModule {}
@@ -0,0 +1,273 @@
import {
Injectable,
Logger,
NotFoundException,
type OnModuleInit,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { User } from '../user/entities/user.entity';
import { UserService } from '../user/user.service';
import { ScheduleCategory } from './entities/schedule-category.entity';
import { ScheduleEvent } from './entities/schedule-event.entity';
import { CreateScheduleCategoryDto } from './dto/schedule-category.dto';
import { CreateScheduleEventDto } from './dto/schedule-event.dto';
// 카테고리/일정 응답(원시) — 표시 음영색은 프론트가 color 로 파생
export interface ScheduleCategoryResponse {
id: string;
label: string;
color: string;
sortOrder: number;
}
export interface ScheduleAttendee {
id: string;
name: string;
avatarUrl: string | null;
}
export interface ScheduleEventResponse {
id: string;
categoryId: string;
title: string;
start: string; // YYYY-MM-DD
end: string; // YYYY-MM-DD
time: string;
place: string | null;
description: string | null;
allHands: boolean;
attendees: ScheduleAttendee[];
createdById: string | null;
}
// 기본 카테고리 시드 — 최초 1회만 적재(테이블 비어있을 때). label/color/순서.
const DEFAULT_CATEGORIES: { label: string; color: string }[] = [
{ label: '회의', color: '#1d4ed8' },
{ label: '외부 미팅', color: '#0284c7' },
{ label: '채용·면접', color: '#be185d' },
{ label: '사내 행사', color: '#4f46e5' },
{ label: '교육·세미나', color: '#1f9d57' },
{ label: '휴가·부재', color: '#b7791f' },
{ label: '회식·모임', color: '#9333ea' },
{ label: '마감·릴리스', color: '#dc2626' },
{ label: '리뷰·QA', color: '#0d9488' },
{ label: '재무·결산', color: '#475569' },
{ label: '홍보·PR', color: '#ea580c' },
{ label: '기타', color: '#0e9594' },
];
// 일정(전사 공유 캘린더) 비즈니스 로직 — 카테고리/일정 CRUD + 참석자 매핑.
@Injectable()
export class ScheduleService implements OnModuleInit {
private readonly logger = new Logger(ScheduleService.name);
constructor(
@InjectRepository(ScheduleCategory)
private readonly categoryRepo: Repository<ScheduleCategory>,
@InjectRepository(ScheduleEvent)
private readonly eventRepo: Repository<ScheduleEvent>,
private readonly userService: UserService,
) {}
// 기동 시 기본 카테고리 시드(멱등) — 비어있을 때만 적재. dev/prod 공통.
async onModuleInit(): Promise<void> {
const count = await this.categoryRepo.count();
if (count > 0) return;
const rows = DEFAULT_CATEGORIES.map((c, i) =>
this.categoryRepo.create({
label: c.label,
color: c.color,
sortOrder: i,
}),
);
await this.categoryRepo.save(rows);
this.logger.log(`기본 일정 카테고리 ${rows.length}건 시드`);
}
// ----- 카테고리 -----
async listCategories(): Promise<ScheduleCategoryResponse[]> {
const rows = await this.categoryRepo.find({
order: { sortOrder: 'ASC', createdAt: 'ASC' },
});
return rows.map((c) => this.toCategoryResponse(c));
}
async createCategory(
dto: CreateScheduleCategoryDto,
): Promise<ScheduleCategoryResponse> {
// 다음 정렬값 = 현재 최대 + 1
const max = await this.categoryRepo
.createQueryBuilder('c')
.select('MAX(c.sort_order)', 'max')
.getRawOne<{ max: number | null }>();
const sortOrder = (max?.max ?? -1) + 1;
const saved = await this.categoryRepo.save(
this.categoryRepo.create({
label: dto.label.trim(),
color: dto.color,
sortOrder,
}),
);
return this.toCategoryResponse(saved);
}
async updateCategory(
id: string,
dto: Partial<CreateScheduleCategoryDto>,
): Promise<ScheduleCategoryResponse> {
const cat = await this.categoryRepo.findOne({ where: { id } });
if (!cat) throw new NotFoundException('카테고리를 찾을 수 없습니다.');
if (dto.label !== undefined) cat.label = dto.label.trim();
if (dto.color !== undefined) cat.color = dto.color;
const saved = await this.categoryRepo.save(cat);
return this.toCategoryResponse(saved);
}
// 카테고리 삭제 — 포함된 일정도 FK CASCADE 로 함께 삭제된다.
async deleteCategory(id: string): Promise<void> {
const res = await this.categoryRepo.delete({ id });
if (!res.affected)
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
}
// ----- 일정 -----
// 주간(또는 임의 기간) 조회 — [start, end] 와 겹치는 일정 전부.
async listEvents(
start: string,
end: string,
): Promise<ScheduleEventResponse[]> {
const rows = await this.eventRepo.find({
where: {
startDate: LessThanOrEqual(end),
endDate: MoreThanOrEqual(start),
},
relations: ['attendees', 'createdBy'],
order: { startDate: 'ASC', time: 'ASC' },
});
return rows.map((e) => this.toEventResponse(e));
}
async createEvent(
dto: CreateScheduleEventDto,
actorId: string,
): Promise<ScheduleEventResponse> {
const category = await this.categoryRepo.findOne({
where: { id: dto.categoryId },
});
if (!category) throw new NotFoundException('카테고리를 찾을 수 없습니다.');
const event = this.eventRepo.create({
category: { id: category.id } as ScheduleCategory,
title: dto.title.trim(),
startDate: dto.start,
endDate: dto.end,
time: dto.time?.trim() ?? '',
place: dto.place?.trim() || null,
description: dto.description?.trim() || null,
allHands: dto.allHands ?? false,
attendees: this.attendeeRefs(dto.allHands, dto.attendeeIds),
createdBy: { id: actorId } as User,
});
const saved = await this.eventRepo.save(event);
return this.findEventOrThrow(saved.id);
}
async updateEvent(
id: string,
dto: Partial<CreateScheduleEventDto>,
): Promise<ScheduleEventResponse> {
const event = await this.eventRepo.findOne({
where: { id },
relations: ['attendees'],
});
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
if (dto.categoryId !== undefined) {
const category = await this.categoryRepo.findOne({
where: { id: dto.categoryId },
});
if (!category)
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
event.category = { id: category.id } as ScheduleCategory;
}
if (dto.title !== undefined) event.title = dto.title.trim();
if (dto.start !== undefined) event.startDate = dto.start;
if (dto.end !== undefined) event.endDate = dto.end;
if (dto.time !== undefined) event.time = dto.time.trim();
if (dto.place !== undefined) event.place = dto.place.trim() || null;
if (dto.description !== undefined)
event.description = dto.description.trim() || null;
if (dto.allHands !== undefined) event.allHands = dto.allHands;
// 참석자/전직원 변경이 있으면 재매핑
if (dto.allHands !== undefined || dto.attendeeIds !== undefined) {
const allHands = dto.allHands ?? event.allHands;
event.attendees = this.attendeeRefs(allHands, dto.attendeeIds);
}
await this.eventRepo.save(event);
return this.findEventOrThrow(id);
}
async deleteEvent(id: string): Promise<void> {
const res = await this.eventRepo.delete({ id });
if (!res.affected) throw new NotFoundException('일정을 찾을 수 없습니다.');
}
// 참석자 선택 풀(전사 사용자) — 일정 작성 모달의 참석자 선택용.
async listPeople(): Promise<ScheduleAttendee[]> {
const users = await this.userService.search('', 500);
return users.map((u) => ({
id: u.id,
name: u.name,
avatarUrl: u.avatarUrl ?? null,
}));
}
// ----- 내부 헬퍼 -----
// 전 직원 일정이면 개별 참석자는 비운다. 아니면 id 참조 배열로 매핑.
private attendeeRefs(allHands?: boolean, ids?: string[]): User[] {
if (allHands) return [];
return [...new Set(ids ?? [])].map((id) => ({ id }) as User);
}
private async findEventOrThrow(id: string): Promise<ScheduleEventResponse> {
const e = await this.eventRepo.findOne({
where: { id },
relations: ['attendees', 'createdBy'],
});
if (!e) throw new NotFoundException('일정을 찾을 수 없습니다.');
return this.toEventResponse(e);
}
private toCategoryResponse(c: ScheduleCategory): ScheduleCategoryResponse {
return {
id: c.id,
label: c.label,
color: c.color,
sortOrder: c.sortOrder,
};
}
private toEventResponse(e: ScheduleEvent): ScheduleEventResponse {
return {
id: e.id,
categoryId: e.category?.id ?? '',
title: e.title,
start: e.startDate,
end: e.endDate,
time: e.time ?? '',
place: e.place ?? null,
description: e.description ?? null,
allHands: e.allHands,
attendees: (e.attendees ?? []).map((u) => ({
id: u.id,
name: u.name,
avatarUrl: u.avatarUrl ?? null,
})),
createdById: e.createdBy?.id ?? null,
};
}
}