Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa4bc40761 | |||
| 47567b94a0 | |||
| d87726194b | |||
| ef858f6e34 |
Generated
+132
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,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 {}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
+34
-6
@@ -42,14 +42,16 @@
|
||||
status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프론트는 `BIZ_001` 코드일 때 서버 `message` 를 그대로 노출한다.
|
||||
예: 로그인 실패 → `BusinessException('BIZ_001', '이메일 또는 비밀번호가 올바르지 않습니다.', 401)`.
|
||||
|
||||
### 페이지네이션 — **8단계에서 통일(진행 중)**
|
||||
### 페이지네이션 — **8단계에서 통일(완료)**
|
||||
|
||||
목록 API 는 오프셋 기반: 쿼리 `?page=1&size=20`(기본 size 20, 최대 100), 응답 `{ items: [...], total, page, size }`.
|
||||
프론트는 **더보기 버튼**으로 점진 로드(시안에 페이지 UI 없음).
|
||||
|
||||
- **백엔드 적용 완료**: `GET /repos`, `/repos/:id/members`, `/tasks/assigned`, `/tasks/issued`, `/repos/:id/tasks`(세그먼트/검색 + `counts` 동봉). 공통 유틸 `common/pagination`.
|
||||
- **프론트 적용 완료**: 저장소 목록, 내 업무. **남음**: 멤버, 저장소 업무 목록(RepoDetailPage).
|
||||
- 응답이 단순 배열 → `{ items, total, page, size }` 로 바뀌었으므로 프론트 composable/store 가 함께 갱신돼야 한다.
|
||||
- **프론트 적용 완료(전 도메인)**:
|
||||
- **더보기(오프셋)**: 저장소 목록, 내 업무, **저장소 업무 목록(RepoDetailPage)**. 저장소 업무는 세그먼트(`segment`)·검색(`q`)도 백엔드로 이전하고 세그먼트 배지 수는 응답 `counts` 사용(`TaskListQuery={segment,q,assignee}`, `ApiRepoTaskListResult=Paginated<ApiRepoTask> & {counts}`).
|
||||
- **전체 로드**: **멤버(RepoMembersPage)** 는 `member.store.fetchList` 가 페이지네이션 API를 끝까지 순회해 전체를 적재(더보기 대신). 역할/검색 카운트가 전수 기준이어야 하고, TaskCreatePage 담당자 후보 풀이 같은 호출을 쓰므로 전체 셋을 보장한다(멤버는 팀 규모로 한정적).
|
||||
- (회귀 수정 `c128b19`: `useMember.list`·`useTask.list` 가 `Paginated<T>` 를 반환하도록 정합 — 위 작업의 선행 단계로 흡수됨.)
|
||||
|
||||
---
|
||||
|
||||
@@ -178,7 +180,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
|
||||
| 메서드 | 경로 | 요청 | 응답 | 권한 |
|
||||
|---|---|---|---|---|
|
||||
| GET | `.../tasks` | `?status&assignee` | `RepoTask[]` | 인증 |
|
||||
| GET | `.../tasks` | `?segment&q&assignee&page&size` | `{ items: RepoTask[], total, page, size, counts }` | 인증 |
|
||||
| POST | `.../tasks` | `CreateTaskDto` | `TaskDetail` | admin |
|
||||
| GET | `.../tasks/:taskId` | — | `TaskDetail` | 인증 |
|
||||
| PATCH | `.../tasks/:taskId` | `UpdateTaskDto` | `TaskDetail` | admin |
|
||||
@@ -186,7 +188,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
| POST | `.../tasks/:taskId/status` | `{ status }` | `TaskDetail` | 멤버(전이별 역할 검증) |
|
||||
|
||||
- `:taskId` 는 **저장소 내 순번(seq)** — 정수(`ParseIntPipe`). 전역 PK 는 별도 uuid.
|
||||
- 목록 정렬: seq 내림차순(최신 우선). `?status`/`?assignee`(userId) 필터.
|
||||
- 목록 정렬: seq 내림차순(최신 우선). `?segment`(all/prog/todo/done, prog=prog+review)·`?q`(제목 부분일치)·`?assignee`(userId) 필터 + `?page&size` 페이지네이션. 응답에 세그먼트 카운트(`counts{all,prog,todo,done}`, 검색어 반영) 동봉.
|
||||
|
||||
상태(`TaskStatus`): `todo | prog | review | done | changes`. 전이 규칙(상태 머신):
|
||||
|
||||
@@ -289,7 +291,32 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done
|
||||
|
||||
## 8. 마무리(선택) — **8단계**
|
||||
|
||||
소셜 로그인(Google/Kakao OAuth), 이메일 인증, 알림, AI 에이전트 패널, Redis 캐싱, 전체 페이지네이션 통일.
|
||||
### 알림 (`/api/notifications`) — **✅ 구현 완료 (인앱 + 실시간 SSE)**
|
||||
|
||||
활동 피드와 별개로 **사용자별 수신함**을 두고, 도메인 행위에서 대상자에게 알림을 적재한다(쓰기 API 없음 — 다른 서비스가 내부에서 `NotificationService.notify` 호출). 실시간은 **SSE**(서버→클라이언트 단방향, 쿠키 인증).
|
||||
|
||||
| 메서드 | 경로 | 요청 | 응답 | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/notifications` | `?page&size` | `{ items: ApiNotification[], total, page, size, unreadCount }` | 본인 수신함(최신순) |
|
||||
| POST | `/notifications/:id/read` | — | `{ unreadCount }` | 단건 읽음 |
|
||||
| POST | `/notifications/read-all` | — | `{ unreadCount: 0 }` | 전체 읽음 |
|
||||
| SSE | `/notifications/stream` | — (EventSource) | `ApiNotification` 스트림 | 실시간. 표준 래퍼 미적용(`@SkipTransform`), 25초 하트비트(`event: ping`) |
|
||||
|
||||
`ApiNotification`(원시): `{ id, type, payload, read, createdAt }`
|
||||
- `type`(12종): `task.assigned`(지시→담당자) / `task.unassigned`(담당 해제→제거된 담당자) / `task.approval_requested`(승인요청→지시자) / `task.approved`(승인→담당자) / `task.changes_requested`(수정요청→담당자) / `task.started`(시작·재개→지시자) / `task.due_changed`(마감 변경→담당자, payload `dueDate`) / `task.removed`(삭제→관련자, 이동=저장소) / `task.commented`(댓글·답글→관련자, **답글은 원댓글 작성자 포함** + payload `isReply`) / `member.invited`(초대→피초대자) / `member.role_changed`(역할 변경→당사자) / `member.removed`(제거→당사자).
|
||||
- `payload`: `repoId`(slug)·`repoName`·`taskSeq`·`taskTitle`·`actorName`·`roleType` 등. 표시 문구/톤/이동경로(`/repos/:repoId/tasks/:taskSeq` 또는 `/repos/:repoId`)는 **프론트가 type+payload 로 조립**(사용자 입력은 escapeHtml).
|
||||
- **행위자 본인은 수신 대상에서 자동 제외.** 적재는 베스트 에포트(실패해도 본 흐름 안 막음), 도입 이후 발생분만(소급 없음).
|
||||
- 인증: 모든 엔드포인트 `JwtAuthGuard`(본인 알림 전용). SSE 도 `access_token` 쿠키(EventSource `withCredentials`)로 연결 시점 검증.
|
||||
- 실시간 전송은 `REDIS_HOST` 설정 시 **Redis pub/sub**(ioredis, 채널 `relay:notifications`)로 fan-out → **다중 인스턴스 수평 확장에서도 실시간 도달**. 미설정 시 인메모리 폴백(단일 인스턴스).
|
||||
> 매핑: AppShell 상단바 종(미읽음 뱃지 + 드롭다운). 연결은 store 싱글톤(EventSource)이 보유 — 화면 전환에 끊기지 않고 로그아웃 시 해제.
|
||||
|
||||
### 캐싱 (Redis) — **✅ 완료**
|
||||
|
||||
전역 CacheModule 을 Redis(keyv)로 전환(`REDIS_*` 미설정 시 인메모리 폴백, 기본 TTL 60초). **`GET /repos`(저장소 목록)**을 버전 네임스페이스로 캐시 — 키 `repos:list:v{ver}:p{page}:s{size}`, **저장소 생성/수정/삭제·Gitea 동기화·멤버 초대/제거** 시 버전 증가로 일괄 무효화(멤버 역할 변경은 목록 응답 불변이라 제외). 버전 카운터가 공유 Redis 에 있어 **무효화가 다중 인스턴스에서도 정합**. 업무 카운트(`doneCount/totalCount`)는 ≤60초 지연 허용. 응답 계약 불변(캐시는 투명) — 프론트 영향 없음. `GET /repos/meta` 는 DB 미접근 인메모리 getter 라 캐싱 제외.
|
||||
|
||||
### 그 외(미착수)
|
||||
|
||||
소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널. (전체 페이지네이션 통일·실시간 알림·Redis 캐싱은 완료.)
|
||||
|
||||
---
|
||||
|
||||
@@ -303,5 +330,6 @@ src/modules/
|
||||
task/ (4) ✅ — Task / ChecklistItem (Comment / Reply / Attachment 는 5단계)
|
||||
activity/ (6) — Activity 이벤트 (다른 모듈에서 emit)
|
||||
my-tasks/ (7) — 집계 전용 (task 모듈에 흡수 가능)
|
||||
notification/ (8) ✅ — Notification 엔티티 + SSE 스트림 (task/repo 모듈에서 notify)
|
||||
```
|
||||
> 실제 배치: `member` 는 `repo` 모듈에 흡수(`MemberService`/`MemberController` + `RepoMember` 재사용). `task` 모듈은 4단계 완료(엔티티·CRUD·상태머신·집계), 5단계에서 Comment/Reply/Attachment 추가 예정. `RepoModule` 이 진행률·taskCount 집계를 위해 `TaskModule` 을 import(엔티티만 참조해 순환 없음).
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
> 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록.
|
||||
> 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조.
|
||||
> 최종 갱신: 2026-06-18 · 현재 위치: **0~7단계 완료(모든 화면 실데이터 연동). 8단계(마무리·선택) 진행 중 — 페이지네이션 통일 작업 중.**
|
||||
> 8단계 진행: **페이지네이션** 백엔드 전체 완료 + 프론트 2/4 도메인(저장소 목록·내 업무) 완료, 멤버·저장소 업무 도메인 대기. 그 외 8단계 항목(소셜 로그인·이메일 인증·알림·AI 패널·Redis 캐싱)은 미착수.
|
||||
> 최종 갱신: 2026-06-19 · 현재 위치: **0~7단계 완료(모든 화면 실데이터 연동). 8단계(마무리·선택) 진행 중 — 페이지네이션 통일 + 실시간 알림 + Redis 캐싱 완료.**
|
||||
> 8단계 진행: **① 페이지네이션 전 도메인 완료**(더보기/전체 로드). **② 실시간 알림(인앱 + SSE) 완료**. **③ Redis 캐싱 완료** — CacheModule을 Redis(keyv)로 전환(미설정 시 인메모리 폴백), 저장소 목록(`GET /repos`)을 버전 네임스페이스 캐시. 그 외 8단계 항목(소셜 로그인·이메일 인증·AI 패널)은 미착수.
|
||||
|
||||
---
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
|
||||
### 8단계 — 마무리(선택) · **진행 중**
|
||||
|
||||
#### 8-1. 페이지네이션 통일 — 백엔드 ✅ / 프론트 2/4 도메인
|
||||
#### 8-1. 페이지네이션 통일 — 백엔드 ✅ / 프론트 전 도메인 ✅
|
||||
|
||||
**결정(2026-06-18)**: 전체 목록 통일 + 프론트는 **더보기 버튼** 방식(시안에 페이지 UI 없음).
|
||||
|
||||
@@ -218,13 +218,55 @@
|
||||
- ✅ **내 업무**: `useMyTask.assigned/issued(page,size)`, `my-task.store`(원시 행 누적 + 그룹핑은 computed 로 파생 → 더보기 시 같은 그룹에 합류, 담당/지시 각각 total/page/hasMore + `loadMoreAssigned`/`loadMoreIssued`), MyTasksPage 뷰별 더보기.
|
||||
- 검증: 프론트 type-check/lint/build 0 err.
|
||||
|
||||
**프론트 — 남은 2/4 도메인 ⏳**
|
||||
- ⏳ **멤버**(RepoMembersPage): 더보기 페이지네이션. 단 **TaskCreatePage 가 담당자 후보로 전체 멤버를 사용**하므로, 거기엔 전체 로드(모든 페이지 순회 `fetchAll`)를 별도 보장해야 함.
|
||||
- ⏳ **저장소 업무**(RepoDetailPage): 현재 세그먼트/검색이 **클라이언트 필터** → 백엔드(`segment`/`q`)로 이전 + 세그먼트 카운트(`counts`) 연동 + 더보기. `useTask.list`·`task.store.fetchList` 응답 형태 변경 반영. (가장 복잡)
|
||||
- ⚠️ **전환기 주의**: 백엔드 응답이 이미 `{items,...}` 로 바뀌어, 프론트 연동 전까지 **멤버·저장소 상세 화면은 일시적으로 동작이 어긋날 수 있음**(다음 작업에서 해소).
|
||||
**응답 계약 회귀 수정 ✅ (2026-06-19, `c128b19`)**
|
||||
- 백엔드 페이지네이션 도입으로 목록 API 가 `{items,total,...}` 를 반환하는데 프론트 멤버·업무 소비처가 **배열로 기대**해 `list.map` 이 깨지고 멤버 탭·업무 목록이 **비어 보이던 회귀**를 수정.
|
||||
- 프론트: `useMember.list`·`useTask.list` 가 `Paginated<T>` 반환하도록 맞추고, `member.store`·`task.store.fetchList` 에서 `.items` 로 언래핑(저장소·내업무와 동일 계약). → **첫 페이지(기본 20건)는 정상 표시.**
|
||||
- 백엔드(task): 업무 목록 쿼리가 to-many 조인 컬럼(`checklist.sort_order`)으로 정렬해 페이지네이션 시 TypeORM 이 크래시하던 문제 제거 → `task.seq` 만 정렬(목록은 체크리스트 [완료/전체] 수만 쓰므로 항목 순서 불필요).
|
||||
- ⚠️ 이전의 '전환기 화면 깨짐' 위험은 **이 커밋으로 해소**. 단 더보기/세그먼트 이전은 아직이라 **첫 페이지 이후 항목은 보이지 않음**(아래 ⏳).
|
||||
|
||||
#### 8-2. 그 외(미착수)
|
||||
- 소셜 로그인(Google/Kakao OAuth), 이메일 인증, 알림, AI 에이전트 패널 실연동, Redis 캐싱.
|
||||
**프론트 — 남은 2/4 도메인 ✅ (2026-06-19)**
|
||||
- ✅ **멤버**(RepoMembersPage): **더보기 대신 전체 로드 채택**. `member.store.fetchList` 가 페이지네이션 API를 끝까지 순회(`while items`)해 전체 멤버를 적재한다. 이유: ① 역할/검색 세그먼트 카운트(전체/관리자/멤버)가 **전수 기준**이어야 하고(부분 로드 시 합계 불일치), ② **TaskCreatePage 가 담당자 후보 풀로 같은 `fetchList` 를 사용**하므로 전체가 보장돼야 하며, ③ 멤버는 팀 규모로 한정적이라 전체 로드가 안전. → 두 소비처(멤버 페이지·업무 생성 폼) 모두 페이지 수정 없이 전체 셋 확보. (더보기 UI 불필요)
|
||||
- ✅ **저장소 업무**(RepoDetailPage): 세그먼트/검색을 **백엔드로 이전** — `task.store` 에 `total`/`page`/`counts`/`hasMore`/`loadingMore` + `loadMore` 추가, `useTask.list(repoId, {segment,q}, page, size)` 가 `{items,total,counts}` 반환. 페이지가 `segment`(즉시)·`keyword`(250ms 디바운스) 변경 시 1페이지 리셋 로드, 더보기로 다음 페이지 append. 세그먼트 배지 수는 응답 `counts`(검색어 반영) 사용. 타입 `TaskSegment`/`TaskSegmentCounts`/`ApiRepoTaskListResult` 신설, 기존 `TaskListQuery.status` → `{segment,q}` 로 교체.
|
||||
- 검증: 프론트 type-check/lint/build 0 err.
|
||||
|
||||
> **알려진 한계**: RepoDetailPage 진행률 스트립의 '지연 N건'은 **현재 로드된 목록 기준**(전역 정확 집계는 백엔드 미지원). 업무 삭제 시 세그먼트 카운트는 목록 재진입(재마운트) 시 재조회로 갱신(`remove` 는 총계만 보정).
|
||||
|
||||
#### 8-2. 실시간 알림 (인앱 + SSE) ✅ (2026-06-19)
|
||||
|
||||
**전송 방식 결정**: **SSE(Server-Sent Events)** 채택. 알림은 서버→클라이언트 단방향 푸시라 SSE 가 정확히 맞고, HttpOnly 쿠키 인증(EventSource `withCredentials`)과 호환되며 NestJS `@Sse()` 네이티브 지원이라 **새 의존성 없음**(WebSocket 은 socket.io 등 추가 필요). 단일 인스턴스 가정 → 인메모리 pub/sub(RxJS Subject).
|
||||
|
||||
**백엔드** `backend/src/modules/notification/`
|
||||
- `Notification` 엔티티: `recipient`(User, CASCADE)·`type`·`payload`(jsonb)·`read`·`createdAt`. 복합 인덱스 `[recipient, createdAt]`. (활동 피드와 별개 — 활동=저장소 전체 로그, 알림=개인 수신함)
|
||||
- `NotificationService`: `notify({recipientIds, actorId, type, payload})`(행위자 자동 제외 + 베스트 에포트 적재 후 스트림 푸시), `listFor`(페이지네이션 + `unreadCount`), `markRead`/`markAllRead`, `streamFor(userId)`(RxJS Subject 를 userId 로 필터 + 25초 하트비트). 인메모리 `Subject<{userId,data}>` 하나를 구독 시 필터.
|
||||
- `NotificationController`: `GET /notifications`(목록+미읽음), `POST /notifications/:id/read`, `POST /notifications/read-all`, **`@Sse('stream')`**(실시간). SSE 는 `@SkipTransform()` 로 표준 래퍼(`{success,data}`)를 건너뛴다(신규 `common/decorators/skip-transform.decorator.ts` + `TransformInterceptor` 가 Reflector 로 마커 확인).
|
||||
- **적재 지점**(다른 서비스가 `notify` 호출, **8종**): TaskService — 업무 생성→담당자(`task.assigned`), 상태전이 `transition` 단일 지점에서 review→지시자(`task.approval_requested`)·done→담당자(`task.approved`)·changes→담당자(`task.changes_requested`), 댓글/답글→관련자(`task.commented`, **답글은 원댓글 작성자 포함**), 업무 수정 시 새 담당자(`task.assigned`). MemberService — 초대→피초대자(`member.invited`), **역할 변경→당사자(`member.role_changed`)**, **제거→당사자(`member.removed`)**. NotificationModule 을 TaskModule·RepoModule 이 import(순환 없음 — Notification 은 User 만 참조).
|
||||
- *(2026-06-19 보완: P1[멤버 역할변경/제거, 답글→원댓글 작성자] + P2[담당 해제 `task.unassigned`·마감 변경 `task.due_changed`] + P3[삭제 `task.removed`·시작 `task.started`·답글 `isReply` 구분] → 알림 **12종**, gap 전건 해소. 전수 검토는 [`notification-review.md`](./notification-review.md))*
|
||||
|
||||
**프론트** `frontend/src/`
|
||||
- `types/notification.ts`, `composables/useNotification.ts`(list/markRead/markAllRead — SSE 는 axios 비대상이라 store 에서 EventSource 직접 처리), `stores/notification.store.ts`(items/unreadCount/total + `connect`(멱등, 최초 1회 목록 로드 + 스트림 오픈)/`disconnect`/`loadMore`/`markRead`(낙관적)/`markAllRead`, type+payload→톤/문구/이동경로 뷰 매핑, **사용자 입력 escapeHtml 후 신뢰 태그만 v-html**).
|
||||
- **AppShell**: 장식이던 상단바 종 버튼 → 실연동. **미읽음 뱃지**(99+ 캡), **드롭다운**(목록·모두 읽음·더보기·빈 상태), 항목 클릭=읽음 처리 + 대상(`/repos/:id/tasks/:seq` 또는 `/repos/:id`)으로 이동. 스트림은 store 싱글톤이 보유 → 화면 전환에도 끊기지 않고, **연결은 AppShell 마운트 시(로그인 상태) 1회, 해제는 로그아웃 시**.
|
||||
- 검증: 백엔드 build/lint 0 err·20 tests pass, 프론트 type-check/lint/build 0 err.
|
||||
|
||||
> **알려진 한계**: ① ~~단일 인스턴스 전용~~ → **다중 인스턴스 지원 완료(2026-06-19, R6)**: `REDIS_HOST` 설정 시 Redis pub/sub(ioredis)로 fan-out — `notify` 가 채널 `relay:notifications` 에 발행, 모든 인스턴스가 구독해 실시간 전달. 미설정 시 인메모리 폴백. ② access 토큰(15분) 만료 후 SSE 가 끊겨 재연결되는 시점에 쿠키가 만료돼 있으면, 다음 axios 호출이 토큰을 갱신할 때까지 5초 간격 무알림 재시도(유휴 상태 한정, 사용 재개 시 자동 복구). ③ 알림은 **도입 이후 발생분만**(소급 없음), 이메일/푸시는 범위 밖.
|
||||
|
||||
#### 8-3. Redis 캐싱 ✅ (2026-06-19)
|
||||
|
||||
**스택**: `@nestjs/cache-manager@3` + `cache-manager@6`(Keyv 기반) → Redis 연결은 신규 의존성 **`@keyv/redis`**. docker-compose 가 이미 redis 컨테이너 + `REDIS_HOST/PORT/PASSWORD` 주입.
|
||||
|
||||
**백엔드**
|
||||
- **CacheModule → Redis 전환**(`app.module.ts` `registerAsync`): `REDIS_HOST` 있으면 `createKeyv(redis://…)` 스토어, **없으면 인메모리 폴백**(단독 실행/테스트). keyv `error` 이벤트를 흡수해 Redis 장애가 프로세스를 죽이지 않음(캐시 미스→DB 폴백). 기본 TTL 60초.
|
||||
- **저장소 목록 캐싱**(`GET /repos`): 신규 `RepoCacheService`(repo 모듈) — **버전 네임스페이스 무효화**. 키 `repos:list:v{ver}:p{page}:s{size}`, 변경 시 `repos:list:ver` 증가로 기존 키 일괄 폐기(keyv 와일드카드 삭제 미지원 회피). 모든 캐시 접근은 try/catch 로 실패 시 DB 조회 폴백.
|
||||
- **무효화 지점**: RepoService 생성/수정/삭제 + RepoSyncService 동기화 + **MemberService 초대/제거**(목록 카드 멤버 수·아바타 반영, 역할 변경은 목록 불변이라 제외 — *2026-06-19 보강*). `RepoCacheService` 공유.
|
||||
- **보강(2026-06-19, 검토 [`redis-cache-review.md`](./redis-cache-review.md))**: ① C1 멤버 변경 무효화, ② 기동 시 캐시 모드 로그(Redis/인메모리 가시화), ③ `main.ts` `enableShutdownHooks()` — 종료 시 알림 Redis pub/sub 연결 graceful close(이전엔 훅 미설정으로 `onModuleDestroy` 가 死코드였음).
|
||||
- **업무 카운트 staleness**: 업무 상태 변경은 목록 버전을 올리지 않아 `doneCount/totalCount` 는 최대 60초(TTL) 지연 — 대시보드 특성상 허용(정확 무효화 대신 task↔repo 결합 회피).
|
||||
- **캐싱 제외**: `GET /repos/meta` 는 순수 인메모리 getter(DB 미접근)라 Redis 왕복이 오히려 손해 → 캐싱 안 함.
|
||||
|
||||
**검증**: 백엔드 build/lint 0 err·20 tests pass. (프론트 변경 없음 — API 계약 동일, 응답만 캐시.)
|
||||
|
||||
> **알려진 한계**: ① 캐싱 대상은 현재 저장소 목록 1종(가장 트래픽 큰 랜딩) — 업무 목록 등은 세그먼트/검색/페이지 조합이 많아 추후 확장 후보. ② 업무 카운트 ≤60초 지연(위). ③ **의존성 추가(`@keyv/redis`)라 dev 컨테이너는 `up -d --build -V`(익명 node_modules 볼륨 갱신) 필요.**
|
||||
|
||||
#### 8-4. 그 외(미착수)
|
||||
- 소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널 실연동.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# 알림(Notification) 시스템 전체 검토
|
||||
|
||||
> 검토일: 2026-06-19 · 대상: 8-2단계 실시간 알림(인앱 + SSE) 구현 전체
|
||||
> 관련 계약: [`api-contract.md`](./api-contract.md) §8 · 진행 기록: [`integration-progress.md`](./integration-progress.md) §8-2
|
||||
> 코드 기준: `backend/src/modules/notification/`, `task.service.ts`·`member.service.ts` 의 `notify` 호출부, `frontend/src/stores/notification.store.ts`·`layouts/AppShell.vue`
|
||||
|
||||
이 문서는 세 가지 질문에 답한다 — **① 어떤 알림을 구현했는가**, **② 빠진 부분은 없는가**, **③ 잘못된 부분은 없는가**.
|
||||
|
||||
> **갱신(2026-06-19)**: 본 검토의 **빠짐(gap) 전건 + 확장(R6) 보완 완료** — P1(G1·G2·G3) + P2(G4·G5) + P3(G6·G7) + R5(답글 구분) + **R6(Redis pub/sub 다중 인스턴스 실시간)**. 알림 유형 6→**12종**, 미보완 gap **0건**, 남은 건 경미한 견고성 R1~R4 뿐. (아래 §2·§5·§6·§7 에 반영)
|
||||
|
||||
---
|
||||
|
||||
## 1. 아키텍처 요약
|
||||
|
||||
- **수신함 모델**: 활동 피드(저장소 전체 로그)와 **별개**로, 사용자별 `Notification`(recipient·type·payload·read) 수신함을 둔다.
|
||||
- **적재 경로**: 도메인 서비스(Task/Member)가 행위 시점에 `NotificationService.notify({recipientIds, actorId, type, payload})` 호출 → 행위자 제외 + DB 적재(베스트 에포트) → 실시간 전달(`REDIS_HOST` 설정 시 **Redis pub/sub** fan-out, 미설정 시 인메모리 `Subject`).
|
||||
- **실시간 전송**: SSE(`@Sse('stream')`), 쿠키 인증, 25초 하트비트. 표준 응답 래퍼는 `@SkipTransform()` 으로 우회.
|
||||
- **프론트**: store 싱글톤이 `EventSource` 보유(화면 전환에 안 끊김), 상단바 종에 미읽음 뱃지 + 드롭다운. type+payload→문구/이동경로 파생(escapeHtml).
|
||||
|
||||
---
|
||||
|
||||
## 2. 구현된 알림 유형 (12종)
|
||||
|
||||
업무 8종 + 멤버 3종 + 댓글/답글 1종. (P1·P2·P3 보완 반영)
|
||||
|
||||
| # | type | 트리거(코드) | 수신자 | payload 핵심 | 프론트 문구 / 이동 | 톤 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `task.assigned` | 업무 생성(`create`) · 수정 시 **추가된** 담당자(`update`) | 담당자(추가분) | repoId·taskSeq·taskTitle·actorName | "{actor}님이 {업무}를 지시했습니다" → 업무 상세 | blue |
|
||||
| 2 | `task.unassigned` | 수정 시 **제외된** 담당자(`update`) | 담당자(제거분) | 〃 | "{actor}님이 {업무} 담당에서 회원님을 제외했습니다" → 업무 상세 | red |
|
||||
| 3 | `task.approval_requested` | 상태 전이 →`review`(`transition`) | **지시자(issuer)** | 〃 | "{actor}님이 {업무} 승인을 요청했습니다" → 업무 상세 | amber |
|
||||
| 4 | `task.approved` | 상태 전이 →`done` | 담당자 전원 | 〃 | "{업무} 업무가 승인되었습니다" → 업무 상세 | green |
|
||||
| 5 | `task.changes_requested` | 상태 전이 →`changes` | 담당자 전원 | 〃 | "{actor}님이 {업무} 수정을 요청했습니다" → 업무 상세 | red |
|
||||
| 6 | `task.started` | 상태 전이 →`prog`(시작/재개) | **지시자(issuer)** | 〃 | "{actor}님이 {업무}를 시작했습니다" → 업무 상세 | blue |
|
||||
| 7 | `task.due_changed` | 수정 시 마감 변경(`update`) | 담당자 전원 | 〃 + `dueDate` | "{actor}님이 {업무}의 마감기한을 {날짜}로 변경/해제했습니다" → 업무 상세 | blue |
|
||||
| 8 | `task.removed` | 업무 삭제(`remove`) | 지시자 + 담당자 | 〃(삭제 전 스냅샷) | "{actor}님이 {업무} 업무를 삭제했습니다" → **저장소**(업무 소멸) | red |
|
||||
| 9 | `task.commented` | 댓글(`addComment`) · 답글(`addReply`) | 지시자 + 담당자 + **(답글 시) 원댓글 작성자** | 〃 + (답글 시) `isReply` | "{actor}님이 {업무}에 댓글/**답글**을 남겼습니다" → 업무 상세 | (기본) |
|
||||
| 10 | `member.invited` | 멤버 초대(`invite`) | 피초대자 | repoId·repoName·roleType·actorName | "{actor}님이 {저장소}에 초대했습니다" → 저장소 | accent |
|
||||
| 11 | `member.role_changed` | 멤버 역할 변경(`update`) | 역할이 바뀐 당사자 | repoId·repoName·roleType·actorName | "{actor}님이 …역할을 {관리자/멤버}로 변경했습니다" → 저장소 | accent |
|
||||
| 12 | `member.removed` | 멤버 제거(`remove`) | 제거된 당사자 | repoId·repoName·actorName | "{actor}님이 {저장소}에서 회원님을 제외했습니다" → 저장소 | red |
|
||||
|
||||
> 모든 경우 **행위자 본인은 수신 대상에서 자동 제외**(`notify` 내부 `Set` 중복 제거 + actorId 필터). 빈 수신자면 적재/푸시 생략.
|
||||
|
||||
---
|
||||
|
||||
## 3. 검토 결과 한눈에
|
||||
|
||||
| 구분 | 건수 | 비고 |
|
||||
|---|---|---|
|
||||
| ✅ 정상 동작 확인 | 9 | 런타임 실시간 수신 포함 확인됨 |
|
||||
| ⚠️ 빠진 부분(gap) | 7 → **0 (전건 보완 완료)** | 활동 대비 알림 격차 해소 |
|
||||
| 🐛 주의/개선 | 6 → **4 (R5·R6 해소)** | 기능 버그는 없음. 남은 항목은 경미한 견고성(R1~R4) |
|
||||
|
||||
**종합 판정**: 핵심 업무 협업 플로우(지시→진행→승인/수정요청→댓글, 초대)의 알림은 **정상 구현·동작**한다. 치명적 버그 없음. **빠짐(gap) 7건은 P1/P2/P3 로 전건 보완 완료(2026-06-19)** — 의도적 제외 항목(저장소 설정 변경·파일 첨부 등)을 빼면 알림화할 도메인 이벤트는 모두 커버됨. 남은 것은 견고성·확장 항목(§6, 특히 R6 다중 인스턴스).
|
||||
|
||||
---
|
||||
|
||||
## 4. ✅ 정상 동작 확인 항목
|
||||
|
||||
1. **핵심 플로우 6종 적재·전송** — 위 표대로 트리거·수신자 매핑 정확. 런타임에서 실시간 종 수신 확인됨(사용자 검증).
|
||||
2. **행위자 제외** — 자기 행위로 자기 알림이 오지 않음(`notify` 필터).
|
||||
3. **중복 제거** — 지시자=담당자(자가지시·자가담당) 같은 중복 수신자는 1건으로 합쳐짐(`[...new Set]`).
|
||||
4. **권한·격리** — `streamFor(userId)`/`listFor`/`markRead`/`markAllRead` 모두 인증 사용자 본인 알림으로 한정. 타인 알림 열람·읽음 처리 불가(`recipient` WHERE 조건).
|
||||
5. **베스트 에포트** — `notify` 전체가 try/catch 로 감싸져 알림 실패가 본 행위(업무 생성 등)를 막지 않음.
|
||||
6. **XSS 안전** — payload 의 사용자 입력(actorName·taskTitle·repoName)을 프론트에서 `escapeHtml` 후 신뢰 태그(`<b>`)만 v-html 조립.
|
||||
7. **연결 수명** — EventSource 를 store 싱글톤이 보유 → 화면 전환에 안 끊김, 로그아웃 시 해제. 멱등 `connect()`.
|
||||
8. **null 안전** — 지시자 없음(`issuer` SET NULL)·담당자 0명 시 빈 수신자로 처리.
|
||||
9. **읽음 동기화** — 단건/전체 읽음 후 서버 `unreadCount` 로 보정.
|
||||
|
||||
---
|
||||
|
||||
## 5. ⚠️ 빠진 부분 (Gaps)
|
||||
|
||||
핵심 원인: **활동 피드는 11종 이벤트를 적재하지만 알림은 6종만** 발생시킨다. 활동에는 있으나 알림이 없는 이벤트가 아래다.
|
||||
|
||||
| # | 누락 알림 | 현재 상태 | 권고 수신자 | 우선순위 | 비고 |
|
||||
|---|---|---|---|---|---|
|
||||
| ~~G1~~ | ~~멤버 역할 변경~~(`member.role_changed`) | ✅ **보완 완료** — 역할 변경 시 당사자에게 알림 | 역할이 바뀐 당사자 | ~~P1~~ 완료 | 2026-06-19 |
|
||||
| ~~G2~~ | ~~멤버 제거~~(`member.removed`) | ✅ **보완 완료** — 제거 시 당사자에게 알림 | 제거된 당사자 | ~~P1~~ 완료 | 2026-06-19 |
|
||||
| ~~G3~~ | ~~답글 → 원댓글 작성자~~ | ✅ **보완 완료** — `addReply` 가 원댓글 작성자도 수신자에 포함 | 원댓글 작성자(비참여자여도) | ~~P1~~ 완료 | 2026-06-19 |
|
||||
| ~~G4~~ | ~~담당자에서 제외~~ | ✅ **보완 완료** — `update` 제거분에 `task.unassigned` | 제외된 담당자 | ~~P2~~ 완료 | 2026-06-19 |
|
||||
| ~~G5~~ | ~~마감 변경~~ | ✅ **보완 완료** — `update` 마감 변경 시 `task.due_changed`(payload `dueDate`) | 담당자 | ~~P2~~ 완료 | 2026-06-19 |
|
||||
| ~~G6~~ | ~~업무 삭제~~ | ✅ **보완 완료** — `remove` 전 스냅샷 후 `task.removed`(이동=저장소) | 담당자·지시자 | ~~P3~~ 완료 | 2026-06-19 |
|
||||
| ~~G7~~ | ~~업무 시작/재개~~ | ✅ **보완 완료** — `transition` →`prog` 에서 `task.started` | 지시자 | ~~P3~~ 완료 | 2026-06-19 |
|
||||
|
||||
> **의도적으로 제외(정상)**: 저장소 생성/이름변경/공개범위 변경(`repo.*`)·파일 첨부/삭제(`task.file_*`)·업무 일반 수정(`task.edited`)·체크리스트 개별 완료. 노이즈 대비 가치가 낮아 활동 피드로만 표현. (필요 시 추후 추가 가능)
|
||||
|
||||
### 보완 시 주의
|
||||
- **G1/G2(멤버)**: `member.role_changed`/`removed` 는 활동 적재 직후 `notify({recipientIds:[targetUserId], actorId:actingUserId, type, payload:{repoId,repoName,roleType?,actorName}})` 한 줄 추가로 해결. 단 프론트 `notification.store` 의 `toView` 에 두 type 분기 + 타입 정의(`NotificationType`) 추가 필요.
|
||||
- **G3(답글)**: `addReply` 에서 수신자에 `parent.author?.id` 를 추가하면 됨(이미 `parent` 로딩됨). 중복은 `notify` 가 dedup.
|
||||
|
||||
---
|
||||
|
||||
## 6. 🐛 주의 / 개선 필요 (견고성·UX·확장)
|
||||
|
||||
> 기능 오류(버그)는 발견되지 않았다. 아래는 견고성·정합·확장 관점의 개선점이다.
|
||||
|
||||
| # | 항목 | 영향 | 권고 |
|
||||
|---|---|---|---|
|
||||
| R1 | **초기 fetch ↔ SSE 구독이 비원자적** | `connect()` 가 `await fetchInitial()` 후 `openStream()` — 그 사이 ms 윈도에 생성된 알림은 **실시간 푸시 누락**(DB엔 남아 새로고침 시 노출). | 표준 SSE 한계. 필요 시 구독 먼저→직후 목록 fetch 순서로 바꿔 누락→중복(이미 dedup) 쪽으로 전환 |
|
||||
| R2 | **`notify` 가 요청 경로에서 await** | 업무 생성/전이/댓글마다 알림 INSERT + 푸시를 동기 대기 → 약간의 지연(실패는 흡수). | 영향 미미. 트래픽 증가 시 `void`(fire-and-forget) 또는 큐로 분리 검토 |
|
||||
| R3 | **`markAllRead` 클라 가드** | `unreadCount===0` 이면 호출 생략 — 클라 카운트가 드리프트로 0이면 서버 미읽음을 못 비움. | 드물고 다음 fetch 로 자가 보정. 가드 제거 또는 주기적 재동기 고려 |
|
||||
| R4 | **댓글 알림 광범위 + 음소거 없음** | `task.commented` 가 지시자+담당자 전원에게 발송. 대규모 팀·활발한 스레드에서 노이즈. | 현재 규모 허용. 추후 사용자별 음소거/멘션 한정 검토 |
|
||||
| ~~R5~~ | ~~답글이 일반 댓글과 동일 문구~~ | ✅ **해소** — `addReply` payload `isReply:true` → 프론트 "답글을 남겼습니다"로 구분 표시 | 2026-06-19 |
|
||||
| ~~R6~~ | ~~단일 인스턴스 전용~~ | ✅ **해소(2026-06-19)** — `NotificationService` 가 `REDIS_HOST` 설정 시 **Redis pub/sub**(ioredis)로 fan-out: `notify` 가 채널 `relay:notifications` 에 발행, 모든 인스턴스가 구독해 각자 로컬 `stream$` 로 전달 → 다중 인스턴스에서도 실시간 도달. 미설정 시 인메모리 폴백. | 2026-06-19 |
|
||||
| R7 | **`recipients[i]`↔`saved[i]` 순서 의존**(참고) | `save(entities)` 반환 순서가 입력 순서와 같다는 전제로 userId 라우팅. TypeORM 보장 범위 내 정상이나 암묵적. | 견고화하려면 `recipient.id` 를 응답에 포함해 직접 매핑(선택) |
|
||||
|
||||
### 보안·정합 재확인(이상 없음)
|
||||
- 타 사용자 알림 접근 불가(§4-4).
|
||||
- 토큰 만료 후 유휴 재연결은 5초 간격 무알림 재시도 → 다음 axios 호출의 refresh 로 자가 복구(기존 문서화된 한계).
|
||||
- 알림은 **도입 이후 발생분만**(소급 없음) — 의도된 동작.
|
||||
|
||||
---
|
||||
|
||||
## 7. 권고 요약 (우선순위)
|
||||
|
||||
- ~~**P1 (사용자 체감 빠짐 — 우선 보완 권장)**~~ → **✅ 완료(2026-06-19)**
|
||||
- ✅ G1 멤버 역할 변경 알림
|
||||
- ✅ G2 멤버 제거 알림
|
||||
- ✅ G3 답글 → 원댓글 작성자 알림
|
||||
- ✅ **P2 — 완료(2026-06-19)**: G4 담당자 제외 · G5 마감 변경
|
||||
- ✅ **P3 — 완료(2026-06-19)**: G6 업무 삭제 · G7 업무 시작 · R5 답글 구분
|
||||
- ✅ **확장(인프라) — 완료(2026-06-19)**: R6 Redis pub/sub fan-out 전환(다중 인스턴스 실시간 지원)
|
||||
- **남은 경미 항목(선택)**: R1(초기 fetch↔구독 비원자적)·R2(notify await)·R3(markAllRead 가드)·R4(댓글 광범위) — 모두 동작에 영향 없는 견고성/UX 미세조정
|
||||
|
||||
> 알림화할 도메인 이벤트의 빠짐(gap) 전건 + 확장(R6)까지 보완 완료. 남은 R1~R4 는 경미한 견고성 항목으로 동작 영향 없음.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Redis 캐싱 시스템 전체 검토
|
||||
|
||||
> 검토일: 2026-06-19 · 대상: 8-3단계 Redis 캐싱 구현 전체
|
||||
> 관련 계약: [`api-contract.md`](./api-contract.md) §0(캐싱) · 진행 기록: [`integration-progress.md`](./integration-progress.md) §8-3
|
||||
> 코드 기준: `backend/src/app.module.ts`(CacheModule), `modules/repo/repo-cache.service.ts`, `repo.service.ts`(findAll·무효화), `repo-sync.service.ts`
|
||||
|
||||
알림 검토([`notification-review.md`](./notification-review.md))와 동일 형식으로 **① 무엇을 캐싱했는가**, **② 빠진 부분은 없는가**, **③ 잘못된 부분은 없는가**에 답한다.
|
||||
|
||||
> **갱신(2026-06-19)**: 검토 후 보강 완료 — **C1**(멤버 초대/제거 시 목록 캐시 무효화) 추가, **D5**(캐시 모드 기동 로그로 가시화), **D4**(`enableShutdownHooks` 로 종료 시 Redis 연결 정상 정리 — 기존 死코드였던 `onModuleDestroy` 활성화). 나머지(C2·D1·D2·D3·D6)는 평가 후 현행 유지(과설계 회피) — §6·§7 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 아키텍처 요약
|
||||
|
||||
이 프로젝트에서 Redis 는 **두 용도**로 쓰인다(같은 인스턴스, 별개 연결·클라이언트).
|
||||
|
||||
| 용도 | 클라이언트 | 무엇 | 키/채널 |
|
||||
|---|---|---|---|
|
||||
| **캐시**(8-3) | `@keyv/redis`(node-redis 계열) | 전역 CacheModule 스토어 + 저장소 목록 캐시 | 키 `repos:list:*` |
|
||||
| 알림 실시간(8-2/R6) | `ioredis` | SSE fan-out pub/sub | 채널 `relay:notifications` |
|
||||
|
||||
- **캐시 전환**: `app.module.ts` `CacheModule.registerAsync` — `REDIS_HOST` 있으면 `createKeyv(redis://…)` 스토어, 없으면 **인메모리 폴백**(LRU). keyv `error` 흡수(장애→DB 폴백). 기본 TTL 60초.
|
||||
- **저장소 목록 캐싱**: `RepoCacheService` 가 **버전 네임스페이스** 방식으로 `GET /repos`(findAll)를 캐시. 변경 시 버전을 올려 일괄 무효화.
|
||||
|
||||
---
|
||||
|
||||
## 2. 구현된 캐싱
|
||||
|
||||
### 캐시 대상
|
||||
|
||||
| 대상 | 캐시? | 근거 |
|
||||
|---|---|---|
|
||||
| `GET /repos`(저장소 목록, `findAll`) | ✅ 캐시(버전 네임스페이스, TTL 60s) | 랜딩·최다 트래픽 + DB 집계(멤버 조인 + 업무 카운트) 비용 |
|
||||
| `GET /repos/meta`(생성 폼 메타) | ❌ 미캐싱(의도) | DB 미접근 순수 인메모리 getter — Redis 왕복이 오히려 손해 |
|
||||
| 그 외 모든 읽기 | ❌ 미캐싱 | §5 C2 참조 |
|
||||
|
||||
### 동작 방식 (`RepoCacheService`)
|
||||
|
||||
- **키**: `repos:list:v{ver}:p{page}:s{size}` — 페이지 단위로 별도 캐시.
|
||||
- **버전 카운터**: `repos:list:ver`(TTL 없음, 영속). `version()` 이 없으면 1로 초기화.
|
||||
- **무효화**: `invalidateList()` 가 버전을 +1 → 이전 버전 키 전체가 더 이상 참조되지 않음(keyv 와일드카드 삭제 미지원 회피). 남은 구버전 키는 60s TTL 로 자연 소멸.
|
||||
- **폴백**: 모든 캐시 접근(`get`/`set`)을 try/catch 로 감싸 실패 시 DB 조회로 폴백.
|
||||
|
||||
### 무효화 호출 지점 (현재 4곳)
|
||||
|
||||
| 위치 | 트리거 |
|
||||
|---|---|
|
||||
| `repo.service.ts` | 저장소 **생성 / 수정 / 삭제** |
|
||||
| `repo-sync.service.ts` | Gitea **동기화**(import/누락표시/admin 변경분 발생 시) |
|
||||
| `member.service.ts` | 멤버 **초대 / 제거**(목록 카드의 멤버 수·아바타 반영) — *2026-06-19 보강* |
|
||||
|
||||
> 멤버 **역할 변경**은 목록 응답(멤버 수/아바타)을 바꾸지 않으므로 의도적으로 무효화하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 검토 결과 한눈에
|
||||
|
||||
| 구분 | 건수 | 비고 |
|
||||
|---|---|---|
|
||||
| ✅ 정상 동작 확인 | 7 | 특히 **다중 인스턴스 무효화 안전**(공유 버전 키) |
|
||||
| ⚠️ 빠진 부분(gap) | 2 → **1** | C1 **보강 완료** / C2(캐싱 커버리지)는 선택 보류 |
|
||||
| 🐛 주의/개선 | 6 | 기능 버그 없음. D4·D5 보강, 나머지 현행 유지 |
|
||||
|
||||
**종합 판정**: 저장소 목록 캐싱은 **정상 동작**하며, 버전 네임스페이스 + 공유 Redis 덕에 **무효화가 다중 인스턴스에서도 안전**(알림과 달리 별도 pub/sub 불필요). 치명적 버그 없음. 이번 검토의 핵심 빠짐이던 **C1(멤버 변경 시 목록 캐시 무효화 누락)은 보강 완료** — 멤버 초대/제거 시 무효화 추가. 남은 것은 선택적 커버리지 확대(C2)와 동작에 영향 없는 견고성 항목뿐.
|
||||
|
||||
---
|
||||
|
||||
## 4. ✅ 정상 동작 확인 항목
|
||||
|
||||
1. **버전 네임스페이스 무효화** — 변경 시 버전 +1 로 모든 페이지 캐시를 O(1) 폐기. 키 와일드카드 삭제 없이 정확.
|
||||
2. **다중 인스턴스 무효화 안전** — 버전 카운터가 **공유 Redis** 에 있어, 인스턴스 A 의 변경이 인스턴스 B 의 다음 읽기에서 즉시 캐시 미스를 유발. (알림 R6 와 달리 캐시는 추가 작업 없이 이미 수평 확장 정합)
|
||||
3. **그레이스풀 폴백** — `REDIS_HOST` 미설정 시 인메모리, Redis 장애 시 try/catch 로 DB 조회. 캐시가 가용성을 떨어뜨리지 않음.
|
||||
4. **keyv 오류 흡수** — `app.module` 의 `keyv.on('error')` 로 연결 오류가 프로세스를 죽이지 않음.
|
||||
5. **meta 미캐싱 결정** — DB 미접근 getter 라 캐싱 제외. 올바른 판단.
|
||||
6. **plain-DTO 캐싱** — `RepoResponse[]`(날짜는 이미 ISO 문자열)만 저장 → JSON 직렬화 무손실, 엔티티 hydration 함정 없음.
|
||||
7. **TTL 백스톱** — 업무 카운트처럼 무효화하지 않는 파생값도 60초 내 자연 보정.
|
||||
|
||||
---
|
||||
|
||||
## 5. ⚠️ 빠진 부분 (Gaps)
|
||||
|
||||
| # | 빠짐 | 현재 상태 | 영향 | 우선순위 |
|
||||
|---|---|---|---|---|
|
||||
| ~~C1~~ | ~~멤버 변경 시 목록 캐시 무효화 누락~~ | ✅ **보강 완료(2026-06-19)** — `MemberService` 에 `RepoCacheService` 주입, **초대/제거** 시 `invalidateList()` 호출. (역할 변경은 목록 응답 불변이라 제외) | 멤버 초대/제거 후 목록 카드의 멤버 수·아바타 **즉시 반영** | ~~P2~~ 완료 |
|
||||
| C2 | **캐싱 커버리지** | `findOne`(저장소 상세)·멤버 목록·업무 목록(`/repos/:id/tasks`)·내 업무·사용자 검색은 미캐싱 | 성능 최적화 여지(특히 업무 목록·사용자 검색은 빈도 높음). 단 무효화 복잡도(세그먼트/검색/담당자 조합)가 커 신중 필요 | P3(선택, 보류) |
|
||||
|
||||
### C2 참고(보류)
|
||||
업무 목록(`/repos/:id/tasks`)은 `segment`·`q`·`page` 조합 키가 많고 업무 상태 변경마다 무효화가 필요해, 캐싱하려면 저장소별 버전 네임스페이스 + 상태전이 시 무효화 설계가 추가로 든다. **현재 규모에선 과설계라 보류** — 트래픽/병목이 실제로 나타날 때 도입.
|
||||
|
||||
### C2 참고
|
||||
업무 목록(`/repos/:id/tasks`)은 `segment`·`q`·`page` 조합 키가 많고 업무 상태 변경마다 무효화가 필요해, 캐싱하려면 저장소별 버전 네임스페이스 + 상태전이 시 무효화 설계가 추가로 든다. 현재 규모에선 보류가 합리적.
|
||||
|
||||
---
|
||||
|
||||
## 6. 🐛 주의 / 개선 (견고성·일관성·구조)
|
||||
|
||||
> 기능 오류(버그)는 없다. 아래는 견고성·일관성·구조 관점.
|
||||
|
||||
| # | 항목 | 영향 | 권고 |
|
||||
|---|---|---|---|
|
||||
| D1 | **버전 카운터 비원자 증가**(`get`+`set`) | 동시 무효화 시 증가분이 "유실"될 수 있음(둘 다 같은 값으로 set). **정합성은 깨지지 않음**(버전은 어쨌든 전진 → 구버전 키 폐기, DB 커밋이 버전 set 보다 선행). 단 암묵적 순서 의존. | 엄밀히 하려면 Redis 원자 `INCR`(ioredis 이미 도입됨) 사용. 현재는 TTL 백스톱으로 충분 |
|
||||
| D2 | **캐시 스탬피드(single-flight 없음)** | 무효화/TTL 만료 직후 동시 요청이 모두 DB 재계산(한 요청이 채우기 전). 목록 쿼리라 영향 경미 | 트래픽 급증 시 잠금/단일 비행 도입 검토 |
|
||||
| D3 | **Redis 클라이언트 2종 공존** | 캐시=`@keyv/redis`(node-redis), 알림=`ioredis`. 같은 Redis 에 두 클라이언트 라이브러리 | 동작 무해. 정리하려면 한쪽으로 통일(번들/유지보수 단순화) |
|
||||
| D4 | **종료 시 연결 graceful close** | ✅ **보강** — `main.ts` `enableShutdownHooks()` 추가로 종료 시 lifecycle 훅 실행. 이전엔 훅 미설정이라 알림의 `onModuleDestroy`(ioredis 정리)가 **호출조차 안 되던 死코드**였음 → 이제 정상 종료 시 Redis pub/sub 연결 graceful close. (캐시 keyv 연결은 핸들 노출이 없어 프로세스 종료에 위임 — 잔여, 영향 없음) | 알림 연결 정리 활성화 완료 |
|
||||
| D5 | **인메모리 폴백 = 다중 인스턴스 시 캐시 불일치** | ✅ **가시화 보강** — 기동 시 캐시 모드 로그(`캐시: Redis 모드` / `캐시: 인메모리 모드(단일 인스턴스)`) 추가로 오설정 조기 발견 가능. **설계상 단일 인스턴스 모드**는 정상 | 로그로 가시화 |
|
||||
| D6 | **쓰기-무효화 사이 sub-ms 스테일 윈도** | DB 커밋 후 `invalidateList` 까지의 찰나에 들어온 읽기가 캐시 히트로 직전 데이터 반환 가능 | 캐시-어사이드 표준 한계. 사실상 무시 가능(현행 유지) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 권고 요약 (우선순위)
|
||||
|
||||
- ✅ **보강 완료(2026-06-19)**
|
||||
- **C1** 멤버 초대/제거 시 목록 캐시 무효화(`MemberService` → `RepoCacheService`)
|
||||
- **D4** `enableShutdownHooks` 로 종료 시 Redis 연결 graceful close(死코드였던 `onModuleDestroy` 활성화)
|
||||
- **D5** 캐시 모드 기동 로그로 오설정 가시화
|
||||
- **보류(선택) — 트래픽/확장 시 재검토**
|
||||
- C2 캐싱 커버리지 확대(업무 목록/사용자 검색 등) — 무효화 설계 비용이 커 현재는 과설계
|
||||
- D3 Redis 클라이언트 단일화(keyv+ioredis) — 통합이 캐시를 더 복잡하게 만들어 현행 유지
|
||||
- **현행 유지(영향 없음, 평가 후 변경 불필요로 판단)**
|
||||
- D1 버전 비원자 증가 — **정합성 증명상 안전**(버전은 전진 + DB 커밋이 set 보다 선행). 원자 INCR 은 클라이언트 혼용을 유발해 오히려 손해 → 변경 안 함
|
||||
- D2 캐시 스탬피드 · D6 sub-ms 윈도 — 캐시-어사이드 표준 한계
|
||||
|
||||
> **요약**: 실질 빠짐(C1)과 잠재 위험(D4 死코드·D5 오설정 가시성)은 보강 완료. 남은 항목은 모두 **평가 후 의도적으로 현행 유지**(과설계 회피)이며 동작에 영향이 없다.
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -3,28 +3,30 @@ import type {
|
||||
ApiAttachment,
|
||||
ApiComment,
|
||||
ApiCommentReply,
|
||||
ApiRepoTask,
|
||||
ApiRepoTaskListResult,
|
||||
ApiTaskDetail,
|
||||
CreateTaskPayload,
|
||||
TaskListQuery,
|
||||
UpdateTaskPayload,
|
||||
} from '@/types/task'
|
||||
import type { TaskStatus } from '@/mock/relay.mock'
|
||||
import type { Paginated } from '@/types/pagination'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
|
||||
// 업무 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/tasks[/:taskId]
|
||||
export function useTask() {
|
||||
const api = useApi()
|
||||
|
||||
// 업무 목록 (상태/담당자 필터 선택) — 백엔드가 페이지네이션 { items, total, ... } 반환
|
||||
// 업무 목록 (세그먼트/검색/담당자 필터 + 페이지네이션) — 응답에 세그먼트 카운트 동봉
|
||||
async function list(
|
||||
repoId: string,
|
||||
query: TaskListQuery = {},
|
||||
): Promise<Paginated<ApiRepoTask>> {
|
||||
page = 1,
|
||||
size = DEFAULT_PAGE_SIZE,
|
||||
): Promise<ApiRepoTaskListResult> {
|
||||
return (await api.get(`/repos/${repoId}/tasks`, {
|
||||
params: query,
|
||||
})) as unknown as Paginated<ApiRepoTask>
|
||||
params: { ...query, page, size },
|
||||
})) as unknown as ApiRepoTaskListResult
|
||||
}
|
||||
|
||||
// 업무 단건 상세 (taskId = 저장소 내 순번)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 로그인 — 좌측 브랜드 패널 + 우측 로그인 폼
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 폼 상태
|
||||
@@ -15,7 +14,7 @@ const showPassword = ref(false)
|
||||
const keepLogin = ref(true)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 로그인 처리 — 성공 시 원래 목적지(redirect) 또는 저장소 목록으로 이동
|
||||
// 로그인 처리 — 성공 시 항상 저장소 목록으로 진입(이전 화면/딥링크로 복귀하지 않음)
|
||||
async function onLogin() {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
@@ -25,8 +24,7 @@ async function onLogin() {
|
||||
password: password.value,
|
||||
keepLogin: keepLogin.value,
|
||||
})
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/repos'
|
||||
await router.push(redirect)
|
||||
await router.push('/repos')
|
||||
} catch {
|
||||
// 에러 메시지는 API 인터셉터에서 전역 처리됨
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 상세 — 저장소 헤더(공통) + 진행률 + 서브탭(공통) + 업무 목록
|
||||
import { computed, ref, watch } from 'vue'
|
||||
// 세그먼트/검색은 백엔드에서 필터·카운트하고, 목록은 더보기(오프셋 페이지네이션)로 점진 로드한다.
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import RepoHeader from '@/components/RepoHeader.vue'
|
||||
@@ -8,14 +10,14 @@ import RepoTabs from '@/components/RepoTabs.vue'
|
||||
import GiteaRepoBar from '@/components/GiteaRepoBar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import type { TaskListQuery, TaskSegment } from '@/types/task'
|
||||
import type { Repo, TaskStatus } from '@/mock/relay.mock'
|
||||
|
||||
type Segment = 'all' | 'prog' | 'todo' | 'done'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
const taskStore = useTaskStore()
|
||||
const { tasks, counts, hasMore, loadingMore, loading } = storeToRefs(taskStore)
|
||||
|
||||
// 저장소 헤더/진행률 — API 연동
|
||||
const repo = ref<Repo | null>(null)
|
||||
@@ -27,14 +29,34 @@ async function loadRepo() {
|
||||
}
|
||||
}
|
||||
|
||||
// 업무 목록 — API 연동
|
||||
const keyword = ref('')
|
||||
const segment = ref<TaskSegment>('all')
|
||||
|
||||
// 현재 세그먼트/검색 조건 → 목록 쿼리
|
||||
function currentQuery(): TaskListQuery {
|
||||
const q = keyword.value.trim()
|
||||
return { segment: segment.value, ...(q ? { q } : {}) }
|
||||
}
|
||||
|
||||
// 업무 목록 첫 페이지 — 세그먼트/검색 변경 시 리셋 로드
|
||||
async function loadTasks() {
|
||||
try {
|
||||
await taskStore.fetchList(repoId.value)
|
||||
await taskStore.fetchList(repoId.value, currentQuery())
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 더보기 — 같은 조건으로 다음 페이지 이어붙이기
|
||||
async function loadMoreTasks() {
|
||||
try {
|
||||
await taskStore.loadMore(repoId.value, currentQuery())
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 전환 시 헤더 + 목록 리셋 로드
|
||||
watch(
|
||||
repoId,
|
||||
() => {
|
||||
@@ -44,37 +66,27 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const tasks = computed(() => taskStore.tasks)
|
||||
// 세그먼트 변경 시 목록만 리셋 로드(저장소 재조회 불필요)
|
||||
watch(segment, loadTasks)
|
||||
|
||||
// 검색어는 디바운스 후 리셋 로드(연속 입력 시 호출 폭주 방지)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(keyword, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadTasks, 250)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
})
|
||||
|
||||
const memberCount = computed(() =>
|
||||
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
|
||||
)
|
||||
|
||||
const keyword = ref('')
|
||||
const segment = ref<Segment>('all')
|
||||
// 빈 목록이 '검색/세그먼트로 걸러져 비었는지' 판별(빈 상태 문구 분기)
|
||||
const isFiltered = computed(() => keyword.value.trim() !== '' || segment.value !== 'all')
|
||||
|
||||
// 세그먼트별 분류 규칙
|
||||
function inSegment(status: TaskStatus, seg: Segment): boolean {
|
||||
if (seg === 'all') return true
|
||||
if (seg === 'prog') return status === 'prog' || status === 'review'
|
||||
if (seg === 'todo') return status === 'todo'
|
||||
return status === 'done'
|
||||
}
|
||||
|
||||
const counts = computed(() => ({
|
||||
all: tasks.value.length,
|
||||
prog: tasks.value.filter((t) => inSegment(t.status, 'prog')).length,
|
||||
todo: tasks.value.filter((t) => inSegment(t.status, 'todo')).length,
|
||||
done: tasks.value.filter((t) => inSegment(t.status, 'done')).length,
|
||||
}))
|
||||
|
||||
const filteredTasks = computed(() =>
|
||||
tasks.value.filter(
|
||||
(t) =>
|
||||
inSegment(t.status, segment.value) && (!keyword.value || t.title.includes(keyword.value)),
|
||||
),
|
||||
)
|
||||
|
||||
// 지연 건수
|
||||
// 지연 건수 — 현재 로드된 목록 기준(전역 정확 집계는 백엔드 미지원, 알려진 한계)
|
||||
const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
|
||||
// 업무 상태 → 배지 라벨
|
||||
@@ -234,23 +246,23 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
|
||||
<!-- 업무 목록 -->
|
||||
<div
|
||||
v-if="taskStore.loading && tasks.length === 0"
|
||||
v-if="loading && tasks.length === 0"
|
||||
class="state-msg"
|
||||
>
|
||||
업무를 불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredTasks.length === 0"
|
||||
v-else-if="tasks.length === 0"
|
||||
class="state-msg"
|
||||
>
|
||||
{{ tasks.length === 0 ? '아직 등록된 업무가 없습니다. 새 업무를 지시해 보세요.' : '조건에 맞는 업무가 없습니다.' }}
|
||||
{{ isFiltered ? '조건에 맞는 업무가 없습니다.' : '아직 등록된 업무가 없습니다. 새 업무를 지시해 보세요.' }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="list"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="task in filteredTasks"
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
:class="{ 'is-done': task.status === 'done' }"
|
||||
@@ -388,6 +400,17 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- 더보기 -->
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="loadMoreTasks()"
|
||||
>
|
||||
{{ loadingMore ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 저장소를 찾지 못한 경우 -->
|
||||
|
||||
@@ -103,9 +103,9 @@ router.beforeEach(async (to) => {
|
||||
await authStore.bootstrap()
|
||||
}
|
||||
|
||||
// 보호 라우트인데 미로그인 → 로그인 페이지로 (원래 목적지를 redirect 쿼리로 보존)
|
||||
// 보호 라우트인데 미로그인 → 로그인 페이지로 (로그인 후에는 항상 저장소 목록으로 진입)
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
return { name: 'login' }
|
||||
}
|
||||
|
||||
// 이미 로그인 상태에서 로그인/회원가입 진입 → 저장소 목록으로
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
|
||||
import { useMember } from '@/composables/useMember'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { avatarColor, initialOf } from '@/shared/utils/format'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
import type {
|
||||
ApiRepoMember,
|
||||
InviteMemberPayload,
|
||||
@@ -41,12 +42,21 @@ export const useMemberStore = defineStore('member', () => {
|
||||
return authStore.user?.id ?? null
|
||||
}
|
||||
|
||||
// 멤버 목록 조회
|
||||
// 멤버 목록 조회 — 백엔드는 페이지네이션이지만, 역할/검색 카운트와 담당자 후보 풀
|
||||
// (TaskCreatePage)이 전수 기준이어야 하므로 모든 페이지를 순회해 전체를 로드한다.
|
||||
// (멤버는 팀 규모로 한정적이라 전체 로드가 안전하다)
|
||||
async function fetchList(repoId: string): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const { items } = await memberApi.list(repoId)
|
||||
members.value = items.map((m) => toMemberView(m, currentUserId()))
|
||||
const acc: ApiRepoMember[] = []
|
||||
let page = 1
|
||||
for (;;) {
|
||||
const { items, total } = await memberApi.list(repoId, page, DEFAULT_PAGE_SIZE)
|
||||
acc.push(...items)
|
||||
if (items.length === 0 || acc.length >= total) break
|
||||
page += 1
|
||||
}
|
||||
members.value = acc.map((m) => toMemberView(m, currentUserId()))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useTask } from '@/composables/useTask'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
import {
|
||||
avatarColor,
|
||||
escapeHtml,
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
ApiTaskDetail,
|
||||
CreateTaskPayload,
|
||||
TaskListQuery,
|
||||
TaskSegmentCounts,
|
||||
UpdateTaskPayload,
|
||||
} from '@/types/task'
|
||||
import type { ApiActivity } from '@/types/activity'
|
||||
@@ -200,24 +202,51 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
|
||||
}
|
||||
|
||||
// 업무 스토어 — 저장소 업무 목록 + 현재 상세 + CRUD/상태 액션
|
||||
// 빈 세그먼트 카운트(초기값)
|
||||
const EMPTY_COUNTS: TaskSegmentCounts = { all: 0, prog: 0, todo: 0, done: 0 }
|
||||
|
||||
export const useTaskStore = defineStore('task', () => {
|
||||
const tasks = ref<RepoTaskView[]>([])
|
||||
const current = ref<TaskDetailView | null>(null)
|
||||
const loading = ref(false)
|
||||
// 목록 페이지네이션(더보기) + 세그먼트 카운트 상태
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const counts = ref<TaskSegmentCounts>({ ...EMPTY_COUNTS })
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = computed(() => tasks.value.length < total.value)
|
||||
|
||||
const taskApi = useTask()
|
||||
|
||||
// 목록 조회
|
||||
// 목록 첫 페이지 조회(리셋) — 세그먼트/검색은 백엔드에서 필터, 카운트도 응답에서 수신
|
||||
async function fetchList(repoId: string, query: TaskListQuery = {}): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const { items } = await taskApi.list(repoId, query)
|
||||
tasks.value = items.map(toRepoTaskView)
|
||||
const res = await taskApi.list(repoId, query, 1, DEFAULT_PAGE_SIZE)
|
||||
tasks.value = res.items.map(toRepoTaskView)
|
||||
total.value = res.total
|
||||
counts.value = res.counts
|
||||
page.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 페이지 이어붙이기(더보기) — 같은 세그먼트/검색 조건 유지
|
||||
async function loadMore(repoId: string, query: TaskListQuery = {}): Promise<void> {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const res = await taskApi.list(repoId, query, page.value + 1, DEFAULT_PAGE_SIZE)
|
||||
tasks.value.push(...res.items.map(toRepoTaskView))
|
||||
total.value = res.total
|
||||
counts.value = res.counts
|
||||
page.value += 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 단건 상세 조회
|
||||
async function fetchOne(repoId: string, taskId: number): Promise<TaskDetailView | null> {
|
||||
loading.value = true
|
||||
@@ -247,10 +276,12 @@ export const useTaskStore = defineStore('task', () => {
|
||||
return view
|
||||
}
|
||||
|
||||
// 삭제
|
||||
// 삭제 — 목록에서 제외하고 총계도 보정(세그먼트 카운트는 목록 재진입 시 재조회로 갱신)
|
||||
async function remove(repoId: string, taskId: number): Promise<void> {
|
||||
await taskApi.remove(repoId, taskId)
|
||||
const before = tasks.value.length
|
||||
tasks.value = tasks.value.filter((t) => t.id !== taskId)
|
||||
if (tasks.value.length < before) total.value = Math.max(0, total.value - 1)
|
||||
if (current.value?.id === taskId) current.value = null
|
||||
}
|
||||
|
||||
@@ -345,7 +376,12 @@ export const useTaskStore = defineStore('task', () => {
|
||||
tasks,
|
||||
current,
|
||||
loading,
|
||||
total,
|
||||
counts,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
fetchList,
|
||||
loadMore,
|
||||
fetchOne,
|
||||
create,
|
||||
update,
|
||||
|
||||
@@ -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 // 클릭 시 이동 경로
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { TaskStatus } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
import type { ApiActivity } from '@/types/activity'
|
||||
import type { Paginated } from '@/types/pagination'
|
||||
|
||||
// 업무 목록 행(API 원시) — 백엔드 RepoTaskResponse 와 1:1
|
||||
export interface ApiRepoTask {
|
||||
@@ -79,12 +80,29 @@ export interface ApiTaskDetail {
|
||||
changesNote: string | null
|
||||
}
|
||||
|
||||
// 업무 목록 필터
|
||||
// 업무 목록 세그먼트(상태 묶음) — 진행 중 = prog+review
|
||||
export type TaskSegment = 'all' | 'prog' | 'todo' | 'done'
|
||||
|
||||
// 세그먼트별 카운트(검색어 반영) — RepoDetailPage 세그먼트 배지용. 백엔드 TaskSegmentCounts 와 1:1
|
||||
export interface TaskSegmentCounts {
|
||||
all: number
|
||||
prog: number
|
||||
todo: number
|
||||
done: number
|
||||
}
|
||||
|
||||
// 업무 목록 필터 — 세그먼트/검색/담당자 (백엔드 list 쿼리와 1:1)
|
||||
export interface TaskListQuery {
|
||||
status?: TaskStatus
|
||||
segment?: TaskSegment
|
||||
q?: string
|
||||
assignee?: string
|
||||
}
|
||||
|
||||
// 업무 목록 응답 — 페이지네이션 + 세그먼트 카운트 동봉 (백엔드 RepoTaskListResult 와 1:1)
|
||||
export type ApiRepoTaskListResult = Paginated<ApiRepoTask> & {
|
||||
counts: TaskSegmentCounts
|
||||
}
|
||||
|
||||
// 업무 생성 요청
|
||||
export interface CreateTaskPayload {
|
||||
title: string
|
||||
|
||||
Reference in New Issue
Block a user