From d87726194bbaaa377fd07abfdec48c44b890124b Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 19 Jun 2026 13:12:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC(=EC=9D=B8=EC=95=B1+SSE,=20Redis=20pub/sub)=20+=20Redi?= =?UTF-8?q?s=20=EC=BA=90=EC=8B=B1=20(8=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알림(8-2): - Notification 모듈 + SSE 스트림(@Sse, @SkipTransform 으로 표준 래퍼 우회). - 12종 알림 적재(업무 지시/해제/승인요청/승인/수정요청/시작/마감변경/삭제/댓글·답글, 멤버 초대/역할변경/제거). 행위자 제외 + 중복 dedup. AppShell 종(미읽음 뱃지·드롭다운). - 실시간 fan-out: REDIS_HOST 시 ioredis pub/sub(다중 인스턴스), 미설정 시 인메모리 폴백. Redis 캐싱(8-3): - CacheModule → @keyv/redis(미설정 시 인메모리 폴백). 저장소 목록을 버전 네임스페이스로 캐시, 생성/수정/삭제·동기화·멤버 초대/제거 시 무효화(공유 버전 키라 다중 인스턴스 정합). - enableShutdownHooks 로 종료 시 Redis 연결 graceful close. 검증: 백엔드 build/lint 0·20 tests, 프론트 build/lint/type-check 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/package-lock.json | 132 ++++++++ backend/package.json | 2 + backend/src/app.module.ts | 36 +- .../decorators/skip-transform.decorator.ts | 6 + .../interceptors/transform.interceptor.ts | 17 +- backend/src/main.ts | 4 + .../entities/notification.entity.ts | 54 +++ .../notification/notification.controller.ts | 73 ++++ .../notification/notification.module.ts | 15 + .../notification/notification.service.ts | 249 ++++++++++++++ .../src/modules/repo/member.service.spec.ts | 24 +- backend/src/modules/repo/member.service.ts | 56 ++++ .../src/modules/repo/repo-cache.service.ts | 62 ++++ backend/src/modules/repo/repo-sync.service.ts | 7 + backend/src/modules/repo/repo.module.ts | 5 +- backend/src/modules/repo/repo.service.ts | 41 ++- backend/src/modules/task/task.module.ts | 2 + backend/src/modules/task/task.service.spec.ts | 10 +- backend/src/modules/task/task.service.ts | 142 +++++++- frontend/src/composables/useNotification.ts | 35 ++ frontend/src/layouts/AppShell.vue | 315 ++++++++++++++++-- frontend/src/stores/notification.store.ts | 247 ++++++++++++++ frontend/src/types/notification.ts | 47 +++ 23 files changed, 1539 insertions(+), 42 deletions(-) create mode 100644 backend/src/common/decorators/skip-transform.decorator.ts create mode 100644 backend/src/modules/notification/entities/notification.entity.ts create mode 100644 backend/src/modules/notification/notification.controller.ts create mode 100644 backend/src/modules/notification/notification.module.ts create mode 100644 backend/src/modules/notification/notification.service.ts create mode 100644 backend/src/modules/repo/repo-cache.service.ts create mode 100644 frontend/src/composables/useNotification.ts create mode 100644 frontend/src/stores/notification.store.ts create mode 100644 frontend/src/types/notification.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index cfeaf13..6eae8d8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@keyv/redis": "^5.1.6", "@nestjs/cache-manager": "^3.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", @@ -28,6 +29,7 @@ "cookie-parser": "^1.4.7", "express-rate-limit": "^8.3.2", "helmet": "^8.1.0", + "ioredis": "^5.11.1", "nest-winston": "^1.10.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -1475,6 +1477,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2171,6 +2179,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/redis": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-5.1.6.tgz", + "integrity": "sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ==", + "license": "MIT", + "dependencies": { + "@redis/client": "^5.10.0", + "cluster-key-slot": "^1.1.2", + "hookified": "^1.13.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, "node_modules/@keyv/serialize": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", @@ -3312,6 +3337,30 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -6310,6 +6359,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6757,6 +6815,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -8254,6 +8321,12 @@ "node": ">=18.0.0" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -8433,6 +8506,38 @@ "kind-of": "^6.0.2" } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -11247,6 +11352,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -11829,6 +11955,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 1a9278f..0469b0e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@keyv/redis": "^5.1.6", "@nestjs/cache-manager": "^3.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", @@ -39,6 +40,7 @@ "cookie-parser": "^1.4.7", "express-rate-limit": "^8.3.2", "helmet": "^8.1.0", + "ioredis": "^5.11.1", "nest-winston": "^1.10.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1800407..2b0099f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,9 +1,11 @@ -import { Module } from '@nestjs/common'; +import { Logger, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { CacheModule } from '@nestjs/cache-manager'; +import { createKeyv } from '@keyv/redis'; +import type Keyv from 'keyv'; import { APP_GUARD } from '@nestjs/core'; import { AppController } from './app.controller'; @@ -14,6 +16,7 @@ import { AuthModule } from './modules/auth/auth.module'; import { RepoModule } from './modules/repo/repo.module'; import { TaskModule } from './modules/task/task.module'; import { ActivityModule } from './modules/activity/activity.module'; +import { NotificationModule } from './modules/notification/notification.module'; @Module({ imports: [ @@ -22,10 +25,34 @@ import { ActivityModule } from './modules/activity/activity.module'; isGlobal: true, envFilePath: `.env.${process.env.NODE_ENV || 'development'}`, }), - // 전역 캐시 매니저 — Redis 도입 시 store 옵션을 redisStore 로 변경 - CacheModule.register({ + // 전역 캐시 매니저 — REDIS_* 설정 시 Redis(keyv) 스토어, 없으면 인메모리 폴백. + // docker-compose 가 REDIS_HOST/PORT/PASSWORD 를 주입한다(단독 실행/테스트는 미설정 → 인메모리). + CacheModule.registerAsync({ isGlobal: true, - ttl: 60_000, // 60초 기본 TTL + useFactory: (): { ttl: number; stores?: Keyv[] } => { + const logger = new Logger('CacheModule'); + const options: { ttl: number; stores?: Keyv[] } = { ttl: 60_000 }; + const host = process.env.REDIS_HOST; + if (!host) { + // Redis 미설정 — 인메모리 폴백(LRU). 로컬/테스트에서 Redis 없이도 동작. + // 주의: 다중 인스턴스를 인메모리 모드로 띄우면 인스턴스별 캐시·무효화가 분리됨(단일 인스턴스 전용) + logger.log('캐시: 인메모리 모드(단일 인스턴스) — REDIS_HOST 미설정'); + return options; + } + const port = process.env.REDIS_PORT || '6379'; + const password = process.env.REDIS_PASSWORD; + const auth = password ? `:${encodeURIComponent(password)}@` : ''; + const keyv = createKeyv(`redis://${auth}${host}:${port}`); + // Redis 연결 오류가 unhandled 로 프로세스를 죽이지 않도록 흡수(캐시 장애는 DB 조회로 폴백) + keyv.on('error', (err: unknown) => { + logger.warn( + `Redis 캐시 오류(무시, DB 폴백): ${err instanceof Error ? err.message : String(err)}`, + ); + }); + options.stores = [keyv]; + logger.log('캐시: Redis 모드(다중 인스턴스 무효화 정합)'); + return options; + }, }), // 전역 Rate Limiter — main.ts 의 express-rate-limit 와 별개로 라우트 단위 제어 가능 ThrottlerModule.forRoot([ @@ -67,6 +94,7 @@ import { ActivityModule } from './modules/activity/activity.module'; RepoModule, TaskModule, ActivityModule, + NotificationModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/decorators/skip-transform.decorator.ts b/backend/src/common/decorators/skip-transform.decorator.ts new file mode 100644 index 0000000..ae8169c --- /dev/null +++ b/backend/src/common/decorators/skip-transform.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +// 응답 표준 래핑({ success, data }) 제외 마커. +// SSE(text/event-stream)처럼 스트림을 그대로 흘려보내야 하는 핸들러에 사용한다. +export const SKIP_TRANSFORM = 'skipTransform'; +export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM, true); diff --git a/backend/src/common/interceptors/transform.interceptor.ts b/backend/src/common/interceptors/transform.interceptor.ts index 5a72a63..d20552b 100644 --- a/backend/src/common/interceptors/transform.interceptor.ts +++ b/backend/src/common/interceptors/transform.interceptor.ts @@ -4,8 +4,10 @@ import { ExecutionContext, CallHandler, } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { SKIP_TRANSFORM } from '../decorators/skip-transform.decorator'; export interface StandardResponse { success: boolean; @@ -17,10 +19,23 @@ export class TransformInterceptor implements NestInterceptor< T, StandardResponse > { + // main.ts 에서 new 로 생성하므로 DI 대신 자체 Reflector 인스턴스 사용 + private readonly reflector = new Reflector(); + intercept( - _context: ExecutionContext, + context: ExecutionContext, next: CallHandler, ): Observable> { + // @SkipTransform() 핸들러(SSE 스트림 등)는 래핑하지 않고 그대로 흘려보낸다 + const skip = this.reflector.get( + SKIP_TRANSFORM, + context.getHandler(), + ); + if (skip) { + // SSE MessageEvent 스트림 등은 래핑 대상이 아니므로 그대로 통과(타입만 인터페이스에 맞춤) + return next.handle() as unknown as Observable>; + } + // 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑 return next.handle().pipe( map((data: T) => ({ diff --git a/backend/src/main.ts b/backend/src/main.ts index 6bfa166..7d98edc 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -104,6 +104,10 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api-docs', app, document); + // 종료 시그널(SIGTERM/SIGINT) 에 lifecycle 훅 실행 — onModuleDestroy 로 Redis pub/sub 등 + // 외부 연결을 정상 종료(graceful shutdown). 미설정 시 정리 훅이 호출되지 않는다. + app.enableShutdownHooks(); + const port = Number(process.env.PORT ?? 3000); await app.listen(port, '0.0.0.0'); Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap'); diff --git a/backend/src/modules/notification/entities/notification.entity.ts b/backend/src/modules/notification/entities/notification.entity.ts new file mode 100644 index 0000000..24b5ba6 --- /dev/null +++ b/backend/src/modules/notification/entities/notification.entity.ts @@ -0,0 +1,54 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; + +// 알림 타입 — 수신자 관점의 이벤트(활동 피드와 별개: 활동은 저장소 전체 로그, 알림은 개인 수신함) +export type NotificationType = + | 'task.assigned' // 나에게 업무가 지시됨 + | 'task.unassigned' // 업무 담당에서 제외됨 + | 'task.approval_requested' // 내가 지시한 업무의 승인 요청이 옴 + | 'task.approved' // 내가 담당한 업무가 승인(완료)됨 + | 'task.changes_requested' // 내가 담당한 업무에 수정이 요청됨 + | 'task.started' // 내가 지시한 업무를 담당자가 시작/재개함 + | 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨 + | 'task.removed' // 내 관련 업무가 삭제됨 + | 'task.commented' // 내 관련 업무에 댓글/답글이 달림 + | 'member.invited' // 저장소에 멤버로 초대됨 + | 'member.role_changed' // 저장소 내 내 역할이 변경됨 + | 'member.removed'; // 저장소에서 제외됨 + +// 알림 — 사용자별 수신함. 표시 문구/아이콘은 프론트가 type+payload 로 조립한다. +@Entity('notifications') +// 수신자별 최신순 조회 + 미읽음 집계에 사용되는 복합 인덱스 +@Index(['recipient', 'createdAt']) +export class Notification { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // 수신자 (사용자 삭제 시 알림도 함께 삭제) + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'recipient_id' }) + recipient!: User; + + // 이벤트 타입 + @Column({ type: 'varchar' }) + type!: NotificationType; + + // 대상 정보(자유형) — 예: repoId(slug)/repoName/taskSeq/taskTitle/actorName/note + @Column({ type: 'jsonb', default: () => "'{}'" }) + payload!: Record; + + // 읽음 여부 + @Column({ type: 'boolean', default: false }) + read!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/notification/notification.controller.ts b/backend/src/modules/notification/notification.controller.ts new file mode 100644 index 0000000..e5c55b1 --- /dev/null +++ b/backend/src/modules/notification/notification.controller.ts @@ -0,0 +1,73 @@ +import { + Controller, + DefaultValuePipe, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + ParseUUIDPipe, + Post, + Query, + Sse, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import type { Observable } from 'rxjs'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SkipTransform } from '../../common/decorators/skip-transform.decorator'; +import type { PublicUser } from '../user/user.service'; +import { + NotificationService, + type NotificationListResult, +} from './notification.service'; + +// 알림 컨트롤러 — 모든 엔드포인트 인증 필요(본인 수신함 전용) +@ApiTags('Notification') +@Controller('notifications') +@UseGuards(JwtAuthGuard) +export class NotificationController { + constructor(private readonly notificationService: NotificationService) {} + + @Get() + @ApiOperation({ summary: '내 알림 목록 (페이지네이션 + 미읽음 수)' }) + @ApiResponse({ status: 200, description: '조회 성공' }) + list( + @CurrentUser() user: PublicUser, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, + ): Promise { + return this.notificationService.listFor(user.id, page, size); + } + + @Post(':id/read') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '알림 단건 읽음 처리' }) + @ApiResponse({ status: 200, description: '처리 성공(미읽음 수 반환)' }) + markRead( + @CurrentUser() user: PublicUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ unreadCount: number }> { + return this.notificationService.markRead(user.id, id); + } + + @Post('read-all') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '알림 전체 읽음 처리' }) + @ApiResponse({ status: 200, description: '처리 성공' }) + markAllRead( + @CurrentUser() user: PublicUser, + ): Promise<{ unreadCount: number }> { + return this.notificationService.markAllRead(user.id); + } + + // 실시간 스트림(SSE) — 표준 응답 래퍼를 건너뛰고 text/event-stream 으로 흘려보낸다. + // 인증은 access_token 쿠키(EventSource withCredentials)로 연결 시점에 1회 검증된다. + @Sse('stream') + @SkipTransform() + @ApiOperation({ summary: '실시간 알림 스트림 (SSE)' }) + stream(@CurrentUser() user: PublicUser): Observable { + return this.notificationService.streamFor(user.id); + } +} diff --git a/backend/src/modules/notification/notification.module.ts b/backend/src/modules/notification/notification.module.ts new file mode 100644 index 0000000..348226f --- /dev/null +++ b/backend/src/modules/notification/notification.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Notification } from './entities/notification.entity'; +import { NotificationService } from './notification.service'; +import { NotificationController } from './notification.controller'; + +// 알림 모듈 — 다른 도메인 모듈(task/repo)이 import 해 NotificationService 로 알림을 적재한다. +// (Notification 은 User 만 참조하므로 순환 의존 없음) +@Module({ + imports: [TypeOrmModule.forFeature([Notification])], + controllers: [NotificationController], + providers: [NotificationService], + exports: [NotificationService], +}) +export class NotificationModule {} diff --git a/backend/src/modules/notification/notification.service.ts b/backend/src/modules/notification/notification.service.ts new file mode 100644 index 0000000..206b44f --- /dev/null +++ b/backend/src/modules/notification/notification.service.ts @@ -0,0 +1,249 @@ +import { + Injectable, + Logger, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Subject, merge, interval, type Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import Redis from 'ioredis'; +import { + paginated, + resolvePage, + type PaginatedResult, +} from '../../common/pagination/pagination'; +import { + Notification, + type NotificationType, +} from './entities/notification.entity'; + +// 알림 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립 +export interface NotificationResponse { + id: string; + type: NotificationType; + payload: Record; + read: boolean; + createdAt: string; +} + +// 목록 응답 — 페이지네이션 + 미읽음 수 동봉 +export type NotificationListResult = PaginatedResult & { + unreadCount: number; +}; + +// 알림 적재 파라미터 +export interface NotifyParams { + recipientIds: string[]; // 수신자 User uuid 목록 + actorId: string | null; // 행위자(수신자에서 제외 — 자기 행위는 알리지 않음) + type: NotificationType; + payload?: Record; +} + +// SSE 스트림에 흘려보내는 내부 이벤트(수신자 id + 알림 본문) +interface StreamEvent { + userId: string; + data: NotificationResponse; +} + +// 알림 비즈니스 로직 — 다른 도메인 서비스가 notify() 로 적재하고, +// 사용자별 수신함을 읽거나(SSE) 읽음 처리한다. 적재는 베스트 에포트(본 흐름을 막지 않음). +// +// 실시간 전송: +// - REDIS_HOST 설정 시 **Redis pub/sub** — notify 가 채널에 발행하고 모든 인스턴스가 구독해 +// 각자의 로컬 Subject 로 전달한다. → 다중 인스턴스(수평 확장)에서도 실시간 도달. +// - 미설정 시 **인메모리** 직접 전달(단일 인스턴스). 어느 모드든 SSE 구독은 로컬 stream$ 만 본다. +@Injectable() +export class NotificationService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(NotificationService.name); + // 이 인스턴스의 SSE 구독자에게 전달하는 로컬 Subject(구독 시 userId 로 필터) + private readonly stream$ = new Subject(); + // Redis pub/sub 연결(미설정 시 null → 인메모리 모드). 구독 연결은 일반 명령 불가라 분리한다. + private publisher: Redis | null = null; + private subscriber: Redis | null = null; + // 실시간 알림 채널명 + private static readonly CHANNEL = 'relay:notifications'; + + constructor( + @InjectRepository(Notification) + private readonly notificationRepo: Repository, + ) {} + + // 기동 시 Redis pub/sub 연결 — REDIS_HOST 있을 때만. 실패해도 인메모리로 폴백. + onModuleInit(): void { + const host = process.env.REDIS_HOST; + if (!host) { + this.logger.log( + '알림 실시간 전송: 인메모리 모드(단일 인스턴스) — REDIS_HOST 미설정', + ); + return; + } + const options = { + host, + port: Number(process.env.REDIS_PORT) || 6379, + password: process.env.REDIS_PASSWORD || undefined, + // 장애가 unhandled 로 죽지 않도록 — 무한 재시도(백오프)하며 로그만 남김 + maxRetriesPerRequest: null, + }; + this.publisher = new Redis(options); + this.subscriber = new Redis(options); + this.publisher.on('error', (e: Error) => + this.logger.warn(`Redis publisher 오류(무시): ${e.message}`), + ); + this.subscriber.on('error', (e: Error) => + this.logger.warn(`Redis subscriber 오류(무시): ${e.message}`), + ); + // 채널 구독 — 자기 인스턴스가 발행한 것도 여기서 받아 로컬 stream$ 로 단일 전달 + this.subscriber + .subscribe(NotificationService.CHANNEL) + .catch((e: unknown) => + this.logger.warn( + `Redis 구독 실패: ${e instanceof Error ? e.message : String(e)}`, + ), + ); + this.subscriber.on('message', (channel: string, message: string) => { + if (channel !== NotificationService.CHANNEL) return; + try { + this.stream$.next(JSON.parse(message) as StreamEvent); + } catch { + // 파싱 실패 무시 + } + }); + this.logger.log('알림 실시간 전송: Redis pub/sub 모드(다중 인스턴스 지원)'); + } + + // 종료 시 Redis 연결 정리 + onModuleDestroy(): void { + this.publisher?.disconnect(); + this.subscriber?.disconnect(); + } + + // 실시간 이벤트 전달 — Redis 모드면 발행(모든 인스턴스가 구독으로 수신), + // 인메모리 모드면 로컬 Subject 로 직접 전달. + private dispatch(event: StreamEvent): void { + if (this.publisher) { + this.publisher + .publish(NotificationService.CHANNEL, JSON.stringify(event)) + .catch((e: unknown) => { + // 발행 실패 시 최소한 이 인스턴스 구독자에게는 직접 전달(폴백) + this.logger.warn( + `Redis 발행 실패, 로컬 폴백: ${e instanceof Error ? e.message : String(e)}`, + ); + this.stream$.next(event); + }); + } else { + this.stream$.next(event); + } + } + + // 알림 적재 + 실시간 전송 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수 + async notify(params: NotifyParams): Promise { + try { + // 중복 제거 + 행위자/빈 값 제외 + const recipients = [...new Set(params.recipientIds)].filter( + (id) => id && id !== params.actorId, + ); + if (recipients.length === 0) return; + + const entities = recipients.map((recipientId) => + this.notificationRepo.create({ + recipient: { id: recipientId } as Notification['recipient'], + type: params.type, + payload: params.payload ?? {}, + read: false, + }), + ); + const saved = await this.notificationRepo.save(entities); + + // 저장 성공분을 각 수신자에게 실시간 전달(Redis 발행 또는 로컬) + for (let i = 0; i < saved.length; i++) { + this.dispatch({ + userId: recipients[i], + data: this.toResponse(saved[i]), + }); + } + } catch (err) { + this.logger.error( + `알림 적재 실패: ${params.type}`, + err instanceof Error ? err.stack : String(err), + ); + } + } + + // 사용자 수신함 — 최신순 페이지네이션 + 미읽음 수 + async listFor( + userId: string, + page?: number, + size?: number, + ): Promise { + const pg = resolvePage(page, size); + const [rows, total] = await this.notificationRepo.findAndCount({ + where: { recipient: { id: userId } }, + order: { createdAt: 'DESC' }, + skip: pg.skip, + take: pg.take, + }); + const unreadCount = await this.notificationRepo.count({ + where: { recipient: { id: userId }, read: false }, + }); + return { + ...paginated( + rows.map((n) => this.toResponse(n)), + total, + pg, + ), + unreadCount, + }; + } + + // 단건 읽음 처리(본인 알림만) — 처리 후 미읽음 수 반환 + async markRead(userId: string, id: string): Promise<{ unreadCount: number }> { + await this.notificationRepo.update( + { id, recipient: { id: userId } }, + { read: true }, + ); + return { unreadCount: await this.unreadCount(userId) }; + } + + // 전체 읽음 처리(본인 알림) — 처리 후 미읽음 수(0) 반환 + async markAllRead(userId: string): Promise<{ unreadCount: number }> { + await this.notificationRepo.update( + { recipient: { id: userId }, read: false }, + { read: true }, + ); + return { unreadCount: 0 }; + } + + // 사용자별 실시간 스트림(SSE) — 본인 알림만 필터 + 주기적 하트비트(프록시 idle 차단 방지) + streamFor(userId: string): Observable { + const events = this.stream$.asObservable().pipe( + filter((e) => e.userId === userId), + // 기본('message') 이벤트로 전송 → 프론트 EventSource.onmessage 가 수신 + map((e) => ({ data: e.data }) as MessageEvent), + ); + // 'ping' 은 명명 이벤트라 onmessage 를 트리거하지 않는다(연결 유지 전용) + const heartbeat = interval(25_000).pipe( + map(() => ({ type: 'ping', data: {} }) as MessageEvent), + ); + return merge(events, heartbeat); + } + + // --- 내부 헬퍼 --- + + private async unreadCount(userId: string): Promise { + return this.notificationRepo.count({ + where: { recipient: { id: userId }, read: false }, + }); + } + + private toResponse(n: Notification): NotificationResponse { + return { + id: n.id, + type: n.type, + payload: n.payload ?? {}, + read: n.read, + createdAt: n.createdAt.toISOString(), + }; + } +} diff --git a/backend/src/modules/repo/member.service.spec.ts b/backend/src/modules/repo/member.service.spec.ts index 8c91f42..f2345a1 100644 --- a/backend/src/modules/repo/member.service.spec.ts +++ b/backend/src/modules/repo/member.service.spec.ts @@ -4,6 +4,8 @@ import { MemberService } from './member.service'; import { UserService } from '../user/user.service'; import { TaskService } from '../task/task.service'; import { ActivityService } from '../activity/activity.service'; +import { NotificationService } from '../notification/notification.service'; +import { RepoCacheService } from './repo-cache.service'; import { BusinessException } from '../../common/exceptions/business.exception'; import { Repo } from './entities/repo.entity'; import { RepoMember } from './entities/repo-member.entity'; @@ -19,21 +21,39 @@ function makeService() { save: jest.fn(), remove: jest.fn(), }; - const userService = { findByEmail: jest.fn() }; + const userService = { + findByEmail: jest.fn(), + findById: jest.fn().mockResolvedValue(null), + }; // 담당 업무 수 집계는 빈 Map(0건)으로 모킹 const taskService = { assigneeCounts: jest.fn().mockResolvedValue(new Map()), }; // 활동 적재는 no-op 모킹 const activity = { record: jest.fn().mockResolvedValue(undefined) }; + // 알림 적재는 no-op 모킹 + const notification = { notify: jest.fn().mockResolvedValue(undefined) }; + // 목록 캐시 무효화는 no-op 모킹 + const repoCache = { invalidateList: jest.fn().mockResolvedValue(undefined) }; const svc = new MemberService( repoRepo as unknown as Repository, memberRepo as unknown as Repository, userService as unknown as UserService, taskService as unknown as TaskService, activity as unknown as ActivityService, + notification as unknown as NotificationService, + repoCache as unknown as RepoCacheService, ); - return { svc, repoRepo, memberRepo, userService, taskService, activity }; + return { + svc, + repoRepo, + memberRepo, + userService, + taskService, + activity, + notification, + repoCache, + }; } const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' }; diff --git a/backend/src/modules/repo/member.service.ts b/backend/src/modules/repo/member.service.ts index be5aa56..050b93a 100644 --- a/backend/src/modules/repo/member.service.ts +++ b/backend/src/modules/repo/member.service.ts @@ -10,6 +10,8 @@ import { UserService, type PublicUser } from '../user/user.service'; import { BusinessException } from '../../common/exceptions/business.exception'; import { TaskService } from '../task/task.service'; import { ActivityService } from '../activity/activity.service'; +import { NotificationService } from '../notification/notification.service'; +import { RepoCacheService } from './repo-cache.service'; import { paginated, resolvePage, @@ -40,6 +42,8 @@ export class MemberService { private readonly userService: UserService, private readonly taskService: TaskService, private readonly activity: ActivityService, + private readonly notification: NotificationService, + private readonly repoCache: RepoCacheService, ) {} // 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책). 페이지네이션. @@ -113,6 +117,22 @@ export class MemberService { payload: { targetName: user.name, roleType: dto.roleType }, }); + // 알림 — 초대된 사용자에게 '저장소 초대' + await this.notification.notify({ + recipientIds: [user.id], + actorId: actingUserId, + type: 'member.invited', + payload: { + repoId: repo.slugName, + repoName: repo.name, + roleType: dto.roleType, + actorName: await this.actorName(actingUserId), + }, + }); + + // 저장소 목록 캐시 무효화 — 멤버 추가로 목록 카드의 멤버 수/아바타가 바뀜 + await this.repoCache.invalidateList(); + return this.toResponse(saved); } @@ -146,6 +166,20 @@ export class MemberService { payload: { targetName: member.user.name, roleType: member.roleType }, }); + // 알림 — 역할이 변경된 당사자에게(본인이 본인 역할을 바꾼 경우는 자동 제외) + await this.notification.notify({ + recipientIds: [member.user.id], + actorId: actingUserId, + type: 'member.role_changed', + payload: { + repoId: repo.slugName, + repoName: repo.name, + roleType: member.roleType, + actorName: await this.actorName(actingUserId), + }, + }); + + // (역할 변경은 저장소 목록 응답의 멤버 수/아바타를 바꾸지 않으므로 캐시 무효화 불필요) return this.toResponse(member); } @@ -166,6 +200,7 @@ export class MemberService { ); } const targetName = member.user.name; // 제거 전 이름 캡처 + const removedUserId = member.user.id; // 제거 전 id 캡처(알림 수신자) await this.memberRepo.remove(member); // 활동 적재 — 멤버 제거 @@ -175,10 +210,31 @@ export class MemberService { type: 'member.removed', payload: { targetName }, }); + + // 알림 — 제거된 당사자에게(조직 모델상 저장소 열람은 여전히 가능) + await this.notification.notify({ + recipientIds: [removedUserId], + actorId: actingUserId, + type: 'member.removed', + payload: { + repoId: repo.slugName, + repoName: repo.name, + actorName: await this.actorName(actingUserId), + }, + }); + + // 저장소 목록 캐시 무효화 — 멤버 제거로 목록 카드의 멤버 수/아바타가 바뀜 + await this.repoCache.invalidateList(); } // --- 내부 헬퍼 --- + // 행위자 표시 이름(알림 payload 용) — 없으면 빈 문자열 + private async actorName(userId: string): Promise { + const actor = await this.userService.findById(userId); + return actor?.name ?? ''; + } + // slugName 으로 저장소 로딩, 없으면 404 private async getRepoOrThrow(slugName: string): Promise { const repo = await this.repoRepo.findOne({ where: { slugName } }); diff --git a/backend/src/modules/repo/repo-cache.service.ts b/backend/src/modules/repo/repo-cache.service.ts new file mode 100644 index 0000000..fc28ba1 --- /dev/null +++ b/backend/src/modules/repo/repo-cache.service.ts @@ -0,0 +1,62 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import type { Cache } from 'cache-manager'; +import type { PaginatedResult } from '../../common/pagination/pagination'; +import type { RepoResponse } from './repo.service'; + +// 저장소 목록 캐시 — 버전 네임스페이스 방식으로 O(1) 무효화. +// 키에 버전을 포함하고, 변경(생성/수정/삭제/동기화) 시 버전을 올려 기존 키를 통째로 폐기한다. +// (keyv 는 와일드카드 삭제를 지원하지 않으므로 버전 증가로 일괄 무효화한다) +// 모든 캐시 접근은 실패 시 무시하고 DB 조회로 폴백 — 캐시 장애가 요청을 깨지 않는다. +@Injectable() +export class RepoCacheService { + private static readonly VER_KEY = 'repos:list:ver'; + private static readonly LIST_TTL = 60_000; // 60초(업무 카운트 staleness 백스톱) + + constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {} + + // 현재 목록 캐시 버전(없으면 1로 초기화) + private async version(): Promise { + try { + const v = await this.cache.get(RepoCacheService.VER_KEY); + if (typeof v === 'number') return v; + await this.cache.set(RepoCacheService.VER_KEY, 1); + } catch { + // 캐시 장애 — 버전 1로 취급(매번 미스 → DB 조회) + } + return 1; + } + + // 버전 증가 → 이전 버전의 모든 목록 캐시 키를 무효화(TTL 로 자연 소멸) + async invalidateList(): Promise { + try { + const v = await this.version(); + await this.cache.set(RepoCacheService.VER_KEY, v + 1); + } catch { + // 무시(다음 조회는 구버전 키를 보지만 TTL 60초 내 소멸) + } + } + + // 목록 캐시 get-or-compute — 히트 시 캐시, 미스 시 compute() 결과를 적재 후 반환 + async listOrCompute( + page: number, + size: number, + compute: () => Promise>, + ): Promise> { + const ver = await this.version(); + const key = `repos:list:v${ver}:p${page}:s${size}`; + try { + const hit = await this.cache.get>(key); + if (hit) return hit; + } catch { + // 캐시 장애 — 아래 DB 조회로 폴백 + } + const value = await compute(); + try { + await this.cache.set(key, value, RepoCacheService.LIST_TTL); + } catch { + // 적재 실패 무시 + } + return value; + } +} diff --git a/backend/src/modules/repo/repo-sync.service.ts b/backend/src/modules/repo/repo-sync.service.ts index 34fa3c9..3dd1409 100644 --- a/backend/src/modules/repo/repo-sync.service.ts +++ b/backend/src/modules/repo/repo-sync.service.ts @@ -6,6 +6,7 @@ import { Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import type { User } from '../user/entities/user.entity'; import { GiteaService } from '../gitea/gitea.service'; +import { RepoCacheService } from './repo-cache.service'; import { Repo } from './entities/repo.entity'; import { RepoMember } from './entities/repo-member.entity'; @@ -36,6 +37,7 @@ export class RepoSyncService implements OnApplicationBootstrap { private readonly memberRepo: Repository, private readonly userService: UserService, private readonly gitea: GiteaService, + private readonly repoCache: RepoCacheService, config: ConfigService, ) { this.importAdminEmail = ( @@ -130,6 +132,11 @@ export class RepoSyncService implements OnApplicationBootstrap { // (env REPO_IMPORT_ADMIN_EMAIL 미설정/미가입 시 건너뜀) const adminAssigned = await this.ensureImportAdmin(); + // 동기화로 저장소 목록이 바뀌었으면 캐시 무효화(새 import/누락표시/admin 즉시 반영) + if (imported + relinked + missing + adminAssigned > 0) { + await this.repoCache.invalidateList(); + } + this.logger.log( `Gitea 동기화 완료 (${trigger}) — 원격 ${remote.length}개 / 신규 ${imported} · 갱신 ${relinked} · 누락표시 ${missing} · admin지정 ${adminAssigned}`, ); diff --git a/backend/src/modules/repo/repo.module.ts b/backend/src/modules/repo/repo.module.ts index 53afb01..2b306f3 100644 --- a/backend/src/modules/repo/repo.module.ts +++ b/backend/src/modules/repo/repo.module.ts @@ -4,6 +4,7 @@ import { UserModule } from '../user/user.module'; import { GiteaModule } from '../gitea/gitea.module'; import { TaskModule } from '../task/task.module'; import { ActivityModule } from '../activity/activity.module'; +import { NotificationModule } from '../notification/notification.module'; import { Repo } from './entities/repo.entity'; import { RepoMember } from './entities/repo-member.entity'; import { RepoService } from './repo.service'; @@ -11,6 +12,7 @@ import { RepoController } from './repo.controller'; import { MemberService } from './member.service'; import { MemberController } from './member.controller'; import { RepoSyncService } from './repo-sync.service'; +import { RepoCacheService } from './repo-cache.service'; // 저장소 모듈 (저장소 + 멤버 관리 + Gitea 동기화) @Module({ @@ -20,9 +22,10 @@ import { RepoSyncService } from './repo-sync.service'; GiteaModule, TaskModule, ActivityModule, + NotificationModule, ], controllers: [RepoController, MemberController], - providers: [RepoService, MemberService, RepoSyncService], + providers: [RepoService, MemberService, RepoSyncService, RepoCacheService], exports: [RepoService, MemberService], }) export class RepoModule {} diff --git a/backend/src/modules/repo/repo.service.ts b/backend/src/modules/repo/repo.service.ts index 0121caa..82a2cb2 100644 --- a/backend/src/modules/repo/repo.service.ts +++ b/backend/src/modules/repo/repo.service.ts @@ -23,6 +23,7 @@ import { } from '../../common/pagination/pagination'; import { Repo, type RepoVisibility } from './entities/repo.entity'; import { RepoMember } from './entities/repo-member.entity'; +import { RepoCacheService } from './repo-cache.service'; import { CreateRepoDto } from './dto/create-repo.dto'; import { UpdateRepoDto } from './dto/update-repo.dto'; @@ -65,6 +66,7 @@ export class RepoService { private readonly gitea: GiteaService, private readonly taskService: TaskService, private readonly activity: ActivityService, + private readonly repoCache: RepoCacheService, ) {} // 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보. @@ -90,19 +92,25 @@ export class RepoService { size?: number, ): Promise> { const pg = resolvePage(page, size); - const [repos, total] = await this.repoRepo.findAndCount({ - relations: ['members', 'members.user'], - order: { updatedAt: 'DESC' }, - skip: pg.skip, - take: pg.take, + // 페이지 단위로 캐시(버전 네임스페이스). 구조 변경 시 버전 증가로 일괄 무효화하고, + // 업무 카운트 staleness 는 60초 TTL 로 자연 보정한다. + return this.repoCache.listOrCompute(pg.page, pg.size, async () => { + const [repos, total] = await this.repoRepo.findAndCount({ + relations: ['members', 'members.user'], + order: { updatedAt: 'DESC' }, + skip: pg.skip, + take: pg.take, + }); + // 저장소별 업무 완료/전체 수를 한 번에 집계해 진행률에 반영 + const counts = await this.taskService.countsByRepo( + repos.map((r) => r.id), + ); + return paginated( + repos.map((repo) => this.toResponse(repo, counts.get(repo.id))), + total, + pg, + ); }); - // 저장소별 업무 완료/전체 수를 한 번에 집계해 진행률에 반영 - const counts = await this.taskService.countsByRepo(repos.map((r) => r.id)); - return paginated( - repos.map((repo) => this.toResponse(repo, counts.get(repo.id))), - total, - pg, - ); } // 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드). @@ -182,6 +190,9 @@ export class RepoService { payload: { repoName: saved.name }, }); + // 목록 캐시 무효화(새 저장소 즉시 반영) + await this.repoCache.invalidateList(); + // 갓 생성한 저장소는 업무가 없으므로 집계는 0/0 return this.toResponse(await this.getEntityOrThrow(saved.slugName)); } catch (err) { @@ -258,6 +269,9 @@ export class RepoService { }); } + // 목록 캐시 무효화(표시명/공개범위 변경 즉시 반영) + await this.repoCache.invalidateList(); + return this.toResponse( await this.getEntityOrThrow(slugName), await this.taskCountsOf(repo.id), @@ -275,6 +289,9 @@ export class RepoService { } await this.repoRepo.remove(repo); + // 목록 캐시 무효화(삭제 즉시 반영) + await this.repoCache.invalidateList(); + // Gitea 저장소도 삭제 (베스트 에포트 — 실패 시 로그만 남기고 Relay 삭제는 유지) if (this.gitea.enabled && repo.giteaRepoId !== null) { await this.gitea.deleteRepo(repo.slugName).catch((e: unknown) => { diff --git a/backend/src/modules/task/task.module.ts b/backend/src/modules/task/task.module.ts index 91ed1bc..f520370 100644 --- a/backend/src/modules/task/task.module.ts +++ b/backend/src/modules/task/task.module.ts @@ -7,6 +7,7 @@ import { ChecklistItem } from './entities/checklist-item.entity'; import { Comment } from './entities/comment.entity'; import { Attachment } from './entities/attachment.entity'; import { ActivityModule } from '../activity/activity.module'; +import { NotificationModule } from '../notification/notification.module'; import { TaskService } from './task.service'; import { TaskController } from './task.controller'; import { MyTaskController } from './my-task.controller'; @@ -23,6 +24,7 @@ import { MyTaskController } from './my-task.controller'; RepoMember, ]), ActivityModule, + NotificationModule, ], controllers: [TaskController, MyTaskController], providers: [TaskService], diff --git a/backend/src/modules/task/task.service.spec.ts b/backend/src/modules/task/task.service.spec.ts index 7fb2f69..4ab3a03 100644 --- a/backend/src/modules/task/task.service.spec.ts +++ b/backend/src/modules/task/task.service.spec.ts @@ -9,6 +9,7 @@ import { ChecklistItem } from './entities/checklist-item.entity'; import { Comment } from './entities/comment.entity'; import { Attachment } from './entities/attachment.entity'; import { ActivityService } from '../activity/activity.service'; +import { NotificationService } from '../notification/notification.service'; // 리포지토리 간이 모킹 function makeService() { @@ -30,6 +31,8 @@ function makeService() { record: jest.fn().mockResolvedValue(undefined), listForTask: jest.fn().mockResolvedValue([]), }; + // 알림 서비스 — 적재는 no-op + const notification = { notify: jest.fn().mockResolvedValue(undefined) }; const svc = new TaskService( taskRepo as unknown as Repository, checklistRepo as unknown as Repository, @@ -38,6 +41,7 @@ function makeService() { commentRepo as unknown as Repository, attachmentRepo as unknown as Repository, activity as unknown as ActivityService, + notification as unknown as NotificationService, ); return { svc, @@ -104,7 +108,11 @@ describe('TaskService', () => { const { svc, taskRepo, checklistRepo, repoRepo, memberRepo } = makeService(); repoRepo.findOne.mockResolvedValue(REPO); - memberRepo.findOne.mockResolvedValue({ roleType: 'admin' }); // 지시자/admin + // 지시자/admin — assertMember 는 user 관계까지 로드(알림 actorName 용) + memberRepo.findOne.mockResolvedValue({ + roleType: 'admin', + user: { id: 'admin', name: 'A' }, + }); const task = { seq: 9, status: 'review', diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index ce3dc87..528b94f 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -20,6 +20,7 @@ import { ActivityService, type ActivityResponse, } from '../activity/activity.service'; +import { NotificationService } from '../notification/notification.service'; import { paginated, resolvePage, @@ -170,8 +171,24 @@ export class TaskService { @InjectRepository(Attachment) private readonly attachmentRepo: Repository, private readonly activity: ActivityService, + private readonly notification: NotificationService, ) {} + // 알림 payload 공통 조립 — 프론트가 문구/링크(repoId/taskSeq) 구성에 사용 + private taskNotifyPayload( + repo: Repo, + task: Task, + actorName: string, + ): Record { + return { + repoId: repo.slugName, + repoName: repo.name, + taskSeq: task.seq, + taskTitle: task.title, + actorName, + }; + } + // 업무 목록 — 세그먼트(all/prog/todo/done)·검색·담당자 필터 + 오프셋 페이지네이션 + 세그먼트 카운트 async list( slugName: string, @@ -330,6 +347,14 @@ export class TaskService { taskSeq: saved.seq, }); + // 알림 — 담당자들에게 '업무 지시'(행위자=지시자는 자동 제외) + await this.notification.notify({ + recipientIds: assignees.map((a) => a.id), + actorId: actingUserId, + type: 'task.assigned', + payload: this.taskNotifyPayload(repo, saved, issuer.user.name), + }); + return this.buildDetail( await this.getTaskOrThrow(repo.id, saved.seq), repo, @@ -344,7 +369,7 @@ export class TaskService { dto: UpdateTaskDto, ): Promise { const repo = await this.getRepoOrThrow(slugName); - await this.assertAdmin(repo.id, actingUserId); + const acting = await this.assertAdmin(repo.id, actingUserId); const task = await this.getTaskOrThrow(repo.id, seq); // 변경 감지용 이전 상태 캡처 @@ -383,6 +408,45 @@ export class TaskService { checklistChanged, }); + // 알림 — 담당자 변경(추가분=지시, 제거분=담당 해제) + if (dto.assigneeIds !== undefined) { + const newIds = (task.assignees ?? []).map((a) => a.id); + const added = newIds.filter((id) => !prevAssigneeIds.includes(id)); + const removed = prevAssigneeIds.filter((id) => !newIds.includes(id)); + if (added.length) { + await this.notification.notify({ + recipientIds: added, + actorId: actingUserId, + type: 'task.assigned', + payload: this.taskNotifyPayload(repo, task, acting.user.name), + }); + } + if (removed.length) { + await this.notification.notify({ + recipientIds: removed, + actorId: actingUserId, + type: 'task.unassigned', + payload: this.taskNotifyPayload(repo, task, acting.user.name), + }); + } + } + + // 알림 — 마감 변경 시 현재 담당자에게(추가/해제와 별개로 발송) + if (dto.dueDate !== undefined) { + const newDueIso = task.dueDate ? task.dueDate.toISOString() : null; + if (newDueIso !== prevDueIso) { + await this.notification.notify({ + recipientIds: (task.assignees ?? []).map((a) => a.id), + actorId: actingUserId, + type: 'task.due_changed', + payload: { + ...this.taskNotifyPayload(repo, task, acting.user.name), + dueDate: newDueIso, + }, + }); + } + } + return this.buildDetail(await this.getTaskOrThrow(repo.id, seq), repo); } @@ -501,7 +565,7 @@ export class TaskService { seq: number, ): Promise { const repo = await this.getRepoOrThrow(slugName); - await this.assertAdmin(repo.id, actingUserId); + const acting = await this.assertAdmin(repo.id, actingUserId); const task = await this.getTaskOrThrow(repo.id, seq); // 디스크 첨부 파일 정리(베스트 에포트) — DB 행은 FK CASCADE 로 함께 삭제됨 const files = await this.attachmentRepo.find({ @@ -510,6 +574,12 @@ export class TaskService { await Promise.all(files.map((f) => this.deleteFileFromDisk(f.storedName))); const taskTitle = task.title; // 삭제 전 캡처 const taskSeq = task.seq; + // 삭제 알림 수신자(지시자 + 담당자)와 payload 를 삭제 전에 스냅샷 + const removalRecipients = [ + ...(task.issuer ? [task.issuer.id] : []), + ...(task.assignees ?? []).map((a) => a.id), + ]; + const removalPayload = this.taskNotifyPayload(repo, task, acting.user.name); await this.taskRepo.remove(task); // 활동 적재 — 업무 삭제(활동은 taskSeq 로만 연결돼 업무 삭제 후에도 유지) @@ -520,6 +590,14 @@ export class TaskService { payload: { taskTitle }, taskSeq, }); + + // 알림 — 지시자/담당자에게 '업무 삭제'(업무가 사라지므로 프론트는 저장소로 이동) + await this.notification.notify({ + recipientIds: removalRecipients, + actorId: actingUserId, + type: 'task.removed', + payload: removalPayload, + }); } // 상태 변경 — 상태 머신 전이 규칙 + 역할 검증 @@ -574,6 +652,17 @@ export class TaskService { taskSeq: task.seq, }); + // 알림 — 업무 관련자(지시자 + 담당자, 작성자 본인 제외)에게 '댓글' + await this.notification.notify({ + recipientIds: [ + ...(task.issuer ? [task.issuer.id] : []), + ...task.assignees.map((a) => a.id), + ], + actorId: actingUserId, + type: 'task.commented', + payload: this.taskNotifyPayload(repo, task, membership.user.name), + }); + return this.toCommentResponse( await this.commentRepo.findOneOrFail({ where: { id: saved.id }, @@ -598,6 +687,7 @@ export class TaskService { const parent = await this.commentRepo.findOne({ where: { id: commentId, task: { id: task.id }, parent: IsNull() }, + relations: ['author'], // 답글 알림 수신자(원댓글 작성자) 식별용 }); if (!parent) { throw new NotFoundException(); @@ -620,6 +710,21 @@ export class TaskService { taskSeq: task.seq, }); + // 알림 — 업무 관련자(지시자 + 담당자) + 원댓글 작성자(비참여자여도), 작성자 본인 제외 + await this.notification.notify({ + recipientIds: [ + ...(task.issuer ? [task.issuer.id] : []), + ...task.assignees.map((a) => a.id), + ...(parent.author ? [parent.author.id] : []), + ], + actorId: actingUserId, + type: 'task.commented', + payload: { + ...this.taskNotifyPayload(repo, task, membership.user.name), + isReply: true, + }, + }); + return { id: saved.id, author: membership.user ? UserService.toPublic(membership.user) : null, @@ -939,6 +1044,39 @@ export class TaskService { payload: { statusFrom: from, statusTo: next, taskTitle: task.title }, taskSeq: task.seq, }); + + // 알림 — 전이 종류별 수신자(승인요청→지시자, 승인/수정요청→담당자). 시작(→prog)은 알림 없음 + const actorName = membership.user.name; + if (next === 'review') { + await this.notification.notify({ + recipientIds: task.issuer ? [task.issuer.id] : [], + actorId: actingUserId, + type: 'task.approval_requested', + payload: this.taskNotifyPayload(repo, task, actorName), + }); + } else if (next === 'done') { + await this.notification.notify({ + recipientIds: task.assignees.map((a) => a.id), + actorId: actingUserId, + type: 'task.approved', + payload: this.taskNotifyPayload(repo, task, actorName), + }); + } else if (next === 'changes') { + await this.notification.notify({ + recipientIds: task.assignees.map((a) => a.id), + actorId: actingUserId, + type: 'task.changes_requested', + payload: this.taskNotifyPayload(repo, task, actorName), + }); + } else if (next === 'prog') { + // 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게 + await this.notification.notify({ + recipientIds: task.issuer ? [task.issuer.id] : [], + actorId: actingUserId, + type: 'task.started', + payload: this.taskNotifyPayload(repo, task, actorName), + }); + } } // slugName 으로 저장소 로딩, 없으면 404 diff --git a/frontend/src/composables/useNotification.ts b/frontend/src/composables/useNotification.ts new file mode 100644 index 0000000..484e621 --- /dev/null +++ b/frontend/src/composables/useNotification.ts @@ -0,0 +1,35 @@ +import { useApi } from './useApi' +import { DEFAULT_PAGE_SIZE } from '@/types/pagination' +import type { ApiNotificationList, UnreadCountResult } from '@/types/notification' + +// 알림 도메인 API Composable — 인터셉터가 success/data 를 언래핑 +// (실시간 스트림 SSE 는 store 에서 EventSource 로 직접 다룬다 — axios 비대상) +export function useNotification() { + const api = useApi() + + // 내 알림 목록(페이지네이션 + 미읽음 수) + async function list( + page = 1, + size = DEFAULT_PAGE_SIZE, + ): Promise { + return (await api.get('/notifications', { + params: { page, size }, + })) as unknown as ApiNotificationList + } + + // 단건 읽음 처리 + async function markRead(id: string): Promise { + return (await api.post( + `/notifications/${id}/read`, + )) as unknown as UnreadCountResult + } + + // 전체 읽음 처리 + async function markAllRead(): Promise { + return (await api.post( + '/notifications/read-all', + )) as unknown as UnreadCountResult + } + + return { list, markRead, markAllRead } +} diff --git a/frontend/src/layouts/AppShell.vue b/frontend/src/layouts/AppShell.vue index 3953228..f5051ba 100644 --- a/frontend/src/layouts/AppShell.vue +++ b/frontend/src/layouts/AppShell.vue @@ -1,9 +1,12 @@