diff --git a/backend/src/migrations/1783700000000-AddSchedulePersonal.ts b/backend/src/migrations/1783700000000-AddSchedulePersonal.ts new file mode 100644 index 0000000..26a2215 --- /dev/null +++ b/backend/src/migrations/1783700000000-AddSchedulePersonal.ts @@ -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 { + // 카테고리 소유자 — 기존 카테고리는 전부 전사 공용(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 { + 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"`, + ); + } +} diff --git a/backend/src/modules/schedule/dto/schedule-category.dto.ts b/backend/src/modules/schedule/dto/schedule-category.dto.ts index 28e1183..94393b8 100644 --- a/backend/src/modules/schedule/dto/schedule-category.dto.ts +++ b/backend/src/modules/schedule/dto/schedule-category.dto.ts @@ -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, ) {} diff --git a/backend/src/modules/schedule/entities/schedule-category.entity.ts b/backend/src/modules/schedule/entities/schedule-category.entity.ts index c30b949..8f910a3 100644 --- a/backend/src/modules/schedule/entities/schedule-category.entity.ts +++ b/backend/src/modules/schedule/entities/schedule-category.entity.ts @@ -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; diff --git a/backend/src/modules/schedule/entities/schedule-event.entity.ts b/backend/src/modules/schedule/entities/schedule-event.entity.ts index 23384cd..edd8302 100644 --- a/backend/src/modules/schedule/entities/schedule-event.entity.ts +++ b/backend/src/modules/schedule/entities/schedule-event.entity.ts @@ -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; diff --git a/backend/src/modules/schedule/schedule.controller.ts b/backend/src/modules/schedule/schedule.controller.ts index 126b95f..6ed43e1 100644 --- a/backend/src/modules/schedule/schedule.controller.ts +++ b/backend/src/modules/schedule/schedule.controller.ts @@ -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 { - return this.scheduleService.listCategories(); + listCategories( + @CurrentUser() user: PublicUser, + ): Promise { + return this.scheduleService.listCategories(user.id); } @Post('categories') @ApiOperation({ summary: '일정 카테고리 생성' }) @ApiResponse({ status: 201, description: '생성 성공' }) createCategory( + @CurrentUser() user: PublicUser, @Body() dto: CreateScheduleCategoryDto, ): Promise { - 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 { - 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 { @@ -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') diff --git a/backend/src/modules/schedule/schedule.service.ts b/backend/src/modules/schedule/schedule.service.ts index 9a0b944..07c8d0d 100644 --- a/backend/src/modules/schedule/schedule.service.ts +++ b/backend/src/modules/schedule/schedule.service.ts @@ -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 { + // 전사 공용 + 요청자 본인의 개인 카테고리만 반환한다. + async listCategories(viewerId: string): Promise { 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 { - // 다음 정렬값 = 현재 최대 + 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, + actorId: string, ): Promise { - 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 { - const res = await this.categoryRepo.delete({ id }); - if (!res.affected) - throw new NotFoundException('카테고리를 찾을 수 없습니다.'); + // 공용 카테고리는 남의 일정까지 지워지므로 비어 있을 때만 삭제할 수 있다. + async deleteCategory(id: string, actorId: string): Promise { + 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 { + 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 { - 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 { 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 { // 삭제 전 참석자(알림 대상)를 먼저 확보 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 { + 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 { + private async findEventOrThrow( + id: string, + viewerId: string, + ): Promise { 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), }; } } diff --git a/frontend/src/components/schedule/ScheduleCategoryModal.vue b/frontend/src/components/schedule/ScheduleCategoryModal.vue index d50b05f..48670e7 100644 --- a/frontend/src/components/schedule/ScheduleCategoryModal.vue +++ b/frontend/src/components/schedule/ScheduleCategoryModal.vue @@ -52,11 +52,18 @@ const isEdit = computed(() => !!props.init) const name = ref(props.init?.label ?? '') const hue = ref(props.init?.color ? hexToHue(props.init.color) : 210) const color = computed(() => hslToHex(hue.value, CAT_SAT, CAT_LIG)) +// 개인 전용 여부 — 생성 시에만 선택할 수 있다(수정으로 공용↔개인 전환 불가) +const personal = ref(props.init?.personal ?? false) const valid = computed(() => !!name.value.trim()) function submit(): void { if (!valid.value) return - emit('save', { label: name.value.trim(), color: color.value }) + const payload: ScheduleCategoryPayload = { + label: name.value.trim(), + color: color.value, + } + if (!isEdit.value) payload.personal = personal.value + emit('save', payload) } @@ -75,6 +82,46 @@ function submit(): void { @keydown.enter="submit" > + +
+ * 구분 +
+ {{ personal ? '개인 전용 카테고리입니다. (나만 볼 수 있음)' : '사내 공용 카테고리입니다.' }} +
+ +
색상
@@ -117,6 +164,41 @@ function submit(): void { align-items: center; gap: 0.75rem; } +/* 공개 범위 선택 — 2분할 세그먼트 */ +.scope-opts { + display: flex; + gap: 0.375rem; +} +.scope-opt { + flex: 1; + height: 2.25rem; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: #fff; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-2); + cursor: pointer; +} +.scope-opt:hover { + background: #f9fafb; +} +.scope-opt.on { + border-color: var(--accent); + background: var(--accent-weak); + color: var(--accent); +} +.scope-hint { + margin: 0.4375rem 0 0; + font-size: 0.75rem; + line-height: 1.5; + color: var(--text-3); +} +.scope-note { + font-size: 0.8125rem; + color: var(--text-2); +} .cat-dot { width: 1.5rem; height: 1.5rem; diff --git a/frontend/src/components/schedule/ScheduleDetailPanel.vue b/frontend/src/components/schedule/ScheduleDetailPanel.vue index 66d2eb1..acb0fb7 100644 --- a/frontend/src/components/schedule/ScheduleDetailPanel.vue +++ b/frontend/src/components/schedule/ScheduleDetailPanel.vue @@ -34,6 +34,9 @@ const dur = computed(() => ) const attendees = computed(() => ev.value?.attendees ?? []) +const isPersonal = computed(() => ev.value?.visibility === 'private') +// 수정·삭제 노출 기준 — 서버가 내려준 권한을 그대로 따른다. +const canManage = computed(() => !!ev.value?.canManage)