Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d00d71072 | |||
| 26cad39857 |
@@ -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.<APP_ENV> 에 작성(시크릿). 공개키는 프론트에도 주입됨.
|
||||
# ==========================================
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@example.com
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
Generated
+85
@@ -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",
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})(),
|
||||
)
|
||||
})
|
||||
@@ -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<void> {
|
||||
await api.post('/notifications/push/subscribe', body)
|
||||
}
|
||||
|
||||
// 웹 푸시 구독 해제
|
||||
async function unsubscribePush(endpoint: string): Promise<void> {
|
||||
await api.post('/notifications/push/unsubscribe', { endpoint })
|
||||
}
|
||||
|
||||
return { list, markRead, markAllRead, subscribePush, unsubscribePush }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ref } from 'vue'
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
|
||||
// VAPID 공개키(base64url) → Uint8Array (applicationServerKey 형식)
|
||||
// ArrayBuffer 백킹으로 명시(BufferSource 타입 충족)
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
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<NotificationPermission>(
|
||||
supported ? Notification.permission : 'denied',
|
||||
)
|
||||
|
||||
// 서비스워커 등록 확보 — 명시적 register 로 .ready 무한대기를 피한다.
|
||||
// 활성화 전이면 최대 5초만 기다린다(그래도 없으면 그대로 진행 — subscribe 가 처리).
|
||||
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||
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<PushResult> {
|
||||
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<void> {
|
||||
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 }
|
||||
}
|
||||
@@ -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() {
|
||||
>
|
||||
<div class="noti-head">
|
||||
<span class="noti-title">알림</span>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="noti-readall"
|
||||
@click="notiStore.markAllRead()"
|
||||
>
|
||||
모두 읽음
|
||||
</button>
|
||||
<span class="noti-head-actions">
|
||||
<button
|
||||
v-if="canEnablePush"
|
||||
type="button"
|
||||
class="noti-pushbtn"
|
||||
title="이 브라우저에서 PC 알림 받기"
|
||||
@click="onEnablePush"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
PC 알림 켜기
|
||||
</button>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="noti-readall"
|
||||
@click="notiStore.markAllRead()"
|
||||
>
|
||||
모두 읽음
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="noti-list">
|
||||
<div
|
||||
@@ -795,6 +863,11 @@ async function onLogout() {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.noti-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.noti-readall {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -809,6 +882,29 @@ async function onLogout() {
|
||||
.noti-readall:hover {
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
/* PC 알림 켜기 — 권한 허용 전에만 노출 */
|
||||
.noti-pushbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.125rem 0.3125rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.noti-pushbtn svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.noti-pushbtn:hover {
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
.noti-list {
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -13,3 +13,12 @@ app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 웹 푸시용 서비스워커 등록 — 푸시 수신/클릭 처리(Level2). 실패해도 앱에 영향 없음.
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
void navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// 등록 실패(미지원/비보안 컨텍스트) 무시 — 인앱/SSE 알림은 정상 동작
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,9 +83,6 @@ const modalProps = computed(() => {
|
||||
function openFolder(f: DriveFolder): void {
|
||||
router.push(`/drive/${f.id}`)
|
||||
}
|
||||
function goRoot(): void {
|
||||
router.push('/drive')
|
||||
}
|
||||
|
||||
// --- 모달 열기 ---
|
||||
function openCreateFolder(): void {
|
||||
@@ -200,24 +197,27 @@ function openActivity(): void {
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<!-- 브레드크럼 -->
|
||||
<nav class="crumb dr-crumb">
|
||||
<!-- 브레드크럼 — 전역 .crumb/.lnk/.sep 시스템(프로젝트 화면과 동일) -->
|
||||
<nav class="crumb">
|
||||
<template v-if="breadcrumb.length === 0">
|
||||
<b>드라이브</b>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="goRoot"
|
||||
>드라이브</a>
|
||||
<RouterLink
|
||||
to="/drive"
|
||||
class="lnk"
|
||||
>
|
||||
드라이브
|
||||
</RouterLink>
|
||||
<template
|
||||
v-for="(c, i) in breadcrumb"
|
||||
:key="c.id"
|
||||
>
|
||||
<span class="dr-sep">›</span>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
v-if="i < breadcrumb.length - 1"
|
||||
:to="`/drive/${c.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ c.name }}
|
||||
</RouterLink>
|
||||
@@ -578,21 +578,6 @@ function openActivity(): void {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 브레드크럼 링크 */
|
||||
.dr-crumb a,
|
||||
.dr-crumb :deep(a) {
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dr-crumb a:hover,
|
||||
.dr-crumb :deep(a:hover) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.dr-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 — 다른 목록 페이지(ProjectListPage)와 동일 스페이싱 */
|
||||
.pagehead {
|
||||
display: flex;
|
||||
|
||||
@@ -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<string, unknown>, k: string): string {
|
||||
const v = p[k]
|
||||
@@ -113,7 +148,15 @@ function toView(n: ApiNotification): NotificationView {
|
||||
html = `<b>${actor}</b>님이 참석 일정 <b>${eventTitle}</b>을(를) 삭제했습니다`
|
||||
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 {
|
||||
// 파싱 실패(하트비트 등 비정상 페이로드) 무시
|
||||
}
|
||||
|
||||
@@ -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 // 클릭 시 이동 경로
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user