feat: 알림 신뢰성·내용 보강 (SSE 재동기화·웹푸시·전직원 일정·파일첨부)
[신뢰성] - SSE 재연결 시 fetchInitial 로 재동기화: 끊긴 사이 놓친 알림 복구 + access 토큰 자동 refresh 로 만료 토큰 재연결 무한루프 해소 - 서비스워커 pushsubscriptionchange 처리로 구독 회전 시 자동 재등록 - 읽은 알림 90일 retention 정리 배치(@Cron) - 웹푸시 다기기 병렬 발송(allSettled) + TTL/urgency 지정 - 알림 문구 SSOT 상호참조 주석 + web-push 커버리지 테스트 추가 [내용] - 전 직원(allHands) 일정도 전 사용자에게 알림(생성/수정/삭제) - task.file_attached 신규 타입: 파일 첨부 시 지시자·담당자 알림 - task.started 재개(수정요청 후) 문구 '재개/시작' 구분 - task.due_changed 푸시에 날짜/해제 상세 반영 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ export type NotificationType =
|
||||
| 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴
|
||||
| 'task.removed' // 내 관련 업무가 삭제됨
|
||||
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
|
||||
| 'task.file_attached' // 내 관련 업무에 파일이 첨부됨
|
||||
| 'member.invited' // 프로젝트에 멤버로 초대됨
|
||||
| 'member.role_changed' // 프로젝트 내 내 역할이 변경됨
|
||||
| 'member.removed' // 프로젝트에서 제외됨
|
||||
|
||||
@@ -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<void> {
|
||||
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<MessageEvent> {
|
||||
const events = this.stream$.asObservable().pipe(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,19 @@ function pnum(p: Record<string, unknown>, k: string): number | null {
|
||||
const v = p[k];
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
function pbool(p: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>,
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ export class UserService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
// 전체 사용자 id 목록 — 전사 알림(예: 전 직원 일정)의 수신자 산출용
|
||||
async findAllIds(): Promise<string[]> {
|
||||
const rows = await this.userRepository.find({ select: { id: true } });
|
||||
return rows.map((u) => u.id);
|
||||
}
|
||||
|
||||
// 로그인 검증용 — passwordHash 를 명시적으로 포함하여 조회
|
||||
findByEmailWithPassword(email: string): Promise<User | null> {
|
||||
return this.userRepository
|
||||
|
||||
@@ -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) || '/'
|
||||
|
||||
@@ -49,6 +49,10 @@ function pnum(p: Record<string, unknown>, 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 = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 시작했습니다`
|
||||
// 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작'
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 ${p.resumed === true ? '재개' : '시작'}했습니다`
|
||||
break
|
||||
case 'task.due_changed': {
|
||||
const due = pstr(p, 'dueDate')
|
||||
@@ -113,6 +118,14 @@ function toView(n: ApiNotification): NotificationView {
|
||||
tone = ''
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>에 ${p.isReply === true ? '답글' : '댓글'}을 남겼습니다`
|
||||
break
|
||||
case 'task.file_attached': {
|
||||
const fileName = escapeHtml(pstr(p, 'fileName'))
|
||||
tone = ''
|
||||
html = fileName
|
||||
? `<b>${actor}</b>님이 <b>${taskTitle}</b>에 파일 <b>${fileName}</b>을(를) 첨부했습니다`
|
||||
: `<b>${actor}</b>님이 <b>${taskTitle}</b>에 파일을 첨부했습니다`
|
||||
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<void> {
|
||||
if (wantConnected) return
|
||||
wantConnected = true
|
||||
await fetchInitial()
|
||||
try {
|
||||
await fetchInitial()
|
||||
} catch {
|
||||
// 초기 로드 실패해도 스트림은 열어 둔다 — 실패 시 onerror→재연결이 복구
|
||||
}
|
||||
openStream()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export type NotificationType =
|
||||
| 'task.checkpoint_reported'
|
||||
| 'task.removed'
|
||||
| 'task.commented'
|
||||
| 'task.file_attached'
|
||||
| 'member.invited'
|
||||
| 'member.role_changed'
|
||||
| 'member.removed'
|
||||
|
||||
Reference in New Issue
Block a user