diff --git a/backend/src/modules/notification/entities/notification.entity.ts b/backend/src/modules/notification/entities/notification.entity.ts index cb7811f..240836e 100644 --- a/backend/src/modules/notification/entities/notification.entity.ts +++ b/backend/src/modules/notification/entities/notification.entity.ts @@ -21,6 +21,7 @@ export type NotificationType = | 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴 | 'task.removed' // 내 관련 업무가 삭제됨 | 'task.commented' // 내 관련 업무에 댓글/답글이 달림 + | 'task.file_attached' // 내 관련 업무에 파일이 첨부됨 | 'member.invited' // 프로젝트에 멤버로 초대됨 | 'member.role_changed' // 프로젝트 내 내 역할이 변경됨 | 'member.removed' // 프로젝트에서 제외됨 diff --git a/backend/src/modules/notification/notification.service.ts b/backend/src/modules/notification/notification.service.ts index c2958a0..f018275 100644 --- a/backend/src/modules/notification/notification.service.ts +++ b/backend/src/modules/notification/notification.service.ts @@ -5,7 +5,8 @@ import { type OnModuleInit, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { LessThan, Repository } from 'typeorm'; import { Subject, merge, interval, type Observable } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import Redis from 'ioredis'; @@ -65,6 +66,8 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy { private subscriber: Redis | null = null; // 실시간 알림 채널명 private static readonly CHANNEL = 'relay:notifications'; + // 읽은 알림 보관 기간(일) — 이보다 오래된 '읽음' 알림은 주기적으로 정리해 무한 증가를 막는다. + private static readonly READ_RETENTION_DAYS = 90; constructor( @InjectRepository(Notification) @@ -224,6 +227,29 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy { return { unreadCount: 0 }; } + // 오래된 '읽음' 알림 정리 — 매일 새벽 4시. 무한 증가 방지(미읽음은 보존). + // best-effort: 실패해도 서비스에 영향 없음(다음 주기에 재시도). + @Cron(CronExpression.EVERY_DAY_AT_4AM) + async cleanupOldRead(): Promise { + try { + const cutoff = new Date( + Date.now() - + NotificationService.READ_RETENTION_DAYS * 24 * 60 * 60 * 1000, + ); + const result = await this.notificationRepo.delete({ + read: true, + createdAt: LessThan(cutoff), + }); + if (result.affected) { + this.logger.log(`오래된 읽음 알림 ${result.affected}건 정리`); + } + } catch (err) { + this.logger.warn( + `읽음 알림 정리 실패: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + // 사용자별 실시간 스트림(SSE) — 본인 알림만 필터 + 주기적 하트비트(프록시 idle 차단 방지) streamFor(userId: string): Observable { const events = this.stream$.asObservable().pipe( diff --git a/backend/src/modules/notification/web-push.service.spec.ts b/backend/src/modules/notification/web-push.service.spec.ts new file mode 100644 index 0000000..38c75a1 --- /dev/null +++ b/backend/src/modules/notification/web-push.service.spec.ts @@ -0,0 +1,66 @@ +import { WebPushService } from './web-push.service'; +import type { NotificationType } from './entities/notification.entity'; + +// 모든 NotificationType 에 대해 푸시 문구가 조립되는지 검증한다. +// (NotificationType 을 추가하고 notificationContent 의 case 를 빠뜨리면 기본 문구가 남아 실패) +// +// SSOT 주의: 인앱 표시 문구는 프론트 notification.store.ts 의 toView() 가 별도로 담당한다. +// 새 타입을 추가하면 이 목록과 양쪽(백엔드 notificationContent, 프론트 toView)을 함께 갱신할 것. +const ALL_TYPES: NotificationType[] = [ + 'task.assigned', + 'task.unassigned', + 'task.approval_requested', + 'task.approved', + 'task.changes_requested', + 'task.started', + 'task.due_changed', + 'task.checkpoint_reported', + 'task.removed', + 'task.commented', + 'task.file_attached', + 'member.invited', + 'member.role_changed', + 'member.removed', + 'schedule.invited', + 'schedule.updated', + 'schedule.removed', +]; + +// 서버 기본 문구(케이스 누락 시 남는 값) — 이 문구가 남으면 매핑이 빠진 것. +const DEFAULT_BODY = '새 알림이 도착했습니다'; + +describe('WebPushService.notificationContent', () => { + // notificationContent 는 repo/config 를 쓰지 않으므로 의존성 없이 인스턴스화한다. + const service = new WebPushService( + null as unknown as never, + null as unknown as never, + ); + + // 표시 문구 조립에 쓰이는 대표 payload(모든 필드 채워 어떤 case 든 자연스럽게) + const payload = { + actorName: '홍길동', + projectName: '테스트 프로젝트', + projectId: 'p-1', + taskTitle: '샘플 업무', + taskSeq: 12, + eventTitle: '주간 회의', + roleType: 'admin', + isReply: true, + fileName: '보고서.pdf', + dueDate: '2026-07-10T00:00:00.000Z', + resumed: true, + }; + + it.each(ALL_TYPES)('%s 타입의 본문이 기본 문구가 아니어야 한다', (type) => { + const content = service.notificationContent(type, payload); + expect(content.title).toBeTruthy(); + expect(content.body).not.toBe(DEFAULT_BODY); + expect(content.body.length).toBeGreaterThan(0); + }); + + it('타입 목록이 17종(현재 정의)과 일치해야 한다', () => { + // NotificationType 추가 시 이 테스트가 알려주도록 개수를 고정한다. + expect(ALL_TYPES).toHaveLength(17); + expect(new Set(ALL_TYPES).size).toBe(ALL_TYPES.length); + }); +}); diff --git a/backend/src/modules/notification/web-push.service.ts b/backend/src/modules/notification/web-push.service.ts index 6a9fa50..7e69240 100644 --- a/backend/src/modules/notification/web-push.service.ts +++ b/backend/src/modules/notification/web-push.service.ts @@ -22,6 +22,19 @@ function pnum(p: Record, k: string): number | null { const v = p[k]; return typeof v === 'number' ? v : null; } +function pbool(p: Record, k: string): boolean { + return p[k] === true; +} + +// 마감일(ISO) → 한국식 날짜 라벨(한국 시간대 고정). 파싱 실패 시 빈 문자열. +function fmtDueKo(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return new Intl.DateTimeFormat('ko-KR', { + dateStyle: 'long', + timeZone: 'Asia/Seoul', + }).format(d); +} // 웹 푸시 발송 + 구독 관리. // VAPID 키 미설정 시 비활성(발송 skip) — storage 미설정 패턴과 동일한 graceful degrade. @@ -29,6 +42,8 @@ function pnum(p: Record, k: string): number | null { export class WebPushService implements OnModuleInit { private readonly logger = new Logger(WebPushService.name); private enabled = false; + // 푸시 서비스가 오프라인 기기에 보관·재시도하는 최대 시간(초). 하루 지나면 폐기. + private static readonly PUSH_TTL_SECONDS = 60 * 60 * 24; constructor( @InjectRepository(PushSubscription) @@ -82,6 +97,10 @@ export class WebPushService implements OnModuleInit { // type+payload → 푸시 표시 내용(제목=행위자/맥락, 본문=행위, url=이동 경로). // 프론트 toView 의 푸시 전용 plain 버전(서비스워커가 번들 모듈을 import 못하므로 서버에서 완성). + // + // SSOT 주의: 인앱 표시 문구는 프론트 `notification.store.ts` 의 `toView()` 가 별도로 조립한다 + // (인앱=HTML/톤, 푸시=평문). NotificationType 을 추가하면 **두 곳 모두** case 를 넣어야 하며, + // 여기 커버리지는 `web-push.service.spec.ts` 가 기본 문구 잔존 여부로 검증한다. notificationContent( type: NotificationType, payload: Record, @@ -111,11 +130,16 @@ export class WebPushService implements OnModuleInit { body = `'${taskTitle}' 업무 담당에서 회원님을 제외했습니다`; break; case 'task.started': - body = `'${taskTitle}' 업무를 시작했습니다`; + // 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작' + body = `'${taskTitle}' 업무를 ${pbool(payload, 'resumed') ? '재개' : '시작'}했습니다`; break; - case 'task.due_changed': - body = `'${taskTitle}' 업무의 마감기한이 변경되었습니다`; + case 'task.due_changed': { + const label = fmtDueKo(pstr(payload, 'dueDate')); + body = label + ? `'${taskTitle}' 업무의 마감기한을 ${label}(으)로 변경했습니다` + : `'${taskTitle}' 업무의 마감기한을 해제했습니다`; break; + } case 'task.checkpoint_reported': body = `'${taskTitle}' 업무의 중간 점검을 보고했습니다`; break; @@ -136,6 +160,13 @@ export class WebPushService implements OnModuleInit { case 'task.commented': body = `'${taskTitle}'에 ${payload.isReply === true ? '답글' : '댓글'}을 남겼습니다`; break; + case 'task.file_attached': { + const fileName = pstr(payload, 'fileName'); + body = fileName + ? `'${taskTitle}'에 파일 '${fileName}'을(를) 첨부했습니다` + : `'${taskTitle}'에 파일을 첨부했습니다`; + break; + } case 'member.invited': body = `'${projectName}' 프로젝트에 초대했습니다`; url = projectUrl; @@ -164,28 +195,34 @@ export class WebPushService implements OnModuleInit { return { title, body, url }; } - // 한 사용자의 모든 구독에 발송 — 만료(404/410) 구독은 정리. best-effort. + // 한 사용자의 모든 구독(기기)에 발송 — 만료(404/410) 구독은 정리. best-effort. + // 다기기 병렬 발송(allSettled) — 하나가 실패/지연돼도 다른 기기 발송을 막지 않는다. async sendToUser(userId: string, content: PushContent): Promise { if (!this.enabled) return; const subs = await this.subRepo.find({ where: { user: { id: userId } } }); if (subs.length === 0) return; const json = JSON.stringify(content); - for (const s of subs) { - try { - await webpush.sendNotification( - { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, - json, + await Promise.allSettled(subs.map((s) => this.sendOne(s, json))); + } + + // 단일 구독 발송 — 만료 구독 정리 포함. 예외는 흡수(호출부 흐름 보호). + private async sendOne(s: PushSubscription, json: string): Promise { + try { + await webpush.sendNotification( + { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, + json, + // 오프라인 기기용 보관 시간 + 일반 우선순위(배터리 절약 정책 존중) + { TTL: WebPushService.PUSH_TTL_SECONDS, urgency: 'normal' }, + ); + } catch (err: unknown) { + const statusCode = (err as { statusCode?: number })?.statusCode; + if (statusCode === 404 || statusCode === 410) { + // 만료/해지된 구독 — 정리 + await this.subRepo.delete({ id: s.id }); + } else { + this.logger.warn( + `푸시 발송 실패(무시): ${statusCode ?? ''} ${err instanceof Error ? err.message : String(err)}`, ); - } catch (err: unknown) { - const statusCode = (err as { statusCode?: number })?.statusCode; - if (statusCode === 404 || statusCode === 410) { - // 만료/해지된 구독 — 정리 - await this.subRepo.delete({ id: s.id }); - } else { - this.logger.warn( - `푸시 발송 실패(무시): ${statusCode ?? ''} ${err instanceof Error ? err.message : String(err)}`, - ); - } } } } diff --git a/backend/src/modules/schedule/schedule.service.ts b/backend/src/modules/schedule/schedule.service.ts index 7ca1f8b..9a0b944 100644 --- a/backend/src/modules/schedule/schedule.service.ts +++ b/backend/src/modules/schedule/schedule.service.ts @@ -145,16 +145,14 @@ export class ScheduleService { }); const saved = await this.eventRepo.save(event); - // 참석자로 지정된 사용자에게 알림(전 직원 일정은 개별 알림 생략) - await this.notifyEvent( - 'schedule.invited', - this.idsOf(dto.attendeeIds), - actor, - { - eventTitle: event.title, - start: event.startDate, - }, - ); + // 참석자에게 초대 알림 — 전 직원 일정이면 전 사용자, 아니면 지정 참석자 + 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); } @@ -169,8 +167,9 @@ export class ScheduleService { }); if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.'); - // 수정 전 참석자(알림 대상 비교용) + // 수정 전 참석자·전직원 여부(알림 대상 비교용) — 아래 필드 변경 전에 캡처 const oldIds = new Set((event.attendees ?? []).map((a) => a.id)); + const wasAllHands = event.allHands; if (dto.categoryId !== undefined) { const category = await this.categoryRepo.findOne({ @@ -195,10 +194,16 @@ export class ScheduleService { } 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 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 invited = newEffective.filter((i) => !oldEffective.has(i)); + const stillThere = newEffective.filter((i) => oldEffective.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); @@ -213,7 +218,10 @@ export class ScheduleService { relations: ['attendees'], }); if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.'); - const recipients = (event.attendees ?? []).map((a) => a.id); + // 전 직원 일정이면 전 사용자에게 삭제 알림 + const recipients = event.allHands + ? await this.userService.findAllIds() + : (event.attendees ?? []).map((a) => a.id); await this.eventRepo.delete({ id }); diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index 3f76c3f..d3f28b1 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -851,6 +851,20 @@ export class TaskService { taskSeq: task.seq, }); + // 알림 — 업무 관련자(지시자 + 담당자, 업로더 본인 제외)에게 '파일 첨부' + await this.notification.notify({ + recipientIds: [ + ...(task.issuer ? [task.issuer.id] : []), + ...task.assignees.map((a) => a.id), + ], + actorId: actingUserId, + type: 'task.file_attached', + payload: { + ...this.taskNotifyPayload(project, task, membership.user.name), + fileName: name, + }, + }); + return this.toAttachmentResponse( { ...saved, uploader: membership.user }, project, @@ -1182,12 +1196,16 @@ export class TaskService { payload: this.taskNotifyPayload(project, task, actorName), }); } else if (next === 'prog') { - // 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게 + // 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게. + // resumed=true 면 '재개', 아니면 '시작'으로 문구 분기(수정 요청 후 재개 구분). await this.notification.notify({ recipientIds: task.issuer ? [task.issuer.id] : [], actorId: actingUserId, type: 'task.started', - payload: this.taskNotifyPayload(project, task, actorName), + payload: { + ...this.taskNotifyPayload(project, task, actorName), + resumed: from === 'changes', + }, }); } } diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index b8a8b92..d68a442 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -76,6 +76,12 @@ export class UserService implements OnApplicationBootstrap { }); } + // 전체 사용자 id 목록 — 전사 알림(예: 전 직원 일정)의 수신자 산출용 + async findAllIds(): Promise { + const rows = await this.userRepository.find({ select: { id: true } }); + return rows.map((u) => u.id); + } + // 로그인 검증용 — passwordHash 를 명시적으로 포함하여 조회 findByEmailWithPassword(email: string): Promise { return this.userRepository diff --git a/frontend/public/sw.js b/frontend/public/sw.js index c670c8a..932ade2 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -41,6 +41,49 @@ self.addEventListener('push', (event) => { ) }) +// 브라우저가 푸시 구독을 자체 회전(만료·교체)시키면 발생 — 새 구독으로 재등록해 +// 사용자가 앱을 다시 열지 않아도 푸시가 끊기지 않게 한다. best-effort(실패 시 앱 재진입의 +// enablePush(true) 가 최신화). 백엔드 경로는 기본 프록시('/api') 기준. +self.addEventListener('pushsubscriptionchange', (event) => { + event.waitUntil( + (async () => { + try { + // 브라우저가 새 구독을 넘겨줬으면 그대로, 아니면 이전 구독의 VAPID 키로 재구독 + let sub = event.newSubscription || null + if (!sub) { + const opts = + event.oldSubscription && event.oldSubscription.options + ? event.oldSubscription.options + : null + const appServerKey = opts && opts.applicationServerKey + // 키가 없으면 재구독 불가 — 앱 재진입 때 복구되도록 조용히 종료 + if (!appServerKey) return + sub = await self.registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: appServerKey, + }) + } + if (!sub) return + const json = sub.toJSON() + await fetch('/api/notifications/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + endpoint: sub.endpoint, + keys: { + p256dh: (json.keys && json.keys.p256dh) || '', + auth: (json.keys && json.keys.auth) || '', + }, + }), + }) + } catch (e) { + /* 재구독/재등록 실패 무시 — 앱 재진입 시 최신화 */ + } + })(), + ) +}) + self.addEventListener('notificationclick', (event) => { event.notification.close() const url = (event.notification.data && event.notification.data.url) || '/' diff --git a/frontend/src/stores/notification.store.ts b/frontend/src/stores/notification.store.ts index ae2ca60..ea267e1 100644 --- a/frontend/src/stores/notification.store.ts +++ b/frontend/src/stores/notification.store.ts @@ -49,6 +49,10 @@ function pnum(p: Record, k: string): number | null { } // API 알림 → 화면 뷰(type+payload → 톤/문구/이동경로). 사용자 입력은 escapeHtml 후 신뢰 태그만 조립 +// +// SSOT 주의: 푸시(사이트 닫힘) 표시 문구는 백엔드 web-push.service.ts 의 notificationContent() 가 +// 별도로 조립한다(인앱=HTML/톤, 푸시=평문). NotificationType 을 추가하면 **양쪽 모두** case 를 넣을 것. +// (백엔드 커버리지는 web-push.service.spec.ts 가 검증) function toView(n: ApiNotification): NotificationView { const p = n.payload const actor = escapeHtml(pstr(p, 'actorName') || '누군가') @@ -77,7 +81,8 @@ function toView(n: ApiNotification): NotificationView { break case 'task.started': tone = 'blue' - html = `${actor}님이 ${taskTitle} 업무를 시작했습니다` + // 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작' + html = `${actor}님이 ${taskTitle} 업무를 ${p.resumed === true ? '재개' : '시작'}했습니다` break case 'task.due_changed': { const due = pstr(p, 'dueDate') @@ -113,6 +118,14 @@ function toView(n: ApiNotification): NotificationView { tone = '' html = `${actor}님이 ${taskTitle}에 ${p.isReply === true ? '답글' : '댓글'}을 남겼습니다` break + case 'task.file_attached': { + const fileName = escapeHtml(pstr(p, 'fileName')) + tone = '' + html = fileName + ? `${actor}님이 ${taskTitle}에 파일 ${fileName}을(를) 첨부했습니다` + : `${actor}님이 ${taskTitle}에 파일을 첨부했습니다` + break + } case 'member.invited': tone = 'accent' to = projectId ? `/projects/${projectId}` : null @@ -204,20 +217,40 @@ export const useNotificationStore = defineStore('notification', () => { es.close() es = null } - if (wantConnected && !reconnectTimer) { - reconnectTimer = setTimeout(() => { - reconnectTimer = null - if (wantConnected) openStream() - }, RECONNECT_DELAY) - } + scheduleReconnect() } } + // 재연결 예약(중복 방지) — 지연 후 '재동기화 → 스트림 오픈' 순으로 복구한다. + // 재동기화(fetchInitial)는 axios 인터셉터를 타므로, + // 1) 끊긴 사이 놓친 알림을 수신함에 복구하고(미읽음 수도 서버값으로 보정) + // 2) access 토큰이 만료됐다면 자동 refresh 되어 쿠키가 갱신된다 + // → 만료된 토큰으로 SSE 를 여는 무한 실패 루프를 방지. + function scheduleReconnect(): void { + if (!wantConnected || reconnectTimer) return + reconnectTimer = setTimeout(async () => { + reconnectTimer = null + if (!wantConnected) return + try { + await fetchInitial() + } catch { + // 재동기화 실패(세션 만료/네트워크 지속) — 스트림은 열지 않고 다음 주기 재시도 + scheduleReconnect() + return + } + if (wantConnected) openStream() + }, RECONNECT_DELAY) + } + // 연결 시작(멱등) — 최초 1회 목록 로드 + 스트림 오픈. 로그인 화면 진입마다 호출돼도 안전 async function connect(): Promise { if (wantConnected) return wantConnected = true - await fetchInitial() + try { + await fetchInitial() + } catch { + // 초기 로드 실패해도 스트림은 열어 둔다 — 실패 시 onerror→재연결이 복구 + } openStream() } diff --git a/frontend/src/types/notification.ts b/frontend/src/types/notification.ts index 2ca3ba6..e89ab20 100644 --- a/frontend/src/types/notification.ts +++ b/frontend/src/types/notification.ts @@ -14,6 +14,7 @@ export type NotificationType = | 'task.checkpoint_reported' | 'task.removed' | 'task.commented' + | 'task.file_attached' | 'member.invited' | 'member.role_changed' | 'member.removed'