Skip to content

Commit 7c851a5

Browse files
authored
feat(notion): v2 노션 연동 토대 + 회의 유형 도입 (#14)
1 parent 6022f39 commit 7c851a5

32 files changed

Lines changed: 542 additions & 6 deletions

‎apps/backend/.env.template‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,15 @@ ADMIN_API_TOKEN=
5959

6060
# ffmpeg 바이너리 경로. PATH 에 ffmpeg 가 있으면 설정 불필요.
6161
# FFMPEG_BIN=ffmpeg
62+
63+
# ===== v2 노션 연동 (미설정 시 전체 dormant) =====
64+
65+
# 노션 integration 토큰. 비우면 노션 연동 전체 비활성(기존 web 회의/회의록엔 영향 없음).
66+
# 개발환경에서 연동을 테스트하려면 아래 주석을 풀고 값을 채운다.
67+
# NOTION_TOKEN=
68+
# 회의 자동 생성 폴링이 조회할 노션 이슈 DB ID들(콤마 구분). 팀/프로젝트 이슈 관리 DB가 여럿이라 복수.
69+
# 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
72+
# NOTION_BASE_URL=https://api.notion.com
73+
# NOTION_TIMEOUT_MS=30000

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isPrettyLoggingEnabled, resolveLogLevel } from '@/config/logger.config'
88
import { MediasoupModule } from '@/mediasoup/mediasoup.module';
99
import { MeetingModule } from '@/meeting/meeting.module';
1010
import { MongoModule } from '@/mongo/mongo.module';
11+
import { NotionModule } from '@/notion/notion.module';
1112
import { RecordingModule } from '@/recording/recording.module';
1213
import { RedisModule } from '@/redis/redis.module';
1314
import { ReportsModule } from '@/reports/reports.module';
@@ -51,6 +52,7 @@ import { SharedKernelModule } from '@/shared-kernel/shared-kernel.module';
5152
MediasoupModule,
5253
ReportsModule,
5354
RecordingModule,
55+
NotionModule,
5456
],
5557
providers: [{ provide: APP_FILTER, useClass: DomainExceptionFilter }],
5658
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
DEFAULT_NOTION_BASE_URL,
3+
DEFAULT_NOTION_TIMEOUT_MS,
4+
DEFAULT_NOTION_VERSION,
5+
resolveNotionConfig,
6+
} from './notion.config';
7+
8+
describe('resolveNotionConfig', () => {
9+
it('NOTION_TOKEN이 없으면 null을 돌려준다(노션 기능 dormant 신호)', () => {
10+
expect(resolveNotionConfig({})).toBeNull();
11+
expect(resolveNotionConfig({ NOTION_TOKEN: '' })).toBeNull();
12+
expect(resolveNotionConfig({ NOTION_TOKEN: ' ' })).toBeNull();
13+
});
14+
15+
it('NOTION_TOKEN만 있으면 version/baseUrl/timeout은 디폴트, databaseIds는 빈 배열', () => {
16+
expect(resolveNotionConfig({ NOTION_TOKEN: 't' })).toEqual({
17+
token: 't',
18+
version: DEFAULT_NOTION_VERSION,
19+
baseUrl: DEFAULT_NOTION_BASE_URL,
20+
timeoutMs: DEFAULT_NOTION_TIMEOUT_MS,
21+
databaseIds: [],
22+
});
23+
});
24+
25+
it('NOTION_VERSION / NOTION_BASE_URL / NOTION_TIMEOUT_MS를 적용한다', () => {
26+
expect(
27+
resolveNotionConfig({
28+
NOTION_TOKEN: 't',
29+
NOTION_VERSION: '2022-06-28',
30+
NOTION_BASE_URL: 'https://api.notion.com/',
31+
NOTION_TIMEOUT_MS: '15000',
32+
}),
33+
).toMatchObject({
34+
token: 't',
35+
version: '2022-06-28',
36+
baseUrl: 'https://api.notion.com',
37+
timeoutMs: 15000,
38+
});
39+
});
40+
41+
it('NOTION_DB_IDS는 콤마 구분 복수 DB(팀/프로젝트 이슈 관리)를 파싱한다', () => {
42+
expect(resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_DB_IDS: 'team-db' })?.databaseIds).toEqual(
43+
['team-db'],
44+
);
45+
expect(
46+
resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_DB_IDS: 'team-db, project-db , extra-db' })
47+
?.databaseIds,
48+
).toEqual(['team-db', 'project-db', 'extra-db']);
49+
});
50+
51+
it('NOTION_DB_IDS의 빈 항목/공백은 제거한다', () => {
52+
expect(
53+
resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_DB_IDS: 'team-db,, ,project-db,' })
54+
?.databaseIds,
55+
).toEqual(['team-db', 'project-db']);
56+
});
57+
58+
it('NOTION_TIMEOUT_MS가 양의 정수가 아니면 throw', () => {
59+
expect(() => resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_TIMEOUT_MS: '0' })).toThrow(
60+
/NOTION_TIMEOUT_MS/,
61+
);
62+
expect(() => resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_TIMEOUT_MS: '-5' })).toThrow(
63+
/NOTION_TIMEOUT_MS/,
64+
);
65+
expect(() => resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_TIMEOUT_MS: 'abc' })).toThrow(
66+
/NOTION_TIMEOUT_MS/,
67+
);
68+
expect(() => resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_TIMEOUT_MS: '1.5' })).toThrow(
69+
/NOTION_TIMEOUT_MS/,
70+
);
71+
});
72+
73+
it('NOTION_BASE_URL이 http(s) 스킴이 아니면 throw', () => {
74+
expect(() =>
75+
resolveNotionConfig({ NOTION_TOKEN: 't', NOTION_BASE_URL: 'ftp://x' }),
76+
).toThrow(/NOTION_BASE_URL/);
77+
});
78+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* 노션 REST API 어댑터의 환경변수 해석 모듈.
3+
*
4+
* - `NOTION_TOKEN`이 비어 있으면 노션 기능 dormant 신호로 `null` 반환(gate).
5+
* - version/baseUrl/timeout은 미설정 시 디폴트. `databaseIds`(폴링용)는 미설정 시 빈 배열.
6+
* 이슈 관리 DB는 여럿(팀/프로젝트)이라 `NOTION_DB_IDS`를 콤마 구분 복수로 받는다.
7+
*/
8+
9+
export const DEFAULT_NOTION_VERSION = '2026-03-11';
10+
export const DEFAULT_NOTION_BASE_URL = 'https://api.notion.com';
11+
export const DEFAULT_NOTION_TIMEOUT_MS = 30_000;
12+
13+
export interface NotionConfig {
14+
readonly token: string;
15+
readonly version: string;
16+
readonly baseUrl: string;
17+
readonly timeoutMs: number;
18+
readonly databaseIds: ReadonlyArray<string>;
19+
}
20+
21+
export function resolveNotionConfig(env: NodeJS.ProcessEnv = process.env): NotionConfig | null {
22+
const token = env.NOTION_TOKEN?.trim();
23+
if (token === undefined || token.length === 0) return null;
24+
25+
const versionRaw = env.NOTION_VERSION?.trim();
26+
const baseUrlRaw = env.NOTION_BASE_URL?.trim();
27+
const timeoutRaw = env.NOTION_TIMEOUT_MS?.trim();
28+
const databaseIds = (env.NOTION_DB_IDS ?? '')
29+
.split(',')
30+
.map((id) => id.trim())
31+
.filter((id) => id.length > 0);
32+
33+
let timeoutMs = DEFAULT_NOTION_TIMEOUT_MS;
34+
if (timeoutRaw !== undefined && timeoutRaw.length > 0) {
35+
const parsed = Number(timeoutRaw);
36+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) {
37+
throw new Error(`NOTION_TIMEOUT_MS must be a positive integer (ms): "${timeoutRaw}"`);
38+
}
39+
timeoutMs = parsed;
40+
}
41+
42+
let baseUrl = DEFAULT_NOTION_BASE_URL;
43+
if (baseUrlRaw !== undefined && baseUrlRaw.length > 0) {
44+
if (!/^https?:\/\//.test(baseUrlRaw)) {
45+
throw new Error(`NOTION_BASE_URL must use the http:// or https:// scheme: "${baseUrlRaw}"`);
46+
}
47+
baseUrl = baseUrlRaw.replace(/\/+$/, '');
48+
}
49+
50+
return {
51+
token,
52+
version: versionRaw === undefined || versionRaw.length === 0 ? DEFAULT_NOTION_VERSION : versionRaw,
53+
baseUrl,
54+
timeoutMs,
55+
databaseIds,
56+
};
57+
}

