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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user