Skip to content

Commit 898521c

Browse files
authored
feat(notion): 노션 이슈 기반 회의 생성(즉시·자동) + 링크 노출 (#15)
1 parent 7c851a5 commit 898521c

28 files changed

Lines changed: 1182 additions & 38 deletions

‎apps/backend/.env.template‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ ADMIN_API_TOKEN=
6767
# NOTION_TOKEN=
6868
# 회의 자동 생성 폴링이 조회할 노션 이슈 DB ID들(콤마 구분). 팀/프로젝트 이슈 관리 DB가 여럿이라 복수.
6969
# NOTION_DB_IDS=team-db-id,project-db-id
70-
# 노션 API 상세 옵션. 기본 버전 2026-03-11 / base https://api.notion.com / 타임아웃 30000ms
71-
# NOTION_VERSION=2026-03-11
70+
# 즉시 버튼(GET /notion/meetings) HMAC 서명 키. 비우면 즉시 경로 컨트롤러 미등록.
71+
# NOTION_SIGNING_SECRET=
72+
# 자동 폴링 주기(cron, 초 포함 6필드). 기본 30분마다. 즉시성은 버튼 경로가 담당. 예: 0 */5 * * * *
73+
# NOTION_POLL_CRON=0 */30 * * * *
74+
# 노션 API 상세 옵션. 기본 버전 2025-09-03(멀티소스 DB/data_sources API) / base https://api.notion.com / 타임아웃 30000ms
75+
# NOTION_VERSION=2025-09-03
7276
# NOTION_BASE_URL=https://api.notion.com
7377
# NOTION_TIMEOUT_MS=30000

‎apps/backend/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@nestjs/event-emitter": "^3.0.0",
2222
"@nestjs/platform-express": "^11.1.20",
2323
"@nestjs/platform-socket.io": "^11.1.20",
24+
"@nestjs/schedule": "^6.1.3",
2425
"@nestjs/websockets": "^11.1.20",
2526
"class-transformer": "^0.5.1",
2627
"class-validator": "^0.14.1",

‎apps/backend/src/app.module.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
22
import { ConfigModule } from '@nestjs/config';
33
import { APP_FILTER } from '@nestjs/core';
44
import { EventEmitterModule } from '@nestjs/event-emitter';
5+
import { ScheduleModule } from '@nestjs/schedule';
56
import { LoggerModule } from 'nestjs-pino';
67

78
import { isPrettyLoggingEnabled, resolveLogLevel } from '@/config/logger.config';
@@ -45,14 +46,16 @@ import { SharedKernelModule } from '@/shared-kernel/shared-kernel.module';
4546
}),
4647
}),
4748
EventEmitterModule.forRoot({ wildcard: true, delimiter: '.' }),
49+
// @Cron 스케줄러 인프라는 루트에서 한 번만 초기화. notion 폴링 등 각 모듈의 @Cron을 자동 감지한다.
50+
ScheduleModule.forRoot(),
4851
SharedKernelModule,
4952
RedisModule,
5053
MongoModule,
5154
MeetingModule,
5255
MediasoupModule,
5356
ReportsModule,
5457
RecordingModule,
55-
NotionModule,
58+
NotionModule.register(),
5659
],
5760
providers: [{ provide: APP_FILTER, useClass: DomainExceptionFilter }],
5861
})

‎apps/backend/src/config/notion.config.spec.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {
22
DEFAULT_NOTION_BASE_URL,
3+
DEFAULT_NOTION_POLL_CRON,
34
DEFAULT_NOTION_TIMEOUT_MS,
45
DEFAULT_NOTION_VERSION,
56
resolveNotionConfig,
7+
resolveNotionPollCron,
68
} from './notion.config';
79

