From 8d00d7107211246e2887380cf43324281a09ed63 Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 1 Jul 2026 00:49:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20PC=20=EB=8D=B0=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=ED=86=B1=20=EC=95=8C=EB=A6=BC(=EC=9B=B9=20=ED=91=B8=EC=8B=9C)?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=E2=80=94=20=EC=9D=B8=EC=95=B1/SSE?= =?UTF-8?q?=EC=97=90=20OS=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오톡처럼 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) --- .env.development.example | 8 + .env.production.example | 5 + backend/.env.development.example | 6 + backend/.env.production.example | 3 + backend/package-lock.json | 85 ++++++++ backend/package.json | 2 + .../1783300000000-AddPushSubscriptions.ts | 48 +++++ .../notification/dto/subscribe-push.dto.ts | 37 ++++ .../entities/push-subscription.entity.ts | 39 ++++ .../notification/notification.controller.ts | 37 +++- .../notification/notification.module.ts | 10 +- .../notification/notification.service.ts | 11 +- .../modules/notification/web-push.service.ts | 192 ++++++++++++++++++ docker-compose.dev.yml | 2 + docker-compose.yml | 5 + frontend/env.d.ts | 2 + frontend/public/sw.js | 71 +++++++ frontend/src/composables/useNotification.ts | 15 +- frontend/src/composables/usePush.ts | 114 +++++++++++ frontend/src/layouts/AppShell.vue | 116 ++++++++++- frontend/src/main.ts | 9 + frontend/src/stores/notification.store.ts | 47 ++++- frontend/src/types/notification.ts | 1 + 23 files changed, 847 insertions(+), 18 deletions(-) create mode 100644 backend/src/migrations/1783300000000-AddPushSubscriptions.ts create mode 100644 backend/src/modules/notification/dto/subscribe-push.dto.ts create mode 100644 backend/src/modules/notification/entities/push-subscription.entity.ts create mode 100644 backend/src/modules/notification/web-push.service.ts create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/composables/usePush.ts diff --git a/.env.development.example b/.env.development.example index 3347111..1c737eb 100644 --- a/.env.development.example +++ b/.env.development.example @@ -80,3 +80,11 @@ LIVEKIT_URL=wss://your-project.livekit.cloud OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com OBJECT_STORAGE_REGION=kr-standard OBJECT_STORAGE_BUCKET=comrelay-dev + +# ========================================== +# 웹 푸시 알림 (Web Push / VAPID) — 비밀 아닌 값만 루트에서 주입 +# 키페어 생성: node -e "console.log(require('web-push').generateVAPIDKeys())" +# PRIVATE_KEY 는 backend/.env. 에 작성(시크릿). 공개키는 프론트에도 주입됨. +# ========================================== +VAPID_PUBLIC_KEY= +VAPID_SUBJECT=mailto:admin@example.com diff --git a/.env.production.example b/.env.production.example index ad32dcf..0ce3851 100644 --- a/.env.production.example +++ b/.env.production.example @@ -83,3 +83,8 @@ LIVEKIT_URL=http://livekit:7880 OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com OBJECT_STORAGE_REGION=kr-standard OBJECT_STORAGE_BUCKET=comrelay-prod + +# 웹 푸시 알림 (Web Push / VAPID) — 비밀 아닌 값만 루트에서 주입 +# 키페어 생성: node -e "console.log(require('web-push').generateVAPIDKeys())" +VAPID_PUBLIC_KEY= +VAPID_SUBJECT=mailto:admin@example.com diff --git a/backend/.env.development.example b/backend/.env.development.example index 65f6260..96a5d44 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -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= diff --git a/backend/.env.production.example b/backend/.env.production.example index 30164e2..6790e55 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -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= diff --git a/backend/package-lock.json b/backend/package-lock.json index 0237458..6986018 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 08fe00f..ea3e5e8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/migrations/1783300000000-AddPushSubscriptions.ts b/backend/src/migrations/1783300000000-AddPushSubscriptions.ts new file mode 100644 index 0000000..a894e60 --- /dev/null +++ b/backend/src/migrations/1783300000000-AddPushSubscriptions.ts @@ -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 { + 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 { + await queryRunner.query( + `ALTER TABLE "push_subscriptions" DROP CONSTRAINT IF EXISTS "FK_push_subscriptions_user"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS "push_subscriptions"`); + } +} diff --git a/backend/src/modules/notification/dto/subscribe-push.dto.ts b/backend/src/modules/notification/dto/subscribe-push.dto.ts new file mode 100644 index 0000000..68d0ae2 --- /dev/null +++ b/backend/src/modules/notification/dto/subscribe-push.dto.ts @@ -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; +} diff --git a/backend/src/modules/notification/entities/push-subscription.entity.ts b/backend/src/modules/notification/entities/push-subscription.entity.ts new file mode 100644 index 0000000..fc1708a --- /dev/null +++ b/backend/src/modules/notification/entities/push-subscription.entity.ts @@ -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; +} diff --git a/backend/src/modules/notification/notification.controller.ts b/backend/src/modules/notification/notification.controller.ts index e5c55b1..f9ebe42 100644 --- a/backend/src/modules/notification/notification.controller.ts +++ b/backend/src/modules/notification/notification.controller.ts @@ -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 { + 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 { + await this.webPush.unsubscribe(user.id, dto.endpoint); + return null; + } + // 실시간 스트림(SSE) — 표준 응답 래퍼를 건너뛰고 text/event-stream 으로 흘려보낸다. // 인증은 access_token 쿠키(EventSource withCredentials)로 연결 시점에 1회 검증된다. @Sse('stream') diff --git a/backend/src/modules/notification/notification.module.ts b/backend/src/modules/notification/notification.module.ts index 348226f..d8b1f02 100644 --- a/backend/src/modules/notification/notification.module.ts +++ b/backend/src/modules/notification/notification.module.ts @@ -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 {} diff --git a/backend/src/modules/notification/notification.service.ts b/backend/src/modules/notification/notification.service.ts index 206b44f..c2958a0 100644 --- a/backend/src/modules/notification/notification.service.ts +++ b/backend/src/modules/notification/notification.service.ts @@ -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, + 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( diff --git a/backend/src/modules/notification/web-push.service.ts b/backend/src/modules/notification/web-push.service.ts new file mode 100644 index 0000000..6a9fa50 --- /dev/null +++ b/backend/src/modules/notification/web-push.service.ts @@ -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, k: string): string { + const v = p[k]; + return typeof v === 'string' ? v : ''; +} +function pnum(p: Record, 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, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + const publicKey = this.config.get('VAPID_PUBLIC_KEY'); + const privateKey = this.config.get('VAPID_PRIVATE_KEY'); + const subject = + this.config.get('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 { + 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 { + await this.subRepo.delete({ endpoint, user: { id: userId } }); + } + + // type+payload → 푸시 표시 내용(제목=행위자/맥락, 본문=행위, url=이동 경로). + // 프론트 toView 의 푸시 전용 plain 버전(서비스워커가 번들 모듈을 import 못하므로 서버에서 완성). + notificationContent( + type: NotificationType, + payload: Record, + ): 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 { + 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)}`, + ); + } + } + } + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0974f15..538f81a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,6 +13,8 @@ services: # 화상회의 — 브라우저가 접속할 LiveKit URL(개발은 LiveKit Cloud 사용). # 런타임 env 로 주입해 이미지 재빌드 없이 .env 변경 + 컨테이너 재생성만으로 반영된다. - VITE_LIVEKIT_URL=${LIVEKIT_WS_URL} + # 웹 푸시(VAPID) 공개키 — dev 는 Vite dev 서버 런타임 env 로 주입 + - VITE_VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY} volumes: - ./${FRONTEND_DIR}:/app - /app/node_modules diff --git a/docker-compose.yml b/docker-compose.yml index 7b76f3a..bfd35ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS} # 브라우저가 접속할 LiveKit WebSocket URL - VITE_LIVEKIT_URL=${LIVEKIT_WS_URL} + # 웹 푸시(VAPID) 공개키 — 브라우저 푸시 구독에 사용 + - VITE_VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY} expose: - "80" environment: @@ -72,6 +74,9 @@ services: - OBJECT_STORAGE_ENDPOINT=${OBJECT_STORAGE_ENDPOINT} - OBJECT_STORAGE_REGION=${OBJECT_STORAGE_REGION} - OBJECT_STORAGE_BUCKET=${OBJECT_STORAGE_BUCKET} + # 웹 푸시(VAPID) — 비밀 아닌 값만 루트에서 주입(PRIVATE_KEY 는 backend env_file) + - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY} + - VAPID_SUBJECT=${VAPID_SUBJECT} restart: unless-stopped networks: - app-network diff --git a/frontend/env.d.ts b/frontend/env.d.ts index f01db51..48f9b31 100644 --- a/frontend/env.d.ts +++ b/frontend/env.d.ts @@ -7,6 +7,8 @@ interface ImportMetaEnv { readonly VITE_AI_TIMEOUT_MS: string /** 브라우저가 접속할 LiveKit WebSocket URL (예: ws://localhost:7880) */ readonly VITE_LIVEKIT_URL: string + /** 웹 푸시(VAPID) 공개키 — 푸시 구독에 사용. 미설정 시 푸시 비활성 */ + readonly VITE_VAPID_PUBLIC_KEY: string } interface ImportMeta { diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..c670c8a --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,71 @@ +/* eslint-disable */ +// Relay 서비스워커 — 웹 푸시(Level2): 사이트/탭을 닫아도 OS 토스트를 띄운다. +// 푸시 페이로드는 백엔드 WebPushService 가 { title, body, url } 로 보낸다. + +self.addEventListener('install', () => { + // 새 워커를 즉시 활성화 + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('push', (event) => { + if (!event.data) return + let payload = {} + try { + payload = event.data.json() + } catch (e) { + payload = { title: 'Relay', body: '새 알림이 도착했습니다' } + } + const title = payload.title || 'Relay' + const body = payload.body || '' + const url = payload.url || null + + event.waitUntil( + (async () => { + // 열린 클라이언트(탭)가 있으면 페이지의 SSE 가 이미 토스트를 처리 → 중복 표시 생략 + const clients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }) + if (clients.length > 0) return + await self.registration.showNotification(title, { + body, + icon: '/relay-icon-256.png', + badge: '/relay-icon-256.png', + data: { url }, + }) + })(), + ) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + const url = (event.notification.data && event.notification.data.url) || '/' + event.waitUntil( + (async () => { + const clients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }) + // 이미 열린 창이 있으면 포커스 후 해당 경로로 이동 + for (const client of clients) { + if ('focus' in client) { + await client.focus() + if ('navigate' in client && url) { + try { + await client.navigate(url) + } catch (e) { + /* 이동 실패 무시 */ + } + } + return + } + } + // 열린 창이 없으면 새 창 + if (self.clients.openWindow) await self.clients.openWindow(url) + })(), + ) +}) diff --git a/frontend/src/composables/useNotification.ts b/frontend/src/composables/useNotification.ts index 484e621..19113ee 100644 --- a/frontend/src/composables/useNotification.ts +++ b/frontend/src/composables/useNotification.ts @@ -31,5 +31,18 @@ export function useNotification() { )) as unknown as UnreadCountResult } - return { list, markRead, markAllRead } + // 웹 푸시 구독 등록(브라우저 PushSubscription) + async function subscribePush(body: { + endpoint: string + keys: { p256dh: string; auth: string } + }): Promise { + await api.post('/notifications/push/subscribe', body) + } + + // 웹 푸시 구독 해제 + async function unsubscribePush(endpoint: string): Promise { + await api.post('/notifications/push/unsubscribe', { endpoint }) + } + + return { list, markRead, markAllRead, subscribePush, unsubscribePush } } diff --git a/frontend/src/composables/usePush.ts b/frontend/src/composables/usePush.ts new file mode 100644 index 0000000..7ebb985 --- /dev/null +++ b/frontend/src/composables/usePush.ts @@ -0,0 +1,114 @@ +import { ref } from 'vue' +import { useNotification } from '@/composables/useNotification' + +// VAPID 공개키(base64url) → Uint8Array (applicationServerKey 형식) +// ArrayBuffer 백킹으로 명시(BufferSource 타입 충족) +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4) + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') + const raw = atob(base64) + const arr = new Uint8Array(new ArrayBuffer(raw.length)) + for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i) + return arr +} + +// 푸시 켜기 결과 — UI 가 사유별 안내를 띄울 수 있도록 상태로 반환 +export type PushResult = + | 'subscribed' // 구독 성공 + | 'denied' // 브라우저가 알림을 차단함 + | 'dismissed' // 사용자가 권한 팝업을 닫음(미결정 유지) + | 'unsupported' // 미지원/비보안 컨텍스트 + | 'no-key' // 서버 VAPID 공개키 미설정 + | 'error' // 그 외 실패(SW/구독) + +// 웹 푸시(Level2) 구독 관리 — 권한 요청 + PushManager 구독 + 백엔드 등록/해제. +export function usePush() { + const api = useNotification() + const supported = + typeof navigator !== 'undefined' && + 'serviceWorker' in navigator && + typeof window !== 'undefined' && + 'PushManager' in window && + typeof Notification !== 'undefined' + + // 현재 권한 상태(UI 토글용) + const permission = ref( + supported ? Notification.permission : 'denied', + ) + + // 서비스워커 등록 확보 — 명시적 register 로 .ready 무한대기를 피한다. + // 활성화 전이면 최대 5초만 기다린다(그래도 없으면 그대로 진행 — subscribe 가 처리). + async function getRegistration(): Promise { + if (!supported) return null + try { + const reg = await navigator.serviceWorker.register('/sw.js') + if (!reg.active) { + await Promise.race([ + navigator.serviceWorker.ready, + new Promise((resolve) => setTimeout(resolve, 5000)), + ]) + } + return reg + } catch { + return null + } + } + + // 권한 요청(필요 시) + 푸시 구독 + 백엔드 등록. 결과 상태를 반환(UI 안내용). + // silent=true 면 이미 granted 인 경우에만 진행(권한 팝업 띄우지 않음 — 자동 재구독용). + async function enablePush(silent = false): Promise { + if (!supported) return 'unsupported' + const key = import.meta.env.VITE_VAPID_PUBLIC_KEY + if (!key) return 'no-key' + + let perm = Notification.permission + if (perm !== 'granted') { + if (silent) return 'denied' + perm = await Notification.requestPermission() + } + permission.value = perm + if (perm === 'denied') return 'denied' + if (perm !== 'granted') return 'dismissed' + + const reg = await getRegistration() + if (!reg) return 'error' + try { + let sub = await reg.pushManager.getSubscription() + if (!sub) { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(key), + }) + } + const json = sub.toJSON() + await api.subscribePush({ + endpoint: sub.endpoint, + keys: { + p256dh: json.keys?.p256dh ?? '', + auth: json.keys?.auth ?? '', + }, + }) + return 'subscribed' + } catch { + return 'error' + } + } + + // 구독 해제(로그아웃 등) — 백엔드 삭제 + 브라우저 구독 해지 + async function disablePush(): Promise { + if (!supported) return + const reg = await getRegistration() + if (!reg) return + try { + const sub = await reg.pushManager.getSubscription() + if (sub) { + await api.unsubscribePush(sub.endpoint) + await sub.unsubscribe() + } + } catch { + // 해제 실패 무시 + } + } + + return { supported, permission, enablePush, disablePush } +} diff --git a/frontend/src/layouts/AppShell.vue b/frontend/src/layouts/AppShell.vue index 6734415..3e04204 100644 --- a/frontend/src/layouts/AppShell.vue +++ b/frontend/src/layouts/AppShell.vue @@ -6,6 +6,8 @@ import { storeToRefs } from 'pinia' import { useRoute, useRouter, RouterLink } from 'vue-router' import { useAuthStore } from '@/stores/auth.store' import { useNotificationStore } from '@/stores/notification.store' +import { usePush } from '@/composables/usePush' +import { useDialog } from '@/composables/useDialog' import UserAvatar from '@/components/UserAvatar.vue' import RelayMark from '@/components/RelayMark.vue' import AgentChatPanel from '@/components/AgentChatPanel.vue' @@ -52,6 +54,45 @@ const { const notiOpen = ref(false) +// --- PC 데스크톱 알림(웹 푸시) --- +const dialog = useDialog() +const { supported: pushSupported, permission: pushPermission, enablePush, disablePush } = + usePush() +// 종 드롭다운에서 "PC 알림 켜기" 노출 조건(지원 + 아직 허용 전) +const canEnablePush = computed( + () => pushSupported && pushPermission.value !== 'granted', +) +async function onEnablePush() { + const result = await enablePush() + if (result === 'subscribed') { + notiOpen.value = false + void dialog.alert('이 브라우저에서 PC 알림을 받습니다.', { + title: 'PC 알림 켜짐', + }) + } else if (result === 'denied') { + void dialog.alert( + '브라우저에서 이 사이트의 알림이 차단되어 있습니다. 주소창 왼쪽 자물쇠(ⓘ) 아이콘 → 알림을 “허용”으로 바꾼 뒤 다시 시도해 주세요.', + { title: 'PC 알림 차단됨', variant: 'danger' }, + ) + } else if (result === 'unsupported') { + void dialog.alert( + '이 브라우저(또는 보안 컨텍스트)에서는 PC 알림을 지원하지 않습니다. localhost 또는 https 환경의 최신 Chrome/Edge 에서 이용해 주세요.', + { title: 'PC 알림 미지원', variant: 'danger' }, + ) + } else if (result === 'no-key') { + void dialog.alert( + '서버에 푸시 키가 설정되어 있지 않습니다. 관리자에게 문의해 주세요.', + { title: 'PC 알림', variant: 'danger' }, + ) + } else if (result === 'error') { + void dialog.alert( + 'PC 알림 설정에 실패했습니다. 잠시 후 다시 시도해 주세요.', + { title: 'PC 알림', variant: 'danger' }, + ) + } + // 'dismissed'(권한 팝업 닫음)는 조용히 무시 +} + // 화면 전환 시 모바일 드로어 닫기 watch( () => route.path, @@ -70,11 +111,16 @@ async function onNotiClick(v: NotificationView) { // AppShell 은 인증 이후 화면에서만 마운트되므로 여기서 스트림 연결(멱등) // (store 싱글톤이 연결을 보유 → 화면 전환에도 끊기지 않음. 해제는 로그아웃 시에만) onMounted(() => { - if (authStore.isLoggedIn) void notiStore.connect() + if (authStore.isLoggedIn) { + void notiStore.connect() + // 이미 권한을 허용한 사용자면 조용히 재구독(권한 팝업 없이) — 구독 최신화 + void enablePush(true) + } }) -// 로그아웃 → 알림 스트림 해제 후 로그인 화면으로 +// 로그아웃 → 푸시 구독·알림 스트림 해제 후 로그인 화면으로 async function onLogout() { + await disablePush() notiStore.disconnect() await authStore.logout() await router.push('/login') @@ -392,14 +438,36 @@ async function onLogout() { >
알림 - + + + +
{ + void navigator.serviceWorker.register('/sw.js').catch(() => { + // 등록 실패(미지원/비보안 컨텍스트) 무시 — 인앱/SSE 알림은 정상 동작 + }) + }) +} diff --git a/frontend/src/stores/notification.store.ts b/frontend/src/stores/notification.store.ts index 98fa0d1..676d425 100644 --- a/frontend/src/stores/notification.store.ts +++ b/frontend/src/stores/notification.store.ts @@ -1,10 +1,45 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' +import router from '@/router' import { useNotification } from '@/composables/useNotification' import { DEFAULT_PAGE_SIZE } from '@/types/pagination' import { escapeHtml, relativeTimeKo, taskDueInfo } from '@/shared/utils/format' import type { ApiNotification, NotificationView } from '@/types/notification' +// Level1 — 페이지가 열려 있을 때(특히 백그라운드 탭/최소화) OS 토스트. +// 포커스 상태면 인앱 종 알림만(active 사용자 방해 금지). 닫혀 있으면 서비스워커 푸시가 담당. +function showLocalNotification(view: NotificationView): void { + if (typeof Notification === 'undefined') return + if (Notification.permission !== 'granted') return + if (typeof document !== 'undefined' && document.visibilityState === 'visible') + return + try { + const notif = new Notification('Relay', { + body: view.text, + tag: view.id, // 동일 알림 중복 토스트 방지 + icon: '/relay-icon-256.png', + }) + notif.onclick = () => { + window.focus() + if (view.to) void router.push(view.to) + notif.close() + } + } catch { + // 알림 생성 실패(권한/환경) 무시 + } +} + +// 조립된 html → 태그/엔티티 제거 평문(OS 토스트 본문용) +function stripHtml(html: string): string { + return html + .replace(/<[^>]+>/g, '') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') +} + // payload(자유형)에서 문자열/숫자 값 안전 추출 function pstr(p: Record, k: string): string { const v = p[k] @@ -113,7 +148,15 @@ function toView(n: ApiNotification): NotificationView { html = `${actor}님이 참석 일정 ${eventTitle}을(를) 삭제했습니다` break } - return { id: n.id, read: n.read, tone, html, time: relativeTimeKo(n.createdAt), to } + return { + id: n.id, + read: n.read, + tone, + html, + text: stripHtml(html), + time: relativeTimeKo(n.createdAt), + to, + } } // SSE 재연결 지연(ms) — 토큰 만료/네트워크 끊김 시 정리 후 재시도 @@ -151,6 +194,8 @@ export const useNotificationStore = defineStore('notification', () => { items.value.unshift(n) total.value += 1 if (!n.read) unreadCount.value += 1 + // OS 토스트(Level1) — 백그라운드/최소화 상태일 때만 + showLocalNotification(toView(n)) } catch { // 파싱 실패(하트비트 등 비정상 페이로드) 무시 } diff --git a/frontend/src/types/notification.ts b/frontend/src/types/notification.ts index f2fd7c4..2ca3ba6 100644 --- a/frontend/src/types/notification.ts +++ b/frontend/src/types/notification.ts @@ -46,6 +46,7 @@ export interface NotificationView { read: boolean tone: string // 좌측 점 색상(blue/amber/green/red/accent/'') html: string // 조립된 문구(사용자 입력은 escapeHtml 적용) + text: string // 태그 없는 평문(OS 토스트 본문용) time: string // 상대시간 to: string | null // 클릭 시 이동 경로 }