Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { REPORT_EVENTS } from '@convene/shared-interfaces';

import { participantEntry } from '@/reports/domain/entries/participant-entry';
import { transcriptSegment } from '@/reports/domain/entries/transcript-segment';
import {
Page,
ReportListCriteria,
reportListCriteria,
} from '@/reports/domain/value-objects/report-list-query';
import { chatEntry } from '@/shared-kernel/domain/value-objects/chat-entry';
import { externalReference, NO_EXTERNAL_REFERENCE } from '@/shared-kernel/domain/value-objects/external-reference';
import { ReportSummary, reportSummary } from '@/shared-kernel/domain/value-objects/report-summary';
Expand Down Expand Up @@ -62,7 +67,7 @@ describe('ReportFinalizationService.createDraft', () => {
},
findById: async () => null,
findByMeetingId: async () => null,
listRecent: async () => [],
findPage: async () => ({ items: [], totalItems: 0 }),
},
summarizer,
{ now: () => endedAt },
Expand Down Expand Up @@ -212,7 +217,7 @@ describe('ReportFinalizationService.completeTranscription', () => {
},
findById: async (id) => store.get(id) ?? null,
findByMeetingId: async () => null,
listRecent: async () => [],
findPage: async () => ({ items: [], totalItems: 0 }),
},
summarizer,
{ now: () => failedAt },
Expand Down Expand Up @@ -349,7 +354,7 @@ describe('ReportFinalizationService.resummarize', () => {
},
findById: async (id) => store.get(id) ?? null,
findByMeetingId: async () => null,
listRecent: async () => [],
findPage: async () => ({ items: [], totalItems: 0 }),
},
summarizer,
{ now: () => now },
Expand Down Expand Up @@ -464,7 +469,7 @@ describe('ReportFinalizationService.resummarize', () => {
});
});