810
describe('resolveNotionConfig', () => {
@@ -12,27 +14,28 @@ describe('resolveNotionConfig', () => {
1214
expect(resolveNotionConfig({ NOTION_TOKEN: ' ' })).toBeNull();
1315
});
1416

15-
it('NOTION_TOKEN만 있으면 version/baseUrl/timeout은 디폴트, databaseIds는 빈 배열', () => {
17+
it('NOTION_TOKEN만 있으면 version/baseUrl/timeout은 디폴트, databaseIds는 빈 배열, 서명은 null', () => {
1618
expect(resolveNotionConfig({ NOTION_TOKEN: 't' })).toEqual({
1719
token: 't',
1820
version: DEFAULT_NOTION_VERSION,
1921
baseUrl: DEFAULT_NOTION_BASE_URL,
2022
timeoutMs: DEFAULT_NOTION_TIMEOUT_MS,
2123
databaseIds: [],
24+
signingSecret: null,
2225
});
2326
});
2427

2528
it('NOTION_VERSION / NOTION_BASE_URL / NOTION_TIMEOUT_MS를 적용한다', () => {
2629
expect(
2730
resolveNotionConfig({
2831
NOTION_TOKEN: 't',
29-
NOTION_VERSION: '2022-06-28',
32+
NOTION_VERSION: '2025-09-03',
3033
NOTION_BASE_URL: 'https://api.notion.com/',
3134
NOTION_TIMEOUT_MS: '15000',
3235
}),
3336
).toMatchObject({
3437
token: 't',
35-
version: '2022-06-28',
38+
version: '2025-09-03',
3639
baseUrl: 'https://api.notion.com',
3740
timeoutMs: 15000,
3841
});
@@ -75,4 +78,20 @@ describe('resolveNotionConfig', () => {
7578
resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_BASE_URL: 'ftp://x' }),
7679
).toThrow(/NOTION_BASE_URL/);
7780
});
81+
82+
it('NOTION_SIGNING_SECRET을 파싱한다(즉시 버튼 HMAC 검증용). 미설정/공백이면 null', () => {
83+
expect(resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_SIGNING_SECRET: 's3cr3t' })?.signingSecret).toBe(
84+
's3cr3t',
85+
);
86+
expect(resolveNotionConfig({ NOTION_TOKEN: 't' })?.signingSecret).toBeNull();
87+
expect(resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_SIGNING_SECRET: ' ' })?.signingSecret).toBeNull();
88+
});
89+
});
90+
91+
describe('resolveNotionPollCron', () => {
92+
it('기본은 30분마다, NOTION_POLL_CRON이 있으면 그 값을 쓴다', () => {
93+
expect(resolveNotionPollCron({})).toBe(DEFAULT_NOTION_POLL_CRON);
94+
expect(resolveNotionPollCron({ NOTION_POLL_CRON: '0 */5 * * * *' })).toBe('0 */5 * * * *');
95+
expect(resolveNotionPollCron({ NOTION_POLL_CRON: ' ' })).toBe(DEFAULT_NOTION_POLL_CRON);
96+
});
7897
});

‎apps/backend/src/config/notion.config.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,28 @@
44
* - `NOTION_TOKEN`이 비어 있으면 노션 기능 dormant 신호로 `null` 반환(gate).
55
* - version/baseUrl/timeout은 미설정 시 디폴트. `databaseIds`(폴링용)는 미설정 시 빈 배열.
66
* 이슈 관리 DB는 여럿(팀/프로젝트)이라 `NOTION_DB_IDS`를 콤마 구분 복수로 받는다.
7+
* - `signingSecret`(즉시 버튼 HMAC 검증)은 N-B 즉시 경로용. 없으면 컨트롤러 미등록.
78
*/
89

9-
export const DEFAULT_NOTION_VERSION = '2026-03-11';
10+
// 2025-09-03: 멀티소스 DB 도입. 조회는 databases/{id}/query가 아니라 data_sources/{id}/query.
11+
export const DEFAULT_NOTION_VERSION = '2025-09-03';
1012
export const DEFAULT_NOTION_BASE_URL = 'https://api.notion.com';
1113
export const DEFAULT_NOTION_TIMEOUT_MS = 30_000;
14+
// 자동 폴링 기본 주기(cron, 초 포함 6필드) = 30분마다. 즉시성은 버튼 경로가 담당.
15+
export const DEFAULT_NOTION_POLL_CRON = '0 */30 * * * *';
1216

1317
export interface NotionConfig {
1418
readonly token: string;
1519
readonly version: string;
1620
readonly baseUrl: string;
1721
readonly timeoutMs: number;
1822
readonly databaseIds: ReadonlyArray<string>;
23+
readonly signingSecret: string | null;
24+
}
25+
26+
export function resolveNotionPollCron(env: NodeJS.ProcessEnv = process.env): string {
27+
const raw = env.NOTION_POLL_CRON?.trim();
28+
return raw === undefined || raw.length === 0 ? DEFAULT_NOTION_POLL_CRON : raw;
1929
}
2030

2131
export function resolveNotionConfig(env: NodeJS.ProcessEnv = process.env): NotionConfig | null {
@@ -47,11 +57,16 @@ export function resolveNotionConfig(env: NodeJS.ProcessEnv = process.env): Notio
4757
baseUrl = baseUrlRaw.replace(/\/+$/, '');
4858
}
4959

