feat: PC 데스크톱 알림(웹 푸시) 추가 — 인앱/SSE에 OS 토스트 연동

카카오톡처럼 PC에 OS 토스트가 뜨도록 Web Notifications(Level1) +
Web Push/서비스워커(Level2)를 기존 인앱·SSE 알림 위에 얹는다.
중복 방지: 페이지가 열려 있으면 SSE 로컬 알림(포커스면 인앱만),
닫혀 있으면 서비스워커 푸시가 담당한다.

- 백엔드: push_subscriptions 엔티티 + WebPushService(VAPID 설정·
  type→{title,body,url} 포매터·만료 구독 정리), notify()가 SSE와
  함께 푸시 발송, push/subscribe·unsubscribe 엔드포인트, 마이그레이션,
  web-push 의존성
- 프론트: public/sw.js(push/클릭), usePush 컴포저블(권한·구독, 결과
  안내), notification.store 로컬 토스트, AppShell 'PC 알림 켜기' 버튼
- env/compose: VAPID 키 주입(공개키→프론트, 비밀키→백엔드)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 00:49:33 +09:00
parent 26cad39857
commit 8d00d71072
23 changed files with 847 additions and 18 deletions
+6
View File
@@ -51,3 +51,9 @@ CLAUDE_MODEL=claude-sonnet-4-6
# 미설정 시 드라이브 기능만 비활성화되고 앱은 정상 부팅된다.
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
# ==========================================
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시만 비활성.
# ==========================================
VAPID_PRIVATE_KEY=
+3
View File
@@ -50,3 +50,6 @@ CLAUDE_MODEL=claude-sonnet-4-6
# 운영은 권한 최소화를 위해 해당 버킷에만 접근 가능한 서브계정 키 사용 권장.
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키). 미설정 시 푸시만 비활성.
VAPID_PRIVATE_KEY=
+85
View File
@@ -25,6 +25,7 @@
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"@types/passport-google-oauth20": "^2.0.17",
"@types/web-push": "^3.6.4",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
@@ -46,6 +47,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
"web-push": "^3.6.7",
"winston": "^3.17.0"
},
"devDependencies": {
@@ -4962,6 +4964,15 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -5939,6 +5950,15 @@
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -6291,6 +6311,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -6710,6 +6742,12 @@
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.4",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz",
"integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -9510,6 +9548,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -9551,6 +9598,19 @@
"node": ">=10.19.0"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/human-signals": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
@@ -11686,6 +11746,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -15094,6 +15160,25 @@
"defaults": "^1.0.3"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
+2
View File
@@ -41,6 +41,7 @@
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"@types/passport-google-oauth20": "^2.0.17",
"@types/web-push": "^3.6.4",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
@@ -62,6 +63,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
"web-push": "^3.6.7",
"winston": "^3.17.0"
},
"devDependencies": {
@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 웹 푸시 구독 테이블(push_subscriptions) 추가 — 브라우저 PushSubscription 을 사용자별로 보관.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
export class AddPushSubscriptions1783300000000 implements MigrationInterface {
name = 'AddPushSubscriptions1783300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"user_id" uuid,
CONSTRAINT "PK_push_subscriptions" PRIMARY KEY ("id")
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_push_subscriptions_endpoint" ON "push_subscriptions" ("endpoint")`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_push_subscriptions_user" ON "push_subscriptions" ("user_id")`,
);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_push_subscriptions_user'
) THEN
ALTER TABLE "push_subscriptions"
ADD CONSTRAINT "FK_push_subscriptions_user"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE NO ACTION;
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "push_subscriptions" DROP CONSTRAINT IF EXISTS "FK_push_subscriptions_user"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "push_subscriptions"`);
}
}
@@ -0,0 +1,37 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 브라우저 PushSubscription.keys
class PushKeysDto {
@ApiProperty({ description: '구독 공개키(p256dh)' })
@IsString()
@IsNotEmpty()
p256dh!: string;
@ApiProperty({ description: '인증 시크릿(auth)' })
@IsString()
@IsNotEmpty()
auth!: string;
}
// 푸시 구독 등록 — 브라우저 PushSubscription.toJSON() 형태
export class SubscribePushDto {
@ApiProperty({ description: '푸시 서비스 엔드포인트 URL' })
@IsString()
@IsNotEmpty()
endpoint!: string;
@ApiProperty({ description: '구독 키(p256dh/auth)' })
@ValidateNested()
@Type(() => PushKeysDto)
keys!: PushKeysDto;
}
// 푸시 구독 해제
export class UnsubscribePushDto {
@ApiProperty({ description: '해제할 구독 엔드포인트 URL' })
@IsString()
@IsNotEmpty()
endpoint!: string;
}
@@ -0,0 +1,39 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 웹 푸시 구독 — 브라우저(PushManager)가 발급한 구독 정보를 사용자별로 보관한다.
// 한 사용자가 여러 기기/브라우저를 가질 수 있으므로 userId당 N개. endpoint 가 고유 키.
@Entity('push_subscriptions')
export class PushSubscription {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 구독 소유자 (사용자 삭제 시 구독도 함께 삭제 — Notification.recipient 와 동일 패턴)
@Index()
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
// 브라우저 푸시 엔드포인트(푸시 서비스 URL) — 구독의 고유 식별자
@Index({ unique: true })
@Column({ type: 'text' })
endpoint!: string;
// 암호화 키(브라우저 PushSubscription.keys)
@Column({ type: 'text' })
p256dh!: string;
@Column({ type: 'text' })
auth!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -1,4 +1,5 @@
import {
Body,
Controller,
DefaultValuePipe,
Get,
@@ -22,13 +23,18 @@ import {
NotificationService,
type NotificationListResult,
} from './notification.service';
import { WebPushService } from './web-push.service';
import { SubscribePushDto, UnsubscribePushDto } from './dto/subscribe-push.dto';
// 알림 컨트롤러 — 모든 엔드포인트 인증 필요(본인 수신함 전용)
@ApiTags('Notification')
@Controller('notifications')
@UseGuards(JwtAuthGuard)
export class NotificationController {
constructor(private readonly notificationService: NotificationService) {}
constructor(
private readonly notificationService: NotificationService,
private readonly webPush: WebPushService,
) {}
@Get()
@ApiOperation({ summary: '내 알림 목록 (페이지네이션 + 미읽음 수)' })
@@ -62,6 +68,35 @@ export class NotificationController {
return this.notificationService.markAllRead(user.id);
}
@Post('push/subscribe')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '웹 푸시 구독 등록(브라우저 PushSubscription)' })
@ApiResponse({ status: 200, description: '등록 성공' })
async subscribePush(
@CurrentUser() user: PublicUser,
@Body() dto: SubscribePushDto,
): Promise<null> {
await this.webPush.subscribe(
user.id,
dto.endpoint,
dto.keys.p256dh,
dto.keys.auth,
);
return null;
}
@Post('push/unsubscribe')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '웹 푸시 구독 해제' })
@ApiResponse({ status: 200, description: '해제 성공' })
async unsubscribePush(
@CurrentUser() user: PublicUser,
@Body() dto: UnsubscribePushDto,
): Promise<null> {
await this.webPush.unsubscribe(user.id, dto.endpoint);
return null;
}
// 실시간 스트림(SSE) — 표준 응답 래퍼를 건너뛰고 text/event-stream 으로 흘려보낸다.
// 인증은 access_token 쿠키(EventSource withCredentials)로 연결 시점에 1회 검증된다.
@Sse('stream')
@@ -1,15 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Notification } from './entities/notification.entity';
import { PushSubscription } from './entities/push-subscription.entity';
import { NotificationService } from './notification.service';
import { WebPushService } from './web-push.service';
import { NotificationController } from './notification.controller';
// 알림 모듈 — 다른 도메인 모듈(task/repo)이 import 해 NotificationService 로 알림을 적재한다.
// (Notification 은 User 만 참조하므로 순환 의존 없음)
// 알림 모듈 — 다른 도메인 모듈(task/project/schedule)이 import 해 NotificationService 로 알림을 적재한다.
// 인앱(DB) + 실시간(SSE) + 웹 푸시(WebPushService) 3경로로 전달한다.
@Module({
imports: [TypeOrmModule.forFeature([Notification])],
imports: [TypeOrmModule.forFeature([Notification, PushSubscription])],
controllers: [NotificationController],
providers: [NotificationService],
providers: [NotificationService, WebPushService],
exports: [NotificationService],
})
export class NotificationModule {}
@@ -18,6 +18,7 @@ import {
Notification,
type NotificationType,
} from './entities/notification.entity';
import { WebPushService } from './web-push.service';
// 알림 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
export interface NotificationResponse {
@@ -68,6 +69,7 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
constructor(
@InjectRepository(Notification)
private readonly notificationRepo: Repository<Notification>,
private readonly webPush: WebPushService,
) {}
// 기동 시 Redis pub/sub 연결 — REDIS_HOST 있을 때만. 실패해도 인메모리로 폴백.
@@ -156,12 +158,19 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
);
const saved = await this.notificationRepo.save(entities);
// 저장 성공분을 각 수신자에게 실시간 전달(Redis 발행 또는 로컬)
// 푸시 표시 내용은 type+payload 로 1회만 산출(수신자 공통)
const pushContent = this.webPush.notificationContent(
params.type,
params.payload ?? {},
);
// 저장 성공분을 각 수신자에게 실시간 전달(Redis/로컬 SSE) + 웹 푸시(사이트 닫힘 대비)
for (let i = 0; i < saved.length; i++) {
this.dispatch({
userId: recipients[i],
data: this.toResponse(saved[i]),
});
void this.webPush.sendToUser(recipients[i], pushContent);
}
} catch (err) {
this.logger.error(
@@ -0,0 +1,192 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as webpush from 'web-push';
import { PushSubscription } from './entities/push-subscription.entity';
import type { NotificationType } from './entities/notification.entity';
// 푸시 표시 내용 — 서비스워커가 showNotification 에 그대로 사용한다.
export interface PushContent {
title: string;
body: string;
url: string | null;
}
// payload(자유형) 안전 추출
function pstr(p: Record<string, unknown>, k: string): string {
const v = p[k];
return typeof v === 'string' ? v : '';
}
function pnum(p: Record<string, unknown>, k: string): number | null {
const v = p[k];
return typeof v === 'number' ? v : null;
}
// 웹 푸시 발송 + 구독 관리.
// VAPID 키 미설정 시 비활성(발송 skip) — storage 미설정 패턴과 동일한 graceful degrade.
@Injectable()
export class WebPushService implements OnModuleInit {
private readonly logger = new Logger(WebPushService.name);
private enabled = false;
constructor(
@InjectRepository(PushSubscription)
private readonly subRepo: Repository<PushSubscription>,
private readonly config: ConfigService,
) {}
onModuleInit(): void {
const publicKey = this.config.get<string>('VAPID_PUBLIC_KEY');
const privateKey = this.config.get<string>('VAPID_PRIVATE_KEY');
const subject =
this.config.get<string>('VAPID_SUBJECT') || 'mailto:admin@example.com';
if (!publicKey || !privateKey) {
this.logger.log('웹 푸시: 비활성(VAPID 키 미설정) — 푸시 발송 건너뜀');
return;
}
webpush.setVapidDetails(subject, publicKey, privateKey);
this.enabled = true;
this.logger.log('웹 푸시: 활성화됨(VAPID)');
}
// 구독 등록(endpoint 기준 upsert — 같은 브라우저 재구독 시 키 갱신)
async subscribe(
userId: string,
endpoint: string,
p256dh: string,
auth: string,
): Promise<void> {
const existing = await this.subRepo.findOne({ where: { endpoint } });
if (existing) {
existing.user = { id: userId } as PushSubscription['user'];
existing.p256dh = p256dh;
existing.auth = auth;
await this.subRepo.save(existing);
return;
}
await this.subRepo.save(
this.subRepo.create({
user: { id: userId } as PushSubscription['user'],
endpoint,
p256dh,
auth,
}),
);
}
// 구독 해제(본인 구독만)
async unsubscribe(userId: string, endpoint: string): Promise<void> {
await this.subRepo.delete({ endpoint, user: { id: userId } });
}
// type+payload → 푸시 표시 내용(제목=행위자/맥락, 본문=행위, url=이동 경로).
// 프론트 toView 의 푸시 전용 plain 버전(서비스워커가 번들 모듈을 import 못하므로 서버에서 완성).
notificationContent(
type: NotificationType,
payload: Record<string, unknown>,
): PushContent {
const actor = pstr(payload, 'actorName') || '누군가';
const projectName = pstr(payload, 'projectName');
const taskTitle = pstr(payload, 'taskTitle');
const eventTitle = pstr(payload, 'eventTitle');
const projectId = pstr(payload, 'projectId');
const taskSeq = pnum(payload, 'taskSeq');
const taskUrl =
projectId && taskSeq !== null
? `/projects/${projectId}/tasks/${taskSeq}`
: null;
const projectUrl = projectId ? `/projects/${projectId}` : null;
// 기본값 — 대부분 제목=행위자, 본문=행위 문구
let title = actor;
let body = '새 알림이 도착했습니다';
let url: string | null = taskUrl;
switch (type) {
case 'task.assigned':
body = `'${taskTitle}' 업무를 지시했습니다`;
break;
case 'task.unassigned':
body = `'${taskTitle}' 업무 담당에서 회원님을 제외했습니다`;
break;
case 'task.started':
body = `'${taskTitle}' 업무를 시작했습니다`;
break;
case 'task.due_changed':
body = `'${taskTitle}' 업무의 마감기한이 변경되었습니다`;
break;
case 'task.checkpoint_reported':
body = `'${taskTitle}' 업무의 중간 점검을 보고했습니다`;
break;
case 'task.removed':
body = `'${taskTitle}' 업무를 삭제했습니다`;
url = projectUrl; // 업무가 삭제되므로 프로젝트로 이동
break;
case 'task.approval_requested':
body = `'${taskTitle}' 승인을 요청했습니다`;
break;
case 'task.approved':
title = '업무 승인';
body = `'${taskTitle}' 업무가 승인되었습니다`;
break;
case 'task.changes_requested':
body = `'${taskTitle}' 수정을 요청했습니다`;
break;
case 'task.commented':
body = `'${taskTitle}'에 ${payload.isReply === true ? '답글' : '댓글'}을 남겼습니다`;
break;
case 'member.invited':
body = `'${projectName}' 프로젝트에 초대했습니다`;
url = projectUrl;
break;
case 'member.role_changed':
body = `'${projectName}'에서 회원님의 역할을 ${pstr(payload, 'roleType') === 'admin' ? '관리자' : '멤버'}(으)로 변경했습니다`;
url = projectUrl;
break;
case 'member.removed':
body = `'${projectName}' 프로젝트에서 회원님을 제외했습니다`;
url = projectUrl;
break;
case 'schedule.invited':
body = `'${eventTitle}' 일정에 회원님을 참석자로 지정했습니다`;
url = '/schedule';
break;
case 'schedule.updated':
body = `참석 일정 '${eventTitle}'을(를) 수정했습니다`;
url = '/schedule';
break;
case 'schedule.removed':
body = `참석 일정 '${eventTitle}'을(를) 삭제했습니다`;
url = '/schedule';
break;
}
return { title, body, url };
}
// 한 사용자의 모든 구독에 발송 — 만료(404/410) 구독은 정리. best-effort.
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,
);
} 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)}`,
);
}
}
}
}
}