describe('ReportFinalizationService.listRecent', () => {
describe('ReportFinalizationService.listPage', () => {
const startedAt = new Date('2026-01-01T00:00:00Z');

const makeReport = (id: string, mid: string, durationMs: number) =>
Expand All @@ -482,37 +487,40 @@ describe('ReportFinalizationService.listRecent', () => {
});

const makeService = () => {
const repoListMock = jest.fn<Promise<MeetingReport[]>, [number]>(async () => []);
const repoPageMock = jest.fn<Promise<Page<MeetingReport>>, [ReportListCriteria]>(async () => ({
items: [],
totalItems: 0,
}));
const { publisher } = makeEventPublisher();
const service = new ReportFinalizationService(
{
save: async () => {},
findById: async () => null,
findByMeetingId: async () => null,
listRecent: repoListMock,
findPage: repoPageMock,
},
noopSummarizer(),
{ now: () => startedAt },
publisher,
noopLogger(),
);
return { service, repoListMock };
return { service, repoPageMock };
};

it('Repository.listRecent에 인자로 받은 limit을 그대로 위임한다', async () => {
const { service, repoListMock } = makeService();
repoListMock.mockResolvedValueOnce([]);
await service.listRecent(7);
expect(repoListMock).toHaveBeenCalledWith(7);
it('Repository.findPage에 criteria를 그대로 위임한다', async () => {
const { service, repoPageMock } = makeService();
const criteria = reportListCriteria({ page: 2, size: 7, sort: 'latest' });
await service.listPage(criteria);
expect(repoPageMock).toHaveBeenCalledWith(criteria);
});

it('Repository가 돌려준 MeetingReport 배열을 그대로 반환한다', async () => {
const { service, repoListMock } = makeService();
it('Repository가 돌려준 페이지를 그대로 반환한다', async () => {
const { service, repoPageMock } = makeService();
const a = makeReport('r1', 'mtg-1', 10 * 60_000);
const b = makeReport('r2', 'mtg-2', 20 * 60_000);
repoListMock.mockResolvedValueOnce([b, a]);
const result = await service.listRecent(5);
expect(result).toEqual([b, a]);
repoPageMock.mockResolvedValueOnce({ items: [b, a], totalItems: 2 });
const result = await service.listPage(reportListCriteria({ page: 1, size: 5, sort: 'latest' }));
expect(result).toEqual({ items: [b, a], totalItems: 2 });
});
});

Expand Down Expand Up @@ -541,7 +549,7 @@ describe('ReportFinalizationService.getById', () => {
save: async () => {},
findById: async (id) => (stored && stored.id === id ? stored : null),
findByMeetingId: async () => null,
listRecent: async () => [],
findPage: async () => ({ items: [], totalItems: 0 }),
},
noopSummarizer(),
{ now: () => endedAt },
Expand Down Expand Up @@ -595,7 +603,7 @@ describe('ReportFinalizationService.failTranscription', () => {
},
findById: async (id) => store.get(id) ?? null,
findByMeetingId: async () => null,
listRecent: async () => [],
findPage: async () => ({ items: [], totalItems: 0 }),
},
summarizer,
{ now: () => failedAt },
Expand Down Expand Up @@ -682,7 +690,7 @@ describe('ReportFinalizationService.recordNotionPush', () => {
},
findById: async (id) => store.get(id) ?? null,
findByMeetingId: async () => null,
listRecent: async () => [],
findPage: async () => ({ items: [], totalItems: 0 }),
},
noopSummarizer(),
{ now: () => endedAt },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MeetingReport } from '@/reports/domain/meeting-report';
import { REPORT_REPOSITORY, ReportRepository } from '@/reports/domain/ports/report.repository';
import { SUMMARIZER, SummarizerPort } from '@/reports/domain/ports/summarizer.port';
import { notionPushResult } from '@/reports/domain/value-objects/notion-push-result';
import { Page, ReportListCriteria } from '@/reports/domain/value-objects/report-list-query';
import { ChatEntry } from '@/shared-kernel/domain/value-objects/chat-entry';
import { ExternalReference } from '@/shared-kernel/domain/value-objects/external-reference';
import { MeetingType } from '@/shared-kernel/domain/value-objects/meeting-type';
Expand Down Expand Up @@ -187,8 +188,8 @@ export class ReportFinalizationService {
return report;
}

async listRecent(limit: number): Promise<MeetingReport[]> {
return this.repository.listRecent(limit);
async listPage(criteria: ReportListCriteria): Promise<Page<MeetingReport>> {
return this.repository.findPage(criteria);
}

async getById(reportId: string): Promise<MeetingReport> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const makeAdapter = (report: MeetingReport | null) => {
save: async () => {},
findById: async (id) => (report !== null && report.id === id ? report : null),
findByMeetingId: async () => null,
listRecent: async () => [],
findPage: async () => ({ items: [], totalItems: 0 }),
};
return new ReportLookupService(repository);
};
Expand Down
7 changes: 4 additions & 3 deletions apps/backend/src/reports/domain/ports/report.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { MeetingReport } from '../meeting-report';
import { Page, ReportListCriteria } from '../value-objects/report-list-query';

export const REPORT_REPOSITORY = Symbol('REPORT_REPOSITORY');

Expand All @@ -10,8 +11,8 @@ export const REPORT_REPOSITORY = Symbol('REPORT_REPOSITORY');
* - `id` 회의록 도큐먼트 id (Application이 생성, MongoDB ObjectId 문자열 호환).
* - `meetingId` 원본 Meeting Aggregate id. 회의 1건당 회의록 1건 보장.
*
* 정렬:
* - `listRecent` 회의록 목록 페이지에서 최신순 노출. v1엔 단순 limit만.
* 목록 조회:
* - `findPage` 정렬·페이지 조건은 `ReportListCriteria`가 통째로 나른다.
*/
export interface ReportRepository {
save(report: MeetingReport): Promise<void>;
Expand All @@ -20,5 +21,5 @@ export interface ReportRepository {

findByMeetingId(meetingId: string): Promise<MeetingReport | null>;

listRecent(limit: number): Promise<MeetingReport[]>;
findPage(criteria: ReportListCriteria): Promise<Page<MeetingReport>>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
DEFAULT_REPORT_SORT,
MAX_REPORT_PAGE_SIZE,
REPORT_SORT_OPTIONS,
} from '@convene/shared-interfaces';

import { REPORT_SORT_SPECS, reportListCriteria } from './report-list-query';

describe('reportListCriteria', () => {
it('page/size/sort를 담고 offset을 파생한다', () => {
expect(reportListCriteria({ page: 3, size: 20, sort: 'latest' })).toEqual({
page: 3,
size: 20,
sort: 'latest',
offset: 40,
});
});

it('첫 페이지의 offset은 0이다', () => {
expect(reportListCriteria({ page: 1, size: 20, sort: DEFAULT_REPORT_SORT }).offset).toBe(0);
});

it('page는 1 이상의 정수여야 한다', () => {
expect(() => reportListCriteria({ page: 0, size: 20, sort: DEFAULT_REPORT_SORT })).toThrow();
expect(() => reportListCriteria({ page: -1, size: 20, sort: DEFAULT_REPORT_SORT })).toThrow();
expect(() => reportListCriteria({ page: 1.5, size: 20, sort: DEFAULT_REPORT_SORT })).toThrow();
});

it('size는 1~MAX 범위의 정수여야 한다', () => {
expect(() => reportListCriteria({ page: 1, size: 0, sort: DEFAULT_REPORT_SORT })).toThrow();
expect(() =>
reportListCriteria({ page: 1, size: MAX_REPORT_PAGE_SIZE + 1, sort: DEFAULT_REPORT_SORT }),
).toThrow();
expect(() => reportListCriteria({ page: 1, size: 2.5, sort: DEFAULT_REPORT_SORT })).toThrow();
});
});

describe('REPORT_SORT_SPECS', () => {
it('latest는 endedAt 내림차순이다', () => {
expect(REPORT_SORT_SPECS.latest).toEqual({ field: 'endedAt', direction: 'desc' });
});

it('모든 정렬 프리셋이 spec을 갖는다', () => {
for (const option of REPORT_SORT_OPTIONS) {
expect(REPORT_SORT_SPECS[option]).toBeDefined();
}
});
});
48 changes: 48 additions & 0 deletions apps/backend/src/reports/domain/value-objects/report-list-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { MAX_REPORT_PAGE_SIZE, ReportSortOption } from '@convene/shared-interfaces';

/** 정렬 프리셋이 실제로 어떤 필드·방향인지. 정렬 축이 늘어나면 이 표에만 더한다. */
export interface ReportSortSpec {
readonly field: 'endedAt';
readonly direction: 'asc' | 'desc';
}

export const REPORT_SORT_SPECS: Record<ReportSortOption, ReportSortSpec> = {
latest: { field: 'endedAt', direction: 'desc' },
};

/** 목록 한 페이지. `totalItems`는 전체 페이지 수 계산에 쓴다. */
export interface Page<T> {
readonly items: ReadonlyArray<T>;
readonly totalItems: number;
}

/**
* 회의록 목록 조회 조건.
*
* 검색이 생기면 이 타입에 조건을 더하고 Repository가 그것까지 해석한다.
*/
export interface ReportListCriteria {
/** 1-based. */
readonly page: number;
readonly size: number;
readonly sort: ReportSortOption;
/** 건너뛸 문서 수. page/size에서 파생된다. */
readonly offset: number;
}

export function reportListCriteria(input: {
page: number;
size: number;
sort: ReportSortOption;
}): ReportListCriteria {
const { page, size, sort } = input;
if (!Number.isInteger(page) || page < 1) {
throw new Error(`ReportListCriteria.page must be an integer >= 1, got ${page}`);
}
if (!Number.isInteger(size) || size < 1 || size > MAX_REPORT_PAGE_SIZE) {
throw new Error(
`ReportListCriteria.size must be an integer in 1~${MAX_REPORT_PAGE_SIZE}, got ${size}`,
);
}
return { page, size, sort, offset: (page - 1) * size };
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Schema } from 'mongoose';
*
* `_id`는 ObjectId가 아니라 `MeetingReport.id`를 그대로 저장한다(UUID string).
* `meetingId`는 회의 1건당 회의록 1건 보장을 위해 unique index를 둔다.
* `endedAt`은 `listRecent`의 정렬 키이므로 descending 인덱스를 함께 둔다.
* `endedAt`은 기본 정렬(latest)의 키이므로 descending 인덱스를 함께 둔다.
*/

export const MEETING_REPORT_COLLECTION = 'meeting_reports';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { participantEntry } from '@/reports/domain/entries/participant-entry';
import { transcriptSegment } from '@/reports/domain/entries/transcript-segment';
import { MeetingReport } from '@/reports/domain/meeting-report';
import { notionPushResult } from '@/reports/domain/value-objects/notion-push-result';
import { reportListCriteria } from '@/reports/domain/value-objects/report-list-query';
import { externalReference, NO_EXTERNAL_REFERENCE } from '@/shared-kernel/domain/value-objects/external-reference';
import { reportSummary } from '@/shared-kernel/domain/value-objects/report-summary';

Expand Down Expand Up @@ -90,22 +91,28 @@ describe('MongoReportRepository', () => {
expect(found!.pipeline.sttStatus).toBe('done');
});

it('listRecent는 endedAt 내림차순으로 limit 만큼 반환한다', async () => {
it('findPage는 latest 정렬로 페이지를 잘라 전체 개수와 함께 반환한다', async () => {
const r1 = makeReport('r1', 'mtg-1', 10 * 60_000);
const r2 = makeReport('r2', 'mtg-2', 30 * 60_000);
const r3 = makeReport('r3', 'mtg-3', 20 * 60_000);
await repo.save(r1);
await repo.save(r2);
await repo.save(r3);
const top2 = await repo.listRecent(2);
expect(top2.map((r) => r.id)).toEqual(['r2', 'r3']);
const all = await repo.listRecent(10);
expect(all.map((r) => r.id)).toEqual(['r2', 'r3', 'r1']);
expect(await repo.listRecent(0)).toEqual([]);

const first = await repo.findPage(reportListCriteria({ page: 1, size: 2, sort: 'latest' }));
expect(first.items.map((r) => r.id)).toEqual(['r2', 'r3']);
expect(first.totalItems).toBe(3);

const second = await repo.findPage(reportListCriteria({ page: 2, size: 2, sort: 'latest' }));
expect(second.items.map((r) => r.id)).toEqual(['r1']);
expect(second.totalItems).toBe(3);
});

it('limit이 음수면 throw', async () => {
await expect(repo.listRecent(-1)).rejects.toThrow(/non-negative/);
it('범위를 벗어난 page는 빈 목록이지만 전체 개수는 그대로다', async () => {
await repo.save(makeReport('r1', 'mtg-1'));
const page = await repo.findPage(reportListCriteria({ page: 5, size: 20, sort: 'latest' }));
expect(page.items).toEqual([]);
expect(page.totalItems).toBe(1);
});

it('완료 상태(transcript + summary + notion push)가 round-trip 된다', async () => {
Expand Down
36 changes: 24 additions & 12 deletions apps/backend/src/reports/infrastructure/mongo-report.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { Connection, Model } from 'mongoose';
import { MeetingReport, MeetingReportSnapshot } from '@/reports/domain/meeting-report';
import { ReportRepository } from '@/reports/domain/ports/report.repository';
import { PipelineState } from '@/reports/domain/value-objects/pipeline-state';
import {
Page,
REPORT_SORT_SPECS,
ReportListCriteria,
} from '@/reports/domain/value-objects/report-list-query';

import { MEETING_REPORT_COLLECTION, meetingReportSchema } from './meeting-report.schema';

Expand Down Expand Up @@ -40,7 +45,7 @@ interface MeetingReportDoc {
*
* - `_id` = `MeetingReport.id` (UUID string), `meetingId`는 unique index.
* - `save`는 `replaceOne(upsert)`으로 멱등 처리.
* - `listRecent`는 `endedAt -1` 인덱스를 활용.
* - `findPage`는 정렬 spec이 가리키는 인덱스(latest = `endedAt -1`)를 활용한다.
*/
@Injectable()
export class MongoReportRepository implements ReportRepository {
Expand Down Expand Up @@ -75,17 +80,24 @@ export class MongoReportRepository implements ReportRepository {
return MeetingReport.fromSnapshot(this.fromWire(doc));
}

async listRecent(limit: number): Promise<MeetingReport[]> {
if (!Number.isInteger(limit) || limit < 0) {
throw new Error(`listRecent.limit must be a non-negative integer, got ${limit}`);
}
if (limit === 0) return [];
const docs = await this.model
.find()
.sort({ endedAt: -1 })
.limit(limit)
.lean<MeetingReportDoc[]>();
return docs.map((d) => MeetingReport.fromSnapshot(this.fromWire(d)));
async findPage(criteria: ReportListCriteria): Promise<Page<MeetingReport>> {
const spec = REPORT_SORT_SPECS[criteria.sort];
const sort: Record<string, 1 | -1> = { [spec.field]: spec.direction === 'desc' ? -1 : 1 };
// 검색이 붙으면 criteria로 filter를 만들어 find/countDocuments에 함께 넘긴다.
const filter = {};
const [docs, totalItems] = await Promise.all([
this.model
.find(filter)
.sort(sort)
.skip(criteria.offset)
.limit(criteria.size)
.lean<MeetingReportDoc[]>(),
this.model.countDocuments(filter),
]);
return {
items: docs.map((d) => MeetingReport.fromSnapshot(this.fromWire(d))),
totalItems,
};
}

private toWire(snapshot: MeetingReportSnapshot): MeetingReportDoc {
Expand Down
Loading
Loading