‎apps/backend/src/meeting/application/meeting.service.spec.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,18 @@ describe('MeetingService.createMeeting', () => {
139139
});
140140
expect(result.hostToken).toBe('host-token-generated');
141141
});
142+
143+
it('meetingType을 지정하면 Meeting에 싣고, 미지정 시 general로 채운다', async () => {
144+
const { service } = makeService();
145+
const retro = await service.createMeeting({
146+
source: 'notion-issue',
147+
meetingType: 'retrospective',
148+
externalReference: externalReference(),
149+
});
150+
expect(retro.meetingType).toBe('retrospective');
151+
const def = await service.createMeeting({ source: 'web', externalReference: externalReference() });
152+
expect(def.meetingType).toBe('general');
153+
});
142154
});
143155

144156
describe('MeetingService.joinMeeting', () => {
@@ -422,6 +434,7 @@ describe('MeetingService.closeMeeting', () => {
422434
payload: {
423435
code: 'abc12xyz',
424436
source: 'web',
437+
meetingType: 'general',
425438
externalReference: NO_EXTERNAL_REFERENCE,
426439
startedAt: t0,
427440
endedAt: tClose,
@@ -565,6 +578,7 @@ describe('MeetingService.detectIdleAndClose', () => {
565578
payload: {
566579
code: 'abc12xyz',
567580
source: 'web',
581+
meetingType: 'general',
568582
externalReference: NO_EXTERNAL_REFERENCE,
569583
startedAt: t0,
570584
endedAt: tIdleElapsed,

‎apps/backend/src/meeting/application/meeting.service.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ChatEntry,
1717
chatEntry,
1818
ExternalReference,
19+
MeetingType,
1920
Source,
2021
} from '@/shared-kernel/domain/value-objects';
2122

@@ -31,6 +32,7 @@ interface MeetingServiceDeps {
3132

3233
interface CreateMeetingCommand {
3334
source: Source;
35+
meetingType?: MeetingType;
3436
externalReference: ExternalReference;
3537
title?: string | null;
3638
}
@@ -83,6 +85,7 @@ export class MeetingService {
8385
const meeting = Meeting.create({
8486
code,
8587
source: command.source,
88+
meetingType: command.meetingType,
8689
externalReference: command.externalReference,
8790
idleTimeout: IdleTimeout.default(),
8891
startedAt,
@@ -203,6 +206,7 @@ export class MeetingService {
203206
return {
204207
code,
205208
source: snapshot.source,
209+
meetingType: snapshot.meetingType,
206210
externalReference: snapshot.externalReference,
207211
startedAt: snapshot.startedAt,
208212
endedAt,

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import { ExternalReference, Source } from '@/shared-kernel/domain/value-objects';
1+
import {
2+
DEFAULT_MEETING_TYPE,
3+
ExternalReference,
4+
MeetingType,
5+
Source,
6+
} from '@/shared-kernel/domain/value-objects';
27

38
import { Participant, ParticipantSnapshot } from './participant';
49
import { IdleTimeout, MeetingCode } from './value-objects';
510

611
export interface MeetingSnapshot {
712
readonly code: string;
813
readonly source: Source;
14+
readonly meetingType: MeetingType;
915
readonly externalReference: ExternalReference;
1016
readonly idleTimeoutMs: number;
1117
readonly startedAt: Date;
@@ -19,6 +25,7 @@ export interface MeetingSnapshot {
1925
interface CreateMeetingInput {
2026
code: MeetingCode;
2127
source: Source;
28+
meetingType?: MeetingType;
2229
externalReference: ExternalReference;
2330
idleTimeout: IdleTimeout;
2431
startedAt: Date;
@@ -42,6 +49,7 @@ export class Meeting {
4249
private constructor(
4350
public readonly code: MeetingCode,
4451
public readonly source: Source,
52+
public readonly meetingType: MeetingType,
4553
public readonly externalReference: ExternalReference,
4654
public readonly idleTimeout: IdleTimeout,
4755
public readonly startedAt: Date,
@@ -55,6 +63,7 @@ export class Meeting {
5563
return new Meeting(
5664
input.code,
5765
input.source,
66+
input.meetingType ?? DEFAULT_MEETING_TYPE,
5867
input.externalReference,
5968
input.idleTimeout,
6069
input.startedAt,
@@ -74,6 +83,7 @@ export class Meeting {
7483
const meeting = new Meeting(
7584
MeetingCode.from(snapshot.code),
7685
snapshot.source,
86+
snapshot.meetingType,
7787
snapshot.externalReference,
7888
IdleTimeout.of(snapshot.idleTimeoutMs),
7989
snapshot.startedAt,
@@ -183,6 +193,7 @@ export class Meeting {
183193
return {
184194
code: this.code.value,
185195
source: this.source,
196+
meetingType: this.meetingType,
186197
externalReference: this.externalReference,
187198
idleTimeoutMs: this.idleTimeout.milliseconds,
188199
startedAt: this.startedAt,

‎apps/backend/src/meeting/infrastructure/redis-meeting.repository.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Redis from 'ioredis';
44
import { Meeting, MeetingSnapshot } from '@/meeting/domain/meeting';
55
import { ParticipantSnapshot } from '@/meeting/domain/participant';
66
import { MeetingRepository } from '@/meeting/domain/ports';
7-
import { ExternalReference, Source } from '@/shared-kernel/domain/value-objects';
7+
import { ExternalReference, MeetingType, Source } from '@/shared-kernel/domain/value-objects';
88

99
const KEY_PREFIX = 'meeting:';
1010

@@ -24,6 +24,7 @@ interface ParticipantWire {
2424
interface MeetingWire {
2525
readonly code: string;
2626
readonly source: Source;
27+
readonly meetingType: MeetingType;
2728
readonly externalReference: ExternalReference;
2829
readonly idleTimeoutMs: number;
2930
readonly startedAt: string;
@@ -67,6 +68,7 @@ export class RedisMeetingRepository implements MeetingRepository {
6768
return {
6869
code: snapshot.code,
6970
source: snapshot.source,
71+
meetingType: snapshot.meetingType,
7072
externalReference: snapshot.externalReference,
7173
idleTimeoutMs: snapshot.idleTimeoutMs,
7274
startedAt: snapshot.startedAt.toISOString(),
@@ -89,6 +91,7 @@ export class RedisMeetingRepository implements MeetingRepository {
8991
return {
9092
code: wire.code,
9193
source: wire.source,
94+
meetingType: wire.meetingType,
9295
externalReference: wire.externalReference,
9396
idleTimeoutMs: wire.idleTimeoutMs,
9497
startedAt: new Date(wire.startedAt),

‎apps/backend/src/meeting/interface/controllers/meeting.controller.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export class MeetingController {
2929
const ref = externalReference({ issueId: dto.externalReference?.issueId });
3030
const meeting = await this.service.createMeeting({
3131
source: dto.source,
32+
meetingType: dto.meetingType,
3233
externalReference: ref,
3334
title: dto.title ?? null,
3435
});

‎apps/backend/src/meeting/interface/dto/create-meeting.dto.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { type CreateMeetingRequest, type Source, SOURCES } from '@convene/shared-interfaces';
1+
import {
2+
type CreateMeetingRequest,
3+
MEETING_TYPES,
4+
type MeetingType,
5+
type Source,
6+
SOURCES,
7+
} from '@convene/shared-interfaces';
28
import { Type } from 'class-transformer';
39
import { IsIn, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
410

@@ -10,6 +16,10 @@ export class CreateMeetingDto implements CreateMeetingRequest {
1016
@IsIn(SOURCES as readonly string[])
1117
source!: Source;
1218

19+
@IsOptional()
20+
@IsIn(MEETING_TYPES as readonly string[])
21+
meetingType?: MeetingType;
22+
1323
@IsOptional()
1424
@ValidateNested()
1525
@Type(() => ExternalReferenceDto)

0 commit comments

Comments
 (0)