60+
const signingSecretRaw = env.NOTION_SIGNING_SECRET?.trim();
61+
const signingSecret =
62+
signingSecretRaw === undefined || signingSecretRaw.length === 0 ? null : signingSecretRaw;
63+
5064
return {
5165
token,
5266
version: versionRaw === undefined || versionRaw.length === 0 ? DEFAULT_NOTION_VERSION : versionRaw,
5367
baseUrl,
5468
timeoutMs,
5569
databaseIds,
70+
signingSecret,
5671
};
5772
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { MeetingService } from '@/meeting/application/meeting.service';
2+
import { MeetingCreationAdapter } from '@/meeting/application/meeting-creation.adapter';
3+
import { CreateMeetingInput } from '@/shared-kernel/domain/ports';
4+
import { externalReference } from '@/shared-kernel/domain/value-objects';
5+
6+
interface FakeCreateResult {
7+
code: { value: string };
8+
hostToken: string;
9+
startedAt: Date;
10+
}
11+
12+
function fakeService(result: FakeCreateResult): {
13+
service: MeetingService;
14+
lastCommand: () => unknown;
15+
} {
16+
let captured: unknown;
17+
const service = {
18+
createMeeting: async (command: unknown): Promise<FakeCreateResult> => {
19+
captured = command;
20+
return result;
21+
},
22+
} as unknown as MeetingService;
23+
return { service, lastCommand: () => captured };
24+
}
25+
26+
describe('MeetingCreationAdapter', () => {
27+
it('MeetingService.createMeeting에 입력을 위임하고 결과를 primitive 스냅샷으로 변환한다', async () => {
28+
const startedAt = new Date('2026-07-20T00:00:00.000Z');
29+
const { service, lastCommand } = fakeService({
30+
code: { value: 'ABC123' },
31+
hostToken: 'host-tok',
32+
startedAt,
33+
});
34+
const ref = externalReference({ issueId: 'issue-1' });
35+
const input: CreateMeetingInput = {
36+
source: 'notion-issue',
37+
meetingType: 'general',
38+
externalReference: ref,
39+
title: '스프린트 회고',
40+
};
41+
42+
const result = await new MeetingCreationAdapter(service).create(input);
43+
44+
expect(result).toEqual({ code: 'ABC123', hostToken: 'host-tok', startedAt });
45+
expect(lastCommand()).toEqual({
46+
source: 'notion-issue',
47+
meetingType: 'general',
48+
externalReference: ref,
49+
title: '스프린트 회고',
50+
});
51+
});
52+
53+
it('title 미지정이면 null로 위임한다', async () => {
54+
const { service, lastCommand } = fakeService({
55+
code: { value: 'X' },
56+
hostToken: 'h',
57+
startedAt: new Date(),
58+
});
59+
60+
await new MeetingCreationAdapter(service).create({
61+
source: 'notion-issue',
62+
externalReference: {},
63+
});
64+
65+
expect((lastCommand() as { title: unknown }).title).toBeNull();
66+
});
67+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { MeetingService } from '@/meeting/application/meeting.service';
2+
import { CreatedMeeting, CreateMeetingInput, MeetingCreationPort } from '@/shared-kernel/domain/ports';
3+
4+
export class MeetingCreationAdapter implements MeetingCreationPort {
5+
constructor(private readonly service: MeetingService) {}
6+
7+
async create(input: CreateMeetingInput): Promise<CreatedMeeting> {
8+
const meeting = await this.service.createMeeting({
9+
source: input.source,
10+
meetingType: input.meetingType,
11+
externalReference: input.externalReference,
12+
title: input.title ?? null,
13+
});
14+
return {
15+
code: meeting.code.value,
16+
hostToken: meeting.hostToken,
17+
startedAt: meeting.startedAt,
18+
};
19+
}
20+
}

‎apps/backend/src/meeting/meeting.module.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
22
import { PinoLogger } from 'nestjs-pino';
33

44
import { MeetingService } from '@/meeting/application/meeting.service';
5+
import { MeetingCreationAdapter } from '@/meeting/application/meeting-creation.adapter';
56
import { RandomHostTokenGenerator } from '@/meeting/infrastructure/random-host-token.generator';
67
import { RandomMeetingCodeGenerator } from '@/meeting/infrastructure/random-meeting-code.generator';
78
import { RedisChatRepository } from '@/meeting/infrastructure/redis-chat.repository';
89
import { RedisMeetingRepository } from '@/meeting/infrastructure/redis-meeting.repository';
910
import { MeetingController } from '@/meeting/interface/controllers/meeting.controller';
1011
import { MeetingGateway } from '@/meeting/interface/gateways/meeting.gateway';
12+
import { MEETING_CREATION_PORT } from '@/shared-kernel/domain/ports';
1113
import { NestEventBusDomainEventPublisher } from '@/shared-kernel/infrastructure/nest-event-bus.publisher';
1214
import { PinoLoggerAdapter } from '@/shared-kernel/infrastructure/pino-logger.adapter';
1315
import { SystemClock } from '@/shared-kernel/infrastructure/system.clock';
@@ -58,6 +60,13 @@ import { SystemClock } from '@/shared-kernel/infrastructure/system.clock';
5860
PinoLogger,
5961
],
6062
},
63+
{
64+
// notion 등 다른 BC가 회의 생성을 호출하기 위한 Port. MeetingService에 위임한다.
65+
provide: MEETING_CREATION_PORT,
66+
useFactory: (service: MeetingService) => new MeetingCreationAdapter(service),
67+
inject: [MeetingService],
68+
},
6169
],
70+
exports: [MEETING_CREATION_PORT],
6271
})
6372
export class MeetingModule {}

0 commit comments

Comments
 (0)