feat: 개인 일정 캘린더 추가 및 사내 일정 수정 권한 제한
카테고리를 사내 공용(owner=null)과 개인 전용(owner=사용자)으로 나누고, 일정의 공개 범위를 소속 카테고리에서 파생시켜 두 종류가 섞이지 않게 한다. - 조회: 사내 일정 + 본인 개인 일정만 반환, 타인의 개인 항목은 존재를 숨김 - 개인 일정은 소유자만 수정·삭제하며 참석자·알림을 사용하지 않음 - 사내 일정은 등록자만 수정·삭제하도록 제한 (등록자가 탈퇴한 기존 일정은 잠기지 않도록 예외 처리) - 일정이 남은 사내 카테고리는 삭제를 막아 타인 일정 연쇄 삭제를 방지 - 툴바를 캘린더 선택(사내/개인 체크박스)과 '내 관련' 참석자 필터로 분리 - 일정·카테고리 작성 시 사내/개인 구분을 명시적으로 선택 - 기존 데이터는 전부 사내 공유(company)로 유지되는 마이그레이션 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 개인 일정 — 카테고리/일정에 소유자와 공개 범위 추가.
|
||||
// 카테고리 owner_id 가 null 이면 전사 공용, 값이 있으면 해당 사용자 전용이며
|
||||
// 일정의 visibility 는 소속 카테고리에서 파생된다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddSchedulePersonal1783700000000 implements MigrationInterface {
|
||||
name = 'AddSchedulePersonal1783700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 카테고리 소유자 — 기존 카테고리는 전부 전사 공용(NULL)으로 남는다.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_categories" ADD "owner_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_schedule_categories_owner" ON "schedule_categories" ("owner_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_categories" ADD CONSTRAINT "FK_schedule_categories_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
|
||||
// 일정 공개 범위/소유자 — 기존 일정은 전부 전사 공유(company/NULL)로 남는다.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" ADD "visibility" character varying(16) NOT NULL DEFAULT 'company'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" ADD "owner_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_schedule_events_visibility" ON "schedule_events" ("visibility")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_schedule_events_owner" ON "schedule_events" ("owner_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_owner"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_schedule_events_owner"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_schedule_events_visibility"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" DROP COLUMN "owner_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" DROP COLUMN "visibility"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_categories" DROP CONSTRAINT "FK_schedule_categories_owner"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_schedule_categories_owner"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_categories" DROP COLUMN "owner_id"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { ApiProperty, PartialType } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
// 일정 카테고리 생성 DTO
|
||||
export class CreateScheduleCategoryDto {
|
||||
@@ -15,9 +22,18 @@ export class CreateScheduleCategoryDto {
|
||||
message: '색상은 #RRGGBB 형식의 hex 값이어야 합니다.',
|
||||
})
|
||||
color!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '개인 전용 카테고리 여부(true 면 본인만 보는 카테고리)',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
personal?: boolean;
|
||||
}
|
||||
|
||||
// 일정 카테고리 수정 DTO — 생성 DTO 의 부분 집합
|
||||
// (personal 은 생성 시에만 유효하며 수정 시에는 무시된다)
|
||||
export class UpdateScheduleCategoryDto extends PartialType(
|
||||
CreateScheduleCategoryDto,
|
||||
) {}
|
||||
|
||||
@@ -2,16 +2,28 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 일정 카테고리(타임라인 레인) — 전사 공유. 사용자가 추가·수정·삭제 가능.
|
||||
// 일정 카테고리(타임라인 레인) — 전사 공용과 개인 전용으로 나뉜다.
|
||||
// owner 가 null 이면 전사 공용, 값이 있으면 해당 사용자에게만 보이는 개인 카테고리다.
|
||||
// 일정의 공개 범위는 소속 카테고리에서 파생되므로, 두 종류는 서로 섞이지 않는다.
|
||||
// 음영색(weak/border)은 color 로부터 프론트가 color-mix 로 파생한다(저장하지 않음).
|
||||
@Entity('schedule_categories')
|
||||
export class ScheduleCategory {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소유자 — null 이면 전사 공용. 사용자 삭제 시 개인 카테고리도 함께 정리(CASCADE)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User | null;
|
||||
|
||||
// 표시 이름(예: 회의, 휴가·부재)
|
||||
@Column({ type: 'varchar' })
|
||||
label!: string;
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { ScheduleCategory } from './schedule-category.entity';
|
||||
|
||||
// 일정(이벤트) — 전사 공유 캘린더의 단일 항목. 종일 기준, 다중일(start≠end) 지원.
|
||||
// 일정 공개 범위 — company: 전사 공유, private: 소유자 본인만
|
||||
export type ScheduleVisibility = 'company' | 'private';
|
||||
|
||||
// 일정(이벤트) — 캘린더의 단일 항목. 종일 기준, 다중일(start≠end) 지원.
|
||||
// 공개 범위는 소속 카테고리에서 파생된다(공용 카테고리=company, 개인 카테고리=private).
|
||||
// 조회 때 카테고리를 조인하지 않아도 되도록 visibility/owner 를 이벤트에도 비정규화해 둔다.
|
||||
@Entity('schedule_events')
|
||||
// 주간 범위 조회(겹침 검색)에 사용되는 복합 인덱스
|
||||
@Index(['startDate', 'endDate'])
|
||||
@@ -49,7 +54,20 @@ export class ScheduleEvent {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
// 공개 범위 — 소속 카테고리에서 파생(서비스가 강제 동기화)
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 16, default: 'company' })
|
||||
visibility!: ScheduleVisibility;
|
||||
|
||||
// 개인 일정 소유자 — visibility 가 private 일 때만 채워진다.
|
||||
// 감사 이력인 createdBy 와 달리 접근 제어용이라 사용자 삭제 시 함께 삭제(CASCADE)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User | null;
|
||||
|
||||
// 전 직원 대상 여부 — true 면 개별 참석자 대신 "전 직원"으로 표시
|
||||
// 개인 일정에는 참석자 개념이 없으므로 항상 false 로 강제된다.
|
||||
@Column({ name: 'all_hands', type: 'boolean', default: false })
|
||||
allHands!: boolean;
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
// YYYY-MM-DD 형식 검증(쿼리 파라미터용)
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// 일정(전사 공유 캘린더) 컨트롤러 — 인증 사용자면 누구나 조회·관리 가능.
|
||||
// 일정 컨트롤러 — 전사 공유 캘린더 + 사용자별 개인 일정.
|
||||
// 조회는 전사 일정 + 본인 개인 일정만, 수정·삭제는 소유자(개인)/작성자(전사)만 가능하다.
|
||||
@ApiTags('Schedule')
|
||||
@Controller('schedule')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -45,39 +46,46 @@ export class ScheduleController {
|
||||
// ----- 카테고리 -----
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: '일정 카테고리 목록' })
|
||||
@ApiOperation({ summary: '일정 카테고리 목록(전사 공용 + 내 개인)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listCategories(): Promise<ScheduleCategoryResponse[]> {
|
||||
return this.scheduleService.listCategories();
|
||||
listCategories(
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<ScheduleCategoryResponse[]> {
|
||||
return this.scheduleService.listCategories(user.id);
|
||||
}
|
||||
|
||||
@Post('categories')
|
||||
@ApiOperation({ summary: '일정 카테고리 생성' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
createCategory(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateScheduleCategoryDto,
|
||||
): Promise<ScheduleCategoryResponse> {
|
||||
return this.scheduleService.createCategory(dto);
|
||||
return this.scheduleService.createCategory(dto, user.id);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
@ApiOperation({ summary: '일정 카테고리 수정' })
|
||||
@ApiResponse({ status: 200, description: '수정 성공' })
|
||||
updateCategory(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateScheduleCategoryDto,
|
||||
): Promise<ScheduleCategoryResponse> {
|
||||
return this.scheduleService.updateCategory(id, dto);
|
||||
return this.scheduleService.updateCategory(id, dto, user.id);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '일정 카테고리 삭제(포함 일정도 삭제)' })
|
||||
@ApiOperation({
|
||||
summary: '일정 카테고리 삭제(개인 카테고리는 포함 일정도 함께 삭제)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async deleteCategory(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.scheduleService.deleteCategory(id);
|
||||
await this.scheduleService.deleteCategory(id, user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -93,9 +101,10 @@ export class ScheduleController {
|
||||
// ----- 일정 -----
|
||||
|
||||
@Get('events')
|
||||
@ApiOperation({ summary: '일정 목록(기간 겹침)' })
|
||||
@ApiOperation({ summary: '일정 목록(기간 겹침) — 전사 일정 + 내 개인 일정' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listEvents(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Query('start') start: string,
|
||||
@Query('end') end: string,
|
||||
): Promise<ScheduleEventResponse[]> {
|
||||
@@ -104,7 +113,7 @@ export class ScheduleController {
|
||||
'start, end 쿼리(YYYY-MM-DD)가 필요합니다.',
|
||||
);
|
||||
}
|
||||
return this.scheduleService.listEvents(start, end);
|
||||
return this.scheduleService.listEvents(start, end, user.id);
|
||||
}
|
||||
|
||||
@Post('events')
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { IsNull, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import type { NotificationType } from '../notification/entities/notification.entity';
|
||||
import { ScheduleCategory } from './entities/schedule-category.entity';
|
||||
import { ScheduleEvent } from './entities/schedule-event.entity';
|
||||
import {
|
||||
ScheduleEvent,
|
||||
type ScheduleVisibility,
|
||||
} from './entities/schedule-event.entity';
|
||||
import { CreateScheduleCategoryDto } from './dto/schedule-category.dto';
|
||||
import { CreateScheduleEventDto } from './dto/schedule-event.dto';
|
||||
|
||||
@@ -22,6 +26,8 @@ export interface ScheduleCategoryResponse {
|
||||
label: string;
|
||||
color: string;
|
||||
sortOrder: number;
|
||||
// 개인 전용 카테고리 여부(true 면 요청자 본인만 보는 카테고리)
|
||||
personal: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduleAttendee {
|
||||
@@ -42,9 +48,19 @@ export interface ScheduleEventResponse {
|
||||
allHands: boolean;
|
||||
attendees: ScheduleAttendee[];
|
||||
createdById: string | null;
|
||||
// 공개 범위 — company: 전사 공유, private: 소유자 본인만
|
||||
visibility: ScheduleVisibility;
|
||||
ownerId: string | null;
|
||||
// 요청자가 이 일정을 수정·삭제할 수 있는지(프론트 버튼 노출 기준)
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
// 일정(전사 공유 캘린더) 비즈니스 로직 — 카테고리/일정 CRUD + 참석자 매핑.
|
||||
// 일정 비즈니스 로직 — 카테고리/일정 CRUD + 참석자 매핑.
|
||||
//
|
||||
// 공개 범위 모델: 카테고리가 전사 공용(owner=null)과 개인 전용(owner=사용자)으로 나뉘고,
|
||||
// 일정의 visibility 는 소속 카테고리에서 파생된다. 두 종류가 섞이는 조합은 존재할 수 없다.
|
||||
//
|
||||
// 권한: 개인 일정·개인 카테고리는 소유자만, 전사 일정은 작성자만 수정·삭제할 수 있다.
|
||||
// (기본 카테고리 시드 없음 — 카테고리는 사용자가 직접 추가한다)
|
||||
@Injectable()
|
||||
export class ScheduleService {
|
||||
@@ -59,8 +75,11 @@ export class ScheduleService {
|
||||
|
||||
// ----- 카테고리 -----
|
||||
|
||||
async listCategories(): Promise<ScheduleCategoryResponse[]> {
|
||||
// 전사 공용 + 요청자 본인의 개인 카테고리만 반환한다.
|
||||
async listCategories(viewerId: string): Promise<ScheduleCategoryResponse[]> {
|
||||
const rows = await this.categoryRepo.find({
|
||||
where: [{ owner: IsNull() }, { owner: { id: viewerId } }],
|
||||
relations: ['owner'],
|
||||
order: { sortOrder: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
return rows.map((c) => this.toCategoryResponse(c));
|
||||
@@ -68,18 +87,23 @@ export class ScheduleService {
|
||||
|
||||
async createCategory(
|
||||
dto: CreateScheduleCategoryDto,
|
||||
actorId: string,
|
||||
): 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 personal = dto.personal ?? false;
|
||||
// 다음 정렬값 = 같은 소유 범위 내 최대 + 1 (공용/개인 정렬을 서로 침범하지 않도록 분리)
|
||||
const [last] = await this.categoryRepo.find({
|
||||
where: personal ? { owner: { id: actorId } } : { owner: IsNull() },
|
||||
order: { sortOrder: 'DESC' },
|
||||
take: 1,
|
||||
});
|
||||
const sortOrder = (last?.sortOrder ?? -1) + 1;
|
||||
|
||||
const saved = await this.categoryRepo.save(
|
||||
this.categoryRepo.create({
|
||||
label: dto.label.trim(),
|
||||
color: dto.color,
|
||||
sortOrder,
|
||||
owner: personal ? { id: actorId } : null,
|
||||
}),
|
||||
);
|
||||
return this.toCategoryResponse(saved);
|
||||
@@ -88,9 +112,11 @@ export class ScheduleService {
|
||||
async updateCategory(
|
||||
id: string,
|
||||
dto: Partial<CreateScheduleCategoryDto>,
|
||||
actorId: string,
|
||||
): Promise<ScheduleCategoryResponse> {
|
||||
const cat = await this.categoryRepo.findOne({ where: { id } });
|
||||
if (!cat) throw new NotFoundException('카테고리를 찾을 수 없습니다.');
|
||||
// personal(소유 범위)은 생성 시에만 정해진다 — 수정으로 공용↔개인 전환은 허용하지 않는다.
|
||||
// 소속 일정 수십 건의 공개 범위가 한 번에 뒤집히는 것을 막기 위함이다.
|
||||
const cat = await this.assertCategoryAccessible(id, actorId);
|
||||
if (dto.label !== undefined) cat.label = dto.label.trim();
|
||||
if (dto.color !== undefined) cat.color = dto.color;
|
||||
const saved = await this.categoryRepo.save(cat);
|
||||
@@ -98,38 +124,62 @@ export class ScheduleService {
|
||||
}
|
||||
|
||||
// 카테고리 삭제 — 포함된 일정도 FK CASCADE 로 함께 삭제된다.
|
||||
async deleteCategory(id: string): Promise<void> {
|
||||
const res = await this.categoryRepo.delete({ id });
|
||||
if (!res.affected)
|
||||
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
|
||||
// 공용 카테고리는 남의 일정까지 지워지므로 비어 있을 때만 삭제할 수 있다.
|
||||
async deleteCategory(id: string, actorId: string): Promise<void> {
|
||||
const cat = await this.assertCategoryAccessible(id, actorId);
|
||||
if (!cat.owner) {
|
||||
const used = await this.eventRepo.count({
|
||||
where: { category: { id } },
|
||||
});
|
||||
if (used > 0) {
|
||||
// 상태 기반 자동 매핑(VAL_001)에 문구가 묻히지 않도록 BusinessException 으로 던진다.
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
`이 카테고리에 등록된 일정 ${used}건이 있어 삭제할 수 없습니다. 일정을 먼저 정리해 주세요.`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.categoryRepo.delete({ id });
|
||||
}
|
||||
|
||||
// ----- 일정 -----
|
||||
|
||||
// 주간(또는 임의 기간) 조회 — [start, end] 와 겹치는 일정 전부.
|
||||
// 주간(또는 임의 기간) 조회 — [start, end] 와 겹치는 일정 중
|
||||
// 전사 일정 + 요청자 본인의 개인 일정만 반환한다.
|
||||
async listEvents(
|
||||
start: string,
|
||||
end: string,
|
||||
viewerId: string,
|
||||
): Promise<ScheduleEventResponse[]> {
|
||||
const overlap = {
|
||||
startDate: LessThanOrEqual(end),
|
||||
endDate: MoreThanOrEqual(start),
|
||||
};
|
||||
const rows = await this.eventRepo.find({
|
||||
where: {
|
||||
startDate: LessThanOrEqual(end),
|
||||
endDate: MoreThanOrEqual(start),
|
||||
},
|
||||
relations: ['attendees', 'createdBy'],
|
||||
where: [
|
||||
{ ...overlap, visibility: 'company' as const },
|
||||
{ ...overlap, visibility: 'private' as const, owner: { id: viewerId } },
|
||||
],
|
||||
relations: ['attendees', 'createdBy', 'owner'],
|
||||
order: { startDate: 'ASC', time: 'ASC' },
|
||||
});
|
||||
return rows.map((e) => this.toEventResponse(e));
|
||||
return rows.map((e) => this.toEventResponse(e, viewerId));
|
||||
}
|
||||
|
||||
async createEvent(
|
||||
dto: CreateScheduleEventDto,
|
||||
actor: ScheduleActor,
|
||||
): Promise<ScheduleEventResponse> {
|
||||
const category = await this.categoryRepo.findOne({
|
||||
where: { id: dto.categoryId },
|
||||
});
|
||||
if (!category) throw new NotFoundException('카테고리를 찾을 수 없습니다.');
|
||||
const category = await this.assertCategoryAccessible(
|
||||
dto.categoryId,
|
||||
actor.id,
|
||||
);
|
||||
// 공개 범위는 카테고리에서 파생 — 개인 카테고리에 담으면 개인 일정이 된다.
|
||||
const isPrivate = !!category.owner;
|
||||
// 개인 일정에는 참석자 개념이 없으므로 전 직원/참석자를 비운다.
|
||||
const allHands = isPrivate ? false : (dto.allHands ?? false);
|
||||
const attendeeIds = isPrivate ? [] : dto.attendeeIds;
|
||||
|
||||
const event = this.eventRepo.create({
|
||||
category: { id: category.id } as ScheduleCategory,
|
||||
@@ -139,21 +189,26 @@ export class ScheduleService {
|
||||
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),
|
||||
visibility: isPrivate ? 'private' : 'company',
|
||||
owner: isPrivate ? { id: actor.id } : null,
|
||||
allHands,
|
||||
attendees: this.attendeeRefs(allHands, attendeeIds),
|
||||
createdBy: { id: actor.id } as User,
|
||||
});
|
||||
const saved = await this.eventRepo.save(event);
|
||||
|
||||
// 참석자에게 초대 알림 — 전 직원 일정이면 전 사용자, 아니면 지정 참석자
|
||||
const recipients = event.allHands
|
||||
? await this.userService.findAllIds()
|
||||
: this.idsOf(dto.attendeeIds);
|
||||
await this.notifyEvent('schedule.invited', recipients, actor, {
|
||||
eventTitle: event.title,
|
||||
start: event.startDate,
|
||||
});
|
||||
return this.findEventOrThrow(saved.id);
|
||||
// 개인 일정은 본인만 보므로 알림을 보내지 않는다.
|
||||
if (!isPrivate) {
|
||||
// 참석자에게 초대 알림 — 전 직원 일정이면 전 사용자, 아니면 지정 참석자
|
||||
const recipients = allHands
|
||||
? await this.userService.findAllIds()
|
||||
: this.idsOf(attendeeIds);
|
||||
await this.notifyEvent('schedule.invited', recipients, actor, {
|
||||
eventTitle: event.title,
|
||||
start: event.startDate,
|
||||
});
|
||||
}
|
||||
return this.findEventOrThrow(saved.id, actor.id);
|
||||
}
|
||||
|
||||
async updateEvent(
|
||||
@@ -163,21 +218,26 @@ export class ScheduleService {
|
||||
): Promise<ScheduleEventResponse> {
|
||||
const event = await this.eventRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['attendees'],
|
||||
relations: ['attendees', 'createdBy', 'owner'],
|
||||
});
|
||||
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
this.assertEventVisible(event, actor.id);
|
||||
this.assertEventManageable(event, actor.id);
|
||||
|
||||
// 수정 전 참석자·전직원 여부(알림 대상 비교용) — 아래 필드 변경 전에 캡처
|
||||
// 수정 전 참석자·전직원·공개범위(알림 대상 비교용) — 아래 필드 변경 전에 캡처
|
||||
const oldIds = new Set((event.attendees ?? []).map((a) => a.id));
|
||||
const wasAllHands = event.allHands;
|
||||
const wasPrivate = event.visibility === 'private';
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
const category = await this.categoryRepo.findOne({
|
||||
where: { id: dto.categoryId },
|
||||
});
|
||||
if (!category)
|
||||
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
|
||||
const category = await this.assertCategoryAccessible(
|
||||
dto.categoryId,
|
||||
actor.id,
|
||||
);
|
||||
event.category = { id: category.id } as ScheduleCategory;
|
||||
// 카테고리를 옮기면 공개 범위도 따라 바뀐다(공용↔개인 전환)
|
||||
event.visibility = category.owner ? 'private' : 'company';
|
||||
event.owner = category.owner ? ({ id: actor.id } as User) : null;
|
||||
}
|
||||
if (dto.title !== undefined) event.title = dto.title.trim();
|
||||
if (dto.start !== undefined) event.startDate = dto.start;
|
||||
@@ -187,41 +247,60 @@ export class ScheduleService {
|
||||
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 isPrivate = event.visibility === 'private';
|
||||
if (isPrivate) {
|
||||
event.allHands = false;
|
||||
event.attendees = [];
|
||||
} else 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);
|
||||
|
||||
// 알림: 전 직원 여부를 반영한 '실효 참석자' 기준으로 신규→초대, 기존→수정됨.
|
||||
// 알림: 전 직원 여부를 반영한 '실효 참석자' 기준으로
|
||||
// 신규→초대, 기존→수정됨, 이탈→삭제됨(개인 일정 전환 포함).
|
||||
// (전 직원 일정이면 전 사용자가 실효 참석자 — 수정 전/후 어느 쪽이든 반영)
|
||||
const allIds =
|
||||
wasAllHands || event.allHands ? await this.userService.findAllIds() : [];
|
||||
const oldEffective = new Set(wasAllHands ? allIds : [...oldIds]);
|
||||
const newEffective = event.allHands
|
||||
? allIds
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
const oldEffective = new Set(
|
||||
wasPrivate ? [] : wasAllHands ? allIds : [...oldIds],
|
||||
);
|
||||
const newEffective = isPrivate
|
||||
? []
|
||||
: event.allHands
|
||||
? allIds
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
const newSet = new Set(newEffective);
|
||||
const invited = newEffective.filter((i) => !oldEffective.has(i));
|
||||
const stillThere = newEffective.filter((i) => oldEffective.has(i));
|
||||
const dropped = [...oldEffective].filter((i) => !newSet.has(i));
|
||||
const payload = { eventTitle: event.title, start: event.startDate };
|
||||
await this.notifyEvent('schedule.invited', invited, actor, payload);
|
||||
await this.notifyEvent('schedule.updated', stillThere, actor, payload);
|
||||
await this.notifyEvent('schedule.removed', dropped, actor, payload);
|
||||
|
||||
return this.findEventOrThrow(id);
|
||||
return this.findEventOrThrow(id, actor.id);
|
||||
}
|
||||
|
||||
async deleteEvent(id: string, actor: ScheduleActor): Promise<void> {
|
||||
// 삭제 전 참석자(알림 대상)를 먼저 확보
|
||||
const event = await this.eventRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['attendees'],
|
||||
relations: ['attendees', 'createdBy', 'owner'],
|
||||
});
|
||||
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
// 전 직원 일정이면 전 사용자에게 삭제 알림
|
||||
const recipients = event.allHands
|
||||
? await this.userService.findAllIds()
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
this.assertEventVisible(event, actor.id);
|
||||
this.assertEventManageable(event, actor.id);
|
||||
|
||||
// 전 직원 일정이면 전 사용자에게 삭제 알림 (개인 일정은 알리지 않는다)
|
||||
const recipients =
|
||||
event.visibility === 'private'
|
||||
? []
|
||||
: event.allHands
|
||||
? await this.userService.findAllIds()
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
|
||||
await this.eventRepo.delete({ id });
|
||||
|
||||
@@ -241,6 +320,52 @@ export class ScheduleService {
|
||||
}));
|
||||
}
|
||||
|
||||
// ----- 내부 헬퍼(권한) -----
|
||||
|
||||
// 요청자가 접근(일정 담기·수정·삭제)할 수 있는 카테고리인지 검증하고 반환한다.
|
||||
// 공용 카테고리는 인증 사용자 누구나, 개인 카테고리는 소유자만 접근할 수 있고
|
||||
// 남의 개인 카테고리는 존재 자체를 노출하지 않는다.
|
||||
private async assertCategoryAccessible(
|
||||
id: string,
|
||||
actorId: string,
|
||||
): Promise<ScheduleCategory> {
|
||||
const cat = await this.categoryRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['owner'],
|
||||
});
|
||||
if (!cat || (cat.owner && cat.owner.id !== actorId))
|
||||
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
|
||||
return cat;
|
||||
}
|
||||
|
||||
// 요청자가 볼 수 있는 일정인지 — 남의 개인 일정은 존재를 숨긴다.
|
||||
private assertEventVisible(event: ScheduleEvent, viewerId: string): void {
|
||||
if (event.visibility === 'private' && event.owner?.id !== viewerId)
|
||||
throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 요청자가 수정·삭제할 수 있는 일정인지.
|
||||
private assertEventManageable(event: ScheduleEvent, actorId: string): void {
|
||||
if (!this.canManageEvent(event, actorId)) {
|
||||
// 상태 기반 자동 매핑(AUTH_003 고정 문구) 대신 구체적인 사유를 노출한다.
|
||||
throw new BusinessException(
|
||||
'AUTH_003',
|
||||
event.visibility === 'private'
|
||||
? '본인의 개인 일정만 수정할 수 있습니다.'
|
||||
: '일정을 등록한 사람만 수정하거나 삭제할 수 있습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 관리 권한 판정 — 개인 일정은 소유자, 전사 일정은 작성자.
|
||||
// 작성자가 탈퇴해 비어 있는(created_by NULL) 전사 일정은 아무도 손대지 못하고
|
||||
// 남는 것을 막기 위해 인증 사용자 누구나 관리할 수 있게 둔다.
|
||||
private canManageEvent(event: ScheduleEvent, actorId: string): boolean {
|
||||
if (event.visibility === 'private') return event.owner?.id === actorId;
|
||||
return !event.createdBy || event.createdBy.id === actorId;
|
||||
}
|
||||
|
||||
// ----- 내부 헬퍼 -----
|
||||
|
||||
// 전 직원 일정이면 개별 참석자는 비운다. 아니면 id 참조 배열로 매핑.
|
||||
@@ -270,13 +395,17 @@ export class ScheduleService {
|
||||
});
|
||||
}
|
||||
|
||||
private async findEventOrThrow(id: string): Promise<ScheduleEventResponse> {
|
||||
private async findEventOrThrow(
|
||||
id: string,
|
||||
viewerId: string,
|
||||
): Promise<ScheduleEventResponse> {
|
||||
const e = await this.eventRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['attendees', 'createdBy'],
|
||||
relations: ['attendees', 'createdBy', 'owner'],
|
||||
});
|
||||
if (!e) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
return this.toEventResponse(e);
|
||||
this.assertEventVisible(e, viewerId);
|
||||
return this.toEventResponse(e, viewerId);
|
||||
}
|
||||
|
||||
private toCategoryResponse(c: ScheduleCategory): ScheduleCategoryResponse {
|
||||
@@ -285,10 +414,14 @@ export class ScheduleService {
|
||||
label: c.label,
|
||||
color: c.color,
|
||||
sortOrder: c.sortOrder,
|
||||
personal: !!c.owner,
|
||||
};
|
||||
}
|
||||
|
||||
private toEventResponse(e: ScheduleEvent): ScheduleEventResponse {
|
||||
private toEventResponse(
|
||||
e: ScheduleEvent,
|
||||
viewerId: string,
|
||||
): ScheduleEventResponse {
|
||||
return {
|
||||
id: e.id,
|
||||
categoryId: e.category?.id ?? '',
|
||||
@@ -305,6 +438,9 @@ export class ScheduleService {
|
||||
avatarUrl: u.avatarUrl ?? null,
|
||||
})),
|
||||
createdById: e.createdBy?.id ?? null,
|
||||
visibility: e.visibility,
|
||||
ownerId: e.owner?.id ?? null,
|
||||
canManage: this.canManageEvent(e, viewerId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user