From 42bf97636adb53e8268a3bb3bce855f8bcdd2a80 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 23 Jun 2026 13:56:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=B0=B8=EC=84=9D?= =?UTF-8?q?=EC=9E=90=20=EC=95=8C=EB=A6=BC(=EC=A7=80=EC=A0=95/=EC=88=98?= =?UTF-8?q?=EC=A0=95/=EC=82=AD=EC=A0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일정 CRUD 시 참석자에게 인앱 알림 발송(기존 NotificationService + SSE 재사용). - 생성: 참석자로 지정된 사용자에게 schedule.invited (전 직원 일정은 개별 알림 생략) - 수정: 신규 참석자 → schedule.invited, 기존 참석자 → schedule.updated - 삭제: 참석자에게 schedule.removed (삭제 전 참석자 확보) - 행위자 본인은 자동 제외, payload(actorName/eventTitle/start), 클릭 시 /schedule 이동 - NotificationType(백/프론트) 3종 추가 + notification.store 렌더 케이스 런타임 검증: 지정/수정/삭제 알림이 참석자에게만 정상 도달. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../entities/notification.entity.ts | 5 +- .../modules/schedule/schedule.controller.ts | 17 ++++- .../src/modules/schedule/schedule.module.ts | 2 + .../src/modules/schedule/schedule.service.ts | 76 +++++++++++++++++-- frontend/src/stores/notification.store.ts | 17 +++++ frontend/src/types/notification.ts | 3 + 6 files changed, 111 insertions(+), 9 deletions(-) diff --git a/backend/src/modules/notification/entities/notification.entity.ts b/backend/src/modules/notification/entities/notification.entity.ts index 313ed31..cb7811f 100644 --- a/backend/src/modules/notification/entities/notification.entity.ts +++ b/backend/src/modules/notification/entities/notification.entity.ts @@ -23,7 +23,10 @@ export type NotificationType = | 'task.commented' // 내 관련 업무에 댓글/답글이 달림 | 'member.invited' // 프로젝트에 멤버로 초대됨 | 'member.role_changed' // 프로젝트 내 내 역할이 변경됨 - | 'member.removed'; // 프로젝트에서 제외됨 + | 'member.removed' // 프로젝트에서 제외됨 + | 'schedule.invited' // 일정 참석자로 지정됨 + | 'schedule.updated' // 내가 참석한 일정이 수정됨 + | 'schedule.removed'; // 내가 참석한 일정이 삭제됨 // 알림 — 사용자별 수신함. 표시 문구/아이콘은 프론트가 type+payload 로 조립한다. @Entity('notifications') diff --git a/backend/src/modules/schedule/schedule.controller.ts b/backend/src/modules/schedule/schedule.controller.ts index 0119ff5..126b95f 100644 --- a/backend/src/modules/schedule/schedule.controller.ts +++ b/backend/src/modules/schedule/schedule.controller.ts @@ -114,17 +114,24 @@ export class ScheduleController { @CurrentUser() user: PublicUser, @Body() dto: CreateScheduleEventDto, ): Promise { - return this.scheduleService.createEvent(dto, user.id); + return this.scheduleService.createEvent(dto, { + id: user.id, + name: user.name, + }); } @Patch('events/:id') @ApiOperation({ summary: '일정 수정' }) @ApiResponse({ status: 200, description: '수정 성공' }) updateEvent( + @CurrentUser() user: PublicUser, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateScheduleEventDto, ): Promise { - return this.scheduleService.updateEvent(id, dto); + return this.scheduleService.updateEvent(id, dto, { + id: user.id, + name: user.name, + }); } @Delete('events/:id') @@ -132,9 +139,13 @@ export class ScheduleController { @ApiOperation({ summary: '일정 삭제' }) @ApiResponse({ status: 200, description: '삭제 성공' }) async deleteEvent( + @CurrentUser() user: PublicUser, @Param('id', ParseUUIDPipe) id: string, ): Promise<{ success: boolean }> { - await this.scheduleService.deleteEvent(id); + await this.scheduleService.deleteEvent(id, { + id: user.id, + name: user.name, + }); return { success: true }; } } diff --git a/backend/src/modules/schedule/schedule.module.ts b/backend/src/modules/schedule/schedule.module.ts index 3004b57..6d4fa38 100644 --- a/backend/src/modules/schedule/schedule.module.ts +++ b/backend/src/modules/schedule/schedule.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserModule } from '../user/user.module'; +import { NotificationModule } from '../notification/notification.module'; import { ScheduleCategory } from './entities/schedule-category.entity'; import { ScheduleEvent } from './entities/schedule-event.entity'; import { ScheduleService } from './schedule.service'; @@ -11,6 +12,7 @@ import { ScheduleController } from './schedule.controller'; imports: [ TypeOrmModule.forFeature([ScheduleCategory, ScheduleEvent]), UserModule, + NotificationModule, ], controllers: [ScheduleController], providers: [ScheduleService], diff --git a/backend/src/modules/schedule/schedule.service.ts b/backend/src/modules/schedule/schedule.service.ts index 3a1d8cf..7ca1f8b 100644 --- a/backend/src/modules/schedule/schedule.service.ts +++ b/backend/src/modules/schedule/schedule.service.ts @@ -3,11 +3,19 @@ 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 { 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 { CreateScheduleCategoryDto } from './dto/schedule-category.dto'; import { CreateScheduleEventDto } from './dto/schedule-event.dto'; +// 알림 행위자(누가 일정을 만들었/수정했/삭제했는지) +export interface ScheduleActor { + id: string; + name: string; +} + // 카테고리/일정 응답(원시) — 표시 음영색은 프론트가 color 로 파생 export interface ScheduleCategoryResponse { id: string; @@ -46,6 +54,7 @@ export class ScheduleService { @InjectRepository(ScheduleEvent) private readonly eventRepo: Repository, private readonly userService: UserService, + private readonly notification: NotificationService, ) {} // ----- 카테고리 ----- @@ -115,7 +124,7 @@ export class ScheduleService { async createEvent( dto: CreateScheduleEventDto, - actorId: string, + actor: ScheduleActor, ): Promise { const category = await this.categoryRepo.findOne({ where: { id: dto.categoryId }, @@ -132,15 +141,27 @@ export class ScheduleService { description: dto.description?.trim() || null, allHands: dto.allHands ?? false, attendees: this.attendeeRefs(dto.allHands, dto.attendeeIds), - createdBy: { id: actorId } as User, + createdBy: { id: actor.id } as User, }); const saved = await this.eventRepo.save(event); + + // 참석자로 지정된 사용자에게 알림(전 직원 일정은 개별 알림 생략) + await this.notifyEvent( + 'schedule.invited', + this.idsOf(dto.attendeeIds), + actor, + { + eventTitle: event.title, + start: event.startDate, + }, + ); return this.findEventOrThrow(saved.id); } async updateEvent( id: string, dto: Partial, + actor: ScheduleActor, ): Promise { const event = await this.eventRepo.findOne({ where: { id }, @@ -148,6 +169,9 @@ export class ScheduleService { }); if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.'); + // 수정 전 참석자(알림 대상 비교용) + const oldIds = new Set((event.attendees ?? []).map((a) => a.id)); + if (dto.categoryId !== undefined) { const category = await this.categoryRepo.findOne({ where: { id: dto.categoryId }, @@ -170,12 +194,33 @@ export class ScheduleService { event.attendees = this.attendeeRefs(allHands, dto.attendeeIds); } await this.eventRepo.save(event); + + // 알림: 새로 추가된 참석자 → 초대, 기존 참석자 → 수정됨 + const newIds = (event.attendees ?? []).map((a) => a.id); + const invited = newIds.filter((i) => !oldIds.has(i)); + const stillThere = newIds.filter((i) => oldIds.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); + return this.findEventOrThrow(id); } - async deleteEvent(id: string): Promise { - const res = await this.eventRepo.delete({ id }); - if (!res.affected) throw new NotFoundException('일정을 찾을 수 없습니다.'); + async deleteEvent(id: string, actor: ScheduleActor): Promise { + // 삭제 전 참석자(알림 대상)를 먼저 확보 + const event = await this.eventRepo.findOne({ + where: { id }, + relations: ['attendees'], + }); + if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.'); + const recipients = (event.attendees ?? []).map((a) => a.id); + + await this.eventRepo.delete({ id }); + + await this.notifyEvent('schedule.removed', recipients, actor, { + eventTitle: event.title, + start: event.startDate, + }); } // 참석자 선택 풀(전사 사용자) — 일정 작성 모달의 참석자 선택용. @@ -196,6 +241,27 @@ export class ScheduleService { return [...new Set(ids ?? [])].map((id) => ({ id }) as User); } + // 중복 제거된 id 배열 + private idsOf(ids?: string[]): string[] { + return [...new Set(ids ?? [])]; + } + + // 참석자 알림 발송(행위자=본인은 NotificationService 가 자동 제외). 빈 수신자면 생략. + private async notifyEvent( + type: NotificationType, + recipientIds: string[], + actor: ScheduleActor, + payload: Record, + ): Promise { + if (!recipientIds.length) return; + await this.notification.notify({ + recipientIds, + actorId: actor.id, + type, + payload: { actorName: actor.name, ...payload }, + }); + } + private async findEventOrThrow(id: string): Promise { const e = await this.eventRepo.findOne({ where: { id }, diff --git a/frontend/src/stores/notification.store.ts b/frontend/src/stores/notification.store.ts index f733dc8..98fa0d1 100644 --- a/frontend/src/stores/notification.store.ts +++ b/frontend/src/stores/notification.store.ts @@ -27,6 +27,8 @@ function toView(n: ApiNotification): NotificationView { projectId && taskSeq !== null ? `/projects/${projectId}/tasks/${taskSeq}` : null + // 일정 알림용 + const eventTitle = escapeHtml(pstr(p, 'eventTitle')) let tone = '' let html = '새 알림' @@ -95,6 +97,21 @@ function toView(n: ApiNotification): NotificationView { to = projectId ? `/projects/${projectId}` : null html = `${actor}님이 ${projectName} 프로젝트에서 회원님을 제외했습니다` break + case 'schedule.invited': + tone = 'accent' + to = '/schedule' + html = `${actor}님이 ${eventTitle} 일정에 회원님을 참석자로 지정했습니다` + break + case 'schedule.updated': + tone = 'blue' + to = '/schedule' + html = `${actor}님이 참석 일정 ${eventTitle}을(를) 수정했습니다` + break + case 'schedule.removed': + tone = 'red' + to = '/schedule' + html = `${actor}님이 참석 일정 ${eventTitle}을(를) 삭제했습니다` + break } return { id: n.id, read: n.read, tone, html, time: relativeTimeKo(n.createdAt), to } } diff --git a/frontend/src/types/notification.ts b/frontend/src/types/notification.ts index 2793e71..f2fd7c4 100644 --- a/frontend/src/types/notification.ts +++ b/frontend/src/types/notification.ts @@ -17,6 +17,9 @@ export type NotificationType = | 'member.invited' | 'member.role_changed' | 'member.removed' + | 'schedule.invited' + | 'schedule.updated' + | 'schedule.removed' // 알림(API 원시) — 표시 문구/링크는 프론트가 type+payload 로 조립 export interface ApiNotification {