feat: 실시간 알림(인앱+SSE, Redis pub/sub) + Redis 캐싱 (8단계)

알림(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) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 13:12:47 +09:00
parent ef858f6e34
commit d87726194b
23 changed files with 1539 additions and 42 deletions
+132
View File
@@ -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",
+2
View File
@@ -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",
+32 -4
View File
@@ -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: [
@@ -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);
@@ -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<T> {
success: boolean;
@@ -17,10 +19,23 @@ export class TransformInterceptor<T> implements NestInterceptor<
T,
StandardResponse<T>
> {
// main.ts 에서 new 로 생성하므로 DI 대신 자체 Reflector 인스턴스 사용
private readonly reflector = new Reflector();
intercept(
_context: ExecutionContext,
context: ExecutionContext,
next: CallHandler<T>,
): Observable<StandardResponse<T>> {
// @SkipTransform() 핸들러(SSE 스트림 등)는 래핑하지 않고 그대로 흘려보낸다
const skip = this.reflector.get<boolean>(
SKIP_TRANSFORM,
context.getHandler(),
);
if (skip) {
// SSE MessageEvent 스트림 등은 래핑 대상이 아니므로 그대로 통과(타입만 인터페이스에 맞춤)
return next.handle() as unknown as Observable<StandardResponse<T>>;
}
// 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑
return next.handle().pipe(
map((data: T) => ({
+4
View File
@@ -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');
@@ -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<string, unknown>;
// 읽음 여부
@Column({ type: 'boolean', default: false })
read!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -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<NotificationListResult> {
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<MessageEvent> {
return this.notificationService.streamFor(user.id);
}
}
@@ -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 {}
@@ -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<string, unknown>;
read: boolean;
createdAt: string;
}
// 목록 응답 — 페이지네이션 + 미읽음 수 동봉
export type NotificationListResult = PaginatedResult<NotificationResponse> & {
unreadCount: number;
};
// 알림 적재 파라미터
export interface NotifyParams {
recipientIds: string[]; // 수신자 User uuid 목록
actorId: string | null; // 행위자(수신자에서 제외 — 자기 행위는 알리지 않음)
type: NotificationType;
payload?: Record<string, unknown>;
}
// 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<StreamEvent>();
// 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<Notification>,
) {}
// 기동 시 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<void> {
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<NotificationListResult> {
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<MessageEvent> {
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<number> {
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(),
};
}
}
@@ -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<Repo>,
memberRepo as unknown as Repository<RepoMember>,
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' };
@@ -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<string> {
const actor = await this.userService.findById(userId);
return actor?.name ?? '';
}
// slugName 으로 저장소 로딩, 없으면 404
private async getRepoOrThrow(slugName: string): Promise<Repo> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
@@ -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<number> {
try {
const v = await this.cache.get<number>(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<void> {
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<PaginatedResult<RepoResponse>>,
): Promise<PaginatedResult<RepoResponse>> {
const ver = await this.version();
const key = `repos:list:v${ver}:p${page}:s${size}`;
try {
const hit = await this.cache.get<PaginatedResult<RepoResponse>>(key);
if (hit) return hit;
} catch {
// 캐시 장애 — 아래 DB 조회로 폴백
}
const value = await compute();
try {
await this.cache.set(key, value, RepoCacheService.LIST_TTL);
} catch {
// 적재 실패 무시
}
return value;
}
}
@@ -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<RepoMember>,
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}`,
);
+4 -1
View File
@@ -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 {}
+29 -12
View File
@@ -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<PaginatedResult<RepoResponse>> {
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) => {
+2
View File
@@ -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],
@@ -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<Task>,
checklistRepo as unknown as Repository<ChecklistItem>,
@@ -38,6 +41,7 @@ function makeService() {
commentRepo as unknown as Repository<Comment>,
attachmentRepo as unknown as Repository<Attachment>,
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',
+140 -2
View File
@@ -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<Attachment>,
private readonly activity: ActivityService,
private readonly notification: NotificationService,
) {}
// 알림 payload 공통 조립 — 프론트가 문구/링크(repoId/taskSeq) 구성에 사용
private taskNotifyPayload(
repo: Repo,
task: Task,
actorName: string,
): Record<string, unknown> {
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<TaskDetailResponse> {
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<void> {
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
@@ -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<ApiNotificationList> {
return (await api.get('/notifications', {
params: { page, size },
})) as unknown as ApiNotificationList
}
// 단건 읽음 처리
async function markRead(id: string): Promise<UnreadCountResult> {
return (await api.post(
`/notifications/${id}/read`,
)) as unknown as UnreadCountResult
}
// 전체 읽음 처리
async function markAllRead(): Promise<UnreadCountResult> {
return (await api.post(
'/notifications/read-all',
)) as unknown as UnreadCountResult
}
return { list, markRead, markAllRead }
}
+296 -19
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
// 앱 공통 셸 — 상단 탑바(브랜드/네비/우측 액션) + 본문 슬롯
// 모든 인증 이후 화면이 이 레이아웃을 공유한다.
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
import { useNotificationStore } from '@/stores/notification.store'
import type { NotificationView } from '@/types/notification'
const route = useRoute()
const router = useRouter()
@@ -21,9 +24,35 @@ const meInitial = computed(() => me.value?.name?.charAt(0) ?? '?')
// 사용자 메뉴 토글
const menuOpen = ref(false)
// 로그아웃 → 로그인 화면으로
// --- 알림(실시간 SSE) ---
const notiStore = useNotificationStore()
const {
views: notiViews,
unreadCount,
loading: notiLoading,
loadingMore: notiLoadingMore,
hasMore: notiHasMore,
} = storeToRefs(notiStore)
const notiOpen = ref(false)
// 알림 항목 클릭 — 읽음 처리 후 대상 화면으로 이동
async function onNotiClick(v: NotificationView) {
notiOpen.value = false
await notiStore.markRead(v.id)
if (v.to) await router.push(v.to)
}
// AppShell 은 인증 이후 화면에서만 마운트되므로 여기서 스트림 연결(멱등)
// (store 싱글톤이 연결을 보유 → 화면 전환에도 끊기지 않음. 해제는 로그아웃 시에만)
onMounted(() => {
if (authStore.isLoggedIn) void notiStore.connect()
})
// 로그아웃 → 알림 스트림 해제 후 로그인 화면으로
async function onLogout() {
menuOpen.value = false
notiStore.disconnect()
await authStore.logout()
await router.push('/login')
}
@@ -82,24 +111,108 @@ async function onLogout() {
<path d="m21 21-4-4" />
</svg>
</button>
<button
class="icon-btn"
type="button"
title="알림"
aria-label="알림"
>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="noti-wrap">
<button
class="icon-btn noti-btn"
type="button"
title="알림"
aria-label="알림"
aria-haspopup="menu"
:aria-expanded="notiOpen"
@click="notiOpen = !notiOpen"
>
<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>
</button>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<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>
<span
v-if="unreadCount > 0"
class="noti-badge"
:aria-label="`읽지 않은 알림 ${unreadCount}`"
>{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
<!-- 알림 드롭다운 -->
<div
v-if="notiOpen"
class="noti-panel"
role="menu"
>
<div class="noti-head">
<span class="noti-title">알림</span>
<button
v-if="unreadCount > 0"
type="button"
class="noti-readall"
@click="notiStore.markAllRead()"
>
모두 읽음
</button>
</div>
<div class="noti-list">
<div
v-if="notiLoading && notiViews.length === 0"
class="noti-empty"
>
불러오는
</div>
<div
v-else-if="notiViews.length === 0"
class="noti-empty"
>
알림이 없습니다.
</div>
<template v-else>
<button
v-for="v in notiViews"
:key="v.id"
type="button"
class="noti-item"
:class="{ unread: !v.read }"
role="menuitem"
@click="onNotiClick(v)"
>
<span
class="noti-dot"
:class="`tone-${v.tone || 'none'}`"
/>
<span class="noti-body">
<!-- noti-text v-html store 에서 사용자 입력을 escapeHtml 신뢰 태그(<b>) 조립한 마크업이다 (XSS 위험 없음) -->
<!-- eslint-disable vue/no-v-html -->
<span
class="noti-text"
v-html="v.html"
/>
<!-- eslint-enable vue/no-v-html -->
<span class="noti-time">{{ v.time }}</span>
</span>
</button>
<button
v-if="notiHasMore"
type="button"
class="noti-more"
:disabled="notiLoadingMore"
@click="notiStore.loadMore()"
>
{{ notiLoadingMore ? '불러오는 중…' : '더보기' }}
</button>
</template>
</div>
</div>
<!-- 메뉴 바깥 클릭 닫기 -->
<div
v-if="notiOpen"
class="noti-overlay"
@click="notiOpen = false"
/>
</div>
<div class="me-wrap">
<button
class="me"
@@ -232,6 +345,170 @@ async function onLogout() {
background: #f1f2f4;
color: var(--text);
}
/* 알림 종 + 드롭다운 */
.noti-wrap {
position: relative;
}
.noti-btn {
position: relative;
}
.noti-badge {
position: absolute;
top: -0.125rem;
right: -0.125rem;
min-width: 1rem;
height: 1rem;
padding: 0 0.25rem;
border-radius: 0.5rem;
background: var(--red);
color: #fff;
font-size: 0.625rem;
font-weight: 700;
line-height: 1rem;
text-align: center;
box-shadow: 0 0 0 2px var(--panel);
}
.noti-panel {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
z-index: 60;
width: 22rem;
max-width: 90vw;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
.noti-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6875rem 0.875rem;
border-bottom: 1px solid var(--border);
}
.noti-title {
font-size: 0.844rem;
font-weight: 700;
color: var(--text);
}
.noti-readall {
border: none;
background: transparent;
color: var(--accent);
font-family: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
padding: 0.125rem 0.25rem;
border-radius: var(--radius-sm);
}
.noti-readall:hover {
background: var(--accent-weak);
}
.noti-list {
max-height: 24rem;
overflow-y: auto;
}
.noti-empty {
padding: 1.75rem 0.875rem;
text-align: center;
font-size: 0.781rem;
color: var(--text-3);
}
.noti-item {
display: flex;
align-items: flex-start;
gap: 0.625rem;
width: 100%;
padding: 0.6875rem 0.875rem;
border: none;
border-top: 1px solid var(--border);
background: transparent;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.noti-item:first-child {
border-top: none;
}
.noti-item:hover {
background: #fafbfc;
}
.noti-item.unread {
background: var(--accent-weak);
}
.noti-item.unread:hover {
background: #eceafd;
}
.noti-dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
margin-top: 0.375rem;
flex-shrink: 0;
background: var(--text-3);
}
.noti-dot.tone-blue {
background: var(--blue);
}
.noti-dot.tone-amber {
background: var(--amber);
}
.noti-dot.tone-green {
background: var(--green);
}
.noti-dot.tone-red {
background: var(--red);
}
.noti-dot.tone-accent {
background: var(--accent);
}
.noti-body {
flex: 1;
min-width: 0;
}
.noti-text {
display: block;
font-size: 0.8125rem;
color: var(--text);
line-height: 1.45;
}
.noti-text :deep(b) {
font-weight: 600;
}
.noti-time {
display: block;
margin-top: 0.1875rem;
font-size: 0.6875rem;
color: var(--text-3);
}
.noti-more {
width: 100%;
padding: 0.625rem;
border: none;
border-top: 1px solid var(--border);
background: transparent;
font-family: inherit;
font-size: 0.781rem;
font-weight: 600;
color: var(--accent);
cursor: pointer;
}
.noti-more:hover {
background: #fafbfc;
}
.noti-more:disabled {
color: var(--text-3);
cursor: default;
}
.noti-overlay {
position: fixed;
inset: 0;
z-index: 55;
}
.me-wrap {
position: relative;
margin-left: 0.25rem;
+247
View File
@@ -0,0 +1,247 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
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'
// 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
}
// API 알림 → 화면 뷰(type+payload → 톤/문구/이동경로). 사용자 입력은 escapeHtml 후 신뢰 태그만 조립
function toView(n: ApiNotification): NotificationView {
const p = n.payload
const actor = escapeHtml(pstr(p, 'actorName') || '누군가')
const repoName = escapeHtml(pstr(p, 'repoName'))
const taskTitle = escapeHtml(pstr(p, 'taskTitle'))
const repoId = pstr(p, 'repoId')
const taskSeq = pnum(p, 'taskSeq')
const taskTo = repoId && taskSeq !== null ? `/repos/${repoId}/tasks/${taskSeq}` : null
let tone = ''
let html = '새 알림'
let to: string | null = taskTo
switch (n.type) {
case 'task.assigned':
tone = 'blue'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 지시했습니다`
break
case 'task.unassigned':
tone = 'red'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무 담당에서 회원님을 제외했습니다`
break
case 'task.started':
tone = 'blue'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 시작했습니다`
break
case 'task.due_changed': {
const due = pstr(p, 'dueDate')
const body = due
? `마감기한을 <b>${escapeHtml(taskDueInfo(due, false).dateLabel)}</b>(으)로 변경했습니다`
: '마감기한을 해제했습니다'
tone = 'blue'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>의 ${body}`
break
}
case 'task.removed':
tone = 'red'
to = repoId ? `/repos/${repoId}` : null // 업무가 삭제되므로 저장소로 이동
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 삭제했습니다`
break
case 'task.approval_requested':
tone = 'amber'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 승인을 요청했습니다`
break
case 'task.approved':
tone = 'green'
html = `<b>${taskTitle}</b> 업무가 승인되었습니다`
break
case 'task.changes_requested':
tone = 'red'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 수정을 요청했습니다`
break
case 'task.commented':
tone = ''
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>에 ${p.isReply === true ? '답글' : '댓글'}을 남겼습니다`
break
case 'member.invited':
tone = 'accent'
to = repoId ? `/repos/${repoId}` : null
html = `<b>${actor}</b>님이 <b>${repoName}</b> 저장소에 초대했습니다`
break
case 'member.role_changed': {
const role = pstr(p, 'roleType') === 'admin' ? '관리자' : '멤버'
tone = 'accent'
to = repoId ? `/repos/${repoId}` : null
html = `<b>${actor}</b>님이 <b>${repoName}</b>에서 회원님의 역할을 <b>${role}</b>(으)로 변경했습니다`
break
}
case 'member.removed':
tone = 'red'
to = repoId ? `/repos/${repoId}` : null
html = `<b>${actor}</b>님이 <b>${repoName}</b> 저장소에서 회원님을 제외했습니다`
break
}
return { id: n.id, read: n.read, tone, html, time: relativeTimeKo(n.createdAt), to }
}
// SSE 재연결 지연(ms) — 토큰 만료/네트워크 끊김 시 정리 후 재시도
const RECONNECT_DELAY = 5_000
// 알림 스토어 — 수신함(목록/미읽음) + 실시간 스트림(SSE)
// EventSource 는 store 싱글톤이 보유해 화면 전환과 무관하게 연결을 유지한다(로그아웃 시에만 해제).
export const useNotificationStore = defineStore('notification', () => {
const items = ref<ApiNotification[]>([])
const unreadCount = ref(0)
const total = ref(0)
const page = ref(0)
const loading = ref(false)
const loadingMore = ref(false)
const views = computed<NotificationView[]>(() => items.value.map(toView))
const hasMore = computed(() => items.value.length < total.value)
const notificationApi = useNotification()
// --- SSE 실시간 스트림 ---
// (Pinia setup store 외부 상태 — 반응형 불필요한 연결 핸들/타이머)
let es: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let wantConnected = false
function openStream(): void {
const base = import.meta.env.VITE_API_BASE_URL || '/api'
es = new EventSource(`${base}/notifications/stream`, { withCredentials: true })
es.onmessage = (e: MessageEvent<string>) => {
try {
const n = JSON.parse(e.data) as ApiNotification
// 재연결 시 동일 알림 재수신 방어(중복 id 무시)
if (items.value.some((x) => x.id === n.id)) return
items.value.unshift(n)
total.value += 1
if (!n.read) unreadCount.value += 1
} catch {
// 파싱 실패(하트비트 등 비정상 페이로드) 무시
}
}
es.onerror = () => {
// 연결 끊김(토큰 만료/네트워크) — 정리 후 재연결 예약. 해제 의사가 있으면 멈춤
if (es) {
es.close()
es = null
}
if (wantConnected && !reconnectTimer) {
reconnectTimer = setTimeout(() => {
reconnectTimer = null
if (wantConnected) openStream()
}, RECONNECT_DELAY)
}
}
}
// 연결 시작(멱등) — 최초 1회 목록 로드 + 스트림 오픈. 로그인 화면 진입마다 호출돼도 안전
async function connect(): Promise<void> {
if (wantConnected) return
wantConnected = true
await fetchInitial()
openStream()
}
// 연결 해제 + 상태 초기화(로그아웃 시)
function disconnect(): void {
wantConnected = false
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (es) {
es.close()
es = null
}
items.value = []
unreadCount.value = 0
total.value = 0
page.value = 0
}
// --- 목록/읽음 처리 ---
// 첫 페이지(리셋) 로드 + 미읽음 수 동기화
async function fetchInitial(): Promise<void> {
loading.value = true
try {
const res = await notificationApi.list(1, DEFAULT_PAGE_SIZE)
items.value = res.items
total.value = res.total
unreadCount.value = res.unreadCount
page.value = 1
} finally {
loading.value = false
}
}
// 다음 페이지 이어붙이기(더보기)
async function loadMore(): Promise<void> {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
try {
const res = await notificationApi.list(page.value + 1, DEFAULT_PAGE_SIZE)
// 그 사이 실시간 수신으로 중복될 수 있는 id 는 제외하고 append
const known = new Set(items.value.map((x) => x.id))
items.value.push(...res.items.filter((n) => !known.has(n.id)))
total.value = res.total
unreadCount.value = res.unreadCount
page.value += 1
} finally {
loadingMore.value = false
}
}
// 단건 읽음 처리(낙관적 갱신 후 서버 미읽음 수로 보정)
async function markRead(id: string): Promise<void> {
const target = items.value.find((n) => n.id === id)
if (!target || target.read) return
target.read = true
try {
const { unreadCount: c } = await notificationApi.markRead(id)
unreadCount.value = c
} catch {
target.read = false // 실패 시 롤백(인터셉터가 알림 처리)
}
}
// 전체 읽음 처리
async function markAllRead(): Promise<void> {
if (unreadCount.value === 0) return
try {
const { unreadCount: c } = await notificationApi.markAllRead()
items.value.forEach((n) => (n.read = true))
unreadCount.value = c
} catch {
// 인터셉터가 알림 처리
}
}
return {
items,
views,
unreadCount,
total,
loading,
loadingMore,
hasMore,
connect,
disconnect,
fetchInitial,
loadMore,
markRead,
markAllRead,
}
})
+47
View File
@@ -0,0 +1,47 @@
// 알림 도메인 타입 — 백엔드 NotificationService 응답과 1:1
import type { Paginated } from '@/types/pagination'
// 알림 타입(백엔드 NotificationType 과 동일)
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'
// 알림(API 원시) — 표시 문구/링크는 프론트가 type+payload 로 조립
export interface ApiNotification {
id: string
type: NotificationType
payload: Record<string, unknown>
read: boolean
createdAt: string
}
// 목록 응답 — 페이지네이션 + 미읽음 수 동봉
export type ApiNotificationList = Paginated<ApiNotification> & {
unreadCount: number
}
// 읽음 처리 응답
export interface UnreadCountResult {
unreadCount: number
}
// 화면용 알림 뷰(type+payload → 톤/문구/이동경로 파생)
export interface NotificationView {
id: string
read: boolean
tone: string // 좌측 점 색상(blue/amber/green/red/accent/'')
html: string // 조립된 문구(사용자 입력은 escapeHtml 적용)
time: string // 상대시간
to: string | null // 클릭 시 이동 경로
}