feat: add meetings domain service

This commit is contained in:
Lucas Winther
2026-09-07 02:22:10 +02:00
parent 456c90adba
commit 8c3db68307
2 changed files with 778 additions and 0 deletions
+469
View File
@@ -0,0 +1,469 @@
/**
* Meetings Domain Service.
* Provides functions to list, inspect, and deep-search OpenProject meetings and agenda items.
*/
import type { OpenProjectClient } from "../client/api-client.ts";
import { extractIdFromHref, extractRawText } from "../client/hal-parser.ts";
import type { HalCollection, HalResource } from "../client/types.ts";
import { resolveClient, resolveProjectId } from "./helper.ts";
export interface MeetingSummary {
id: number;
title: string;
state: string;
startTime: string;
endTime: string;
duration?: string;
project: { id: number; name: string };
location?: string;
author?: { id: number; name: string };
}
export interface AgendaItemOutcome {
id: number;
notes: string;
}
export interface AgendaItem {
id: number;
title: string;
durationInMinutes?: number;
notes?: string;
itemType: string;
position: number;
author?: { id: number; name: string };
presenter?: { id: number; name: string };
section?: { id: number; title: string };
outcomes: AgendaItemOutcome[];
}
export interface MeetingDetail extends MeetingSummary {
template: boolean;
notify: boolean;
participants: Array<{ id: number; name: string }>;
agendaItems?: AgendaItem[];
sections?: Array<{ id: number; title: string; position: number }>;
}
export interface MeetingSearchResult {
meeting: MeetingSummary;
matchType: "title" | "location" | "agenda_item";
matchedAgendaItems?: Array<{
id: number;
title: string;
snippet?: string;
}>;
}
export interface PaginatedResult<T> {
total: number;
count: number;
pageSize: number;
offset: number;
elements: T[];
items?: T[];
}
/**
* Normalizes a raw HAL Meeting resource into a token-efficient MeetingSummary.
*/
export function normalizeMeetingSummary(resource: HalResource): MeetingSummary {
const links = (resource._links ?? {}) as Record<string, { href?: string; title?: string } | undefined>;
const selfLink = links.self;
const projectLink = links.project;
const authorLink = links.author;
const embedded = (resource._embedded ?? {}) as Record<string, unknown>;
const embeddedProject = embedded.project as { id?: number; name?: string } | undefined;
const embeddedAuthor = embedded.author as { id?: number; name?: string } | undefined;
const id = resource.id ?? extractIdFromHref(selfLink?.href) ?? 0;
const title = String(resource.title ?? "");
const state = String(resource.state ?? "");
const startTime = String(resource.startTime ?? "");
const endTime = String(resource.endTime ?? "");
const duration = resource.duration ? String(resource.duration) : undefined;
const location = resource.location ? String(resource.location) : undefined;
const projectId = extractIdFromHref(projectLink?.href) ?? embeddedProject?.id ?? 0;
const projectName = projectLink?.title ?? embeddedProject?.name ?? "";
let author: { id: number; name: string } | undefined;
const authorId = extractIdFromHref(authorLink?.href) ?? embeddedAuthor?.id;
const authorName = authorLink?.title ?? embeddedAuthor?.name;
if (authorId !== undefined || authorName !== undefined) {
author = {
id: authorId ?? 0,
name: authorName ?? (authorId ? `User #${authorId}` : ""),
};
}
return {
id,
title,
state,
startTime,
endTime,
duration,
project: { id: projectId, name: projectName },
location,
author,
};
}
/**
* Normalizes an Agenda Item Outcome.
*/
export function normalizeOutcome(outcome: Record<string, unknown>): AgendaItemOutcome {
const links = (outcome._links ?? {}) as Record<string, { href?: string } | undefined>;
const id = (typeof outcome.id === "number" ? outcome.id : extractIdFromHref(links.self?.href)) ?? 0;
const notes = extractRawText(outcome.notes ?? outcome.text ?? outcome.comment ?? "");
return { id, notes };
}
/**
* Normalizes a raw HAL Agenda Item resource into an AgendaItem.
*/
export function normalizeAgendaItem(item: Record<string, unknown>): AgendaItem {
const links = (item._links ?? {}) as Record<string, { href?: string; title?: string } | undefined>;
const embedded = (item._embedded ?? {}) as Record<string, unknown>;
const id = (typeof item.id === "number" ? item.id : extractIdFromHref(links.self?.href)) ?? 0;
const title = String(item.title ?? links.workPackage?.title ?? "");
const itemType = String(item.itemType ?? item.type ?? "simple");
const position = typeof item.position === "number" ? item.position : 0;
const durationInMinutes =
typeof item.durationInMinutes === "number" ? item.durationInMinutes : undefined;
const rawNotes = extractRawText(item.notes);
const notes = rawNotes.length > 0 ? rawNotes : undefined;
let author: { id: number; name: string } | undefined;
const authorLink = links.author;
const authorId = extractIdFromHref(authorLink?.href);
if (authorId !== undefined || authorLink?.title) {
author = {
id: authorId ?? 0,
name: authorLink?.title ?? `User #${authorId}`,
};
}
let presenter: { id: number; name: string } | undefined;
const presenterLink = links.presenter;
const presenterId = extractIdFromHref(presenterLink?.href);
if (presenterId !== undefined || presenterLink?.title) {
presenter = {
id: presenterId ?? 0,
name: presenterLink?.title ?? `User #${presenterId}`,
};
}
let section: { id: number; title: string } | undefined;
const sectionObj = (embedded.section ?? links.section) as
| { id?: number; title?: string; href?: string; _links?: { self?: { href?: string } } }
| undefined;
if (sectionObj) {
const sectionId =
sectionObj.id ?? extractIdFromHref(sectionObj.href ?? sectionObj._links?.self?.href);
if (sectionId !== undefined || sectionObj.title !== undefined) {
section = {
id: sectionId ?? 0,
title: sectionObj.title ?? "",
};
}
}
const rawOutcomes = embedded.outcomes ?? links.outcomes ?? [];
const outcomes: AgendaItemOutcome[] = Array.isArray(rawOutcomes)
? rawOutcomes.map((o) => normalizeOutcome(o as Record<string, unknown>))
: [];
return {
id,
title,
durationInMinutes,
notes,
itemType,
position,
author,
presenter,
section,
outcomes,
};
}
/**
* Normalizes a raw HAL Meeting resource and embedded agenda items into a MeetingDetail.
*/
export function normalizeMeetingDetail(
resource: HalResource,
agendaItems?: AgendaItem[]
): MeetingDetail {
const summary = normalizeMeetingSummary(resource);
const links = (resource._links ?? {}) as Record<string, unknown>;
const embedded = (resource._embedded ?? {}) as Record<string, unknown>;
const template = Boolean(resource.template);
const notify = Boolean(resource.notify);
const rawParticipants = embedded.participants ?? links.participants ?? [];
const participants: Array<{ id: number; name: string }> = [];
if (Array.isArray(rawParticipants)) {
for (const p of rawParticipants) {
const pObj = p as { id?: number; href?: string; title?: string; name?: string; _links?: { self?: { href?: string } } };
const pId = pObj.id ?? extractIdFromHref(pObj.href ?? pObj._links?.self?.href);
const pName = pObj.title ?? pObj.name;
if (pId !== undefined || pName !== undefined) {
participants.push({
id: pId ?? 0,
name: pName ?? (pId ? `User #${pId}` : ""),
});
}
}
}
const rawSections = embedded.sections ?? links.sections ?? [];
let sections: Array<{ id: number; title: string; position: number }> | undefined;
if (Array.isArray(rawSections) && rawSections.length > 0) {
sections = (rawSections as Array<{ id?: number; href?: string; title?: string; position?: number; _links?: { self?: { href?: string } } }>).map(
(s) => ({
id: s.id ?? extractIdFromHref(s.href ?? s._links?.self?.href) ?? 0,
title: s.title ?? "",
position: typeof s.position === "number" ? s.position : 0,
})
);
}
return {
...summary,
template,
notify,
participants,
agendaItems,
sections,
};
}
/**
* Extracts a concise text snippet centered around query match keyword.
*/
function extractSnippet(text: string, query: string, maxLength = 160): string {
if (!text) return "";
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const idx = lowerText.indexOf(lowerQuery);
if (idx === -1) {
return text.length <= maxLength ? text : `${text.slice(0, maxLength)}...`;
}
const start = Math.max(0, idx - 40);
const end = Math.min(text.length, idx + query.length + 80);
let snippet = text.slice(start, end).trim();
if (start > 0) snippet = `...${snippet}`;
if (end < text.length) snippet = `${snippet}...`;
return snippet;
}
/**
* Lists meetings visible to the user matching optional project, time, and pagination filters.
*/
export async function listMeetings(
params?: {
projectId?: string | number;
time?: string;
offset?: number;
pageSize?: number;
},
client?: OpenProjectClient
): Promise<PaginatedResult<MeetingSummary>> {
const opClient = resolveClient(client);
const filters: Record<string, { operator: string; values: string[] }>[] = [];
if (params?.projectId !== undefined) {
const resolvedId = await resolveProjectId(params.projectId, opClient);
filters.push({
project_id: {
operator: "=",
values: [String(resolvedId)],
},
});
}
if (params?.time) {
const timeLower = params.time.toLowerCase().trim();
const operator = timeLower === "past" ? "past" : "upcoming";
filters.push({
time: {
operator,
values: [],
},
});
}
const searchParams = new URLSearchParams();
if (params?.pageSize !== undefined) {
searchParams.set("pageSize", String(params.pageSize));
}
if (params?.offset !== undefined) {
searchParams.set("offset", String(params.offset));
}
if (filters.length > 0) {
searchParams.set("filters", JSON.stringify(filters));
}
const queryStr = searchParams.toString();
const path = `/api/v3/meetings${queryStr ? `?${queryStr}` : ""}`;
const response = await opClient.get<HalCollection<HalResource>>(path);
const rawElements = response._embedded?.elements ?? [];
const elements = rawElements.map(normalizeMeetingSummary);
return {
total: response.total ?? elements.length,
count: response.count ?? elements.length,
pageSize: response.pageSize ?? elements.length,
offset: response.offset ?? 1,
elements,
items: elements,
};
}
/**
* Retrieves a single meeting by ID, optionally including its agenda items.
*/
export async function getMeeting(
id: number,
options?: { includeAgendaItems?: boolean },
client?: OpenProjectClient
): Promise<MeetingDetail> {
const opClient = resolveClient(client);
const includeAgenda = options?.includeAgendaItems ?? true;
const meetingPromise = opClient.get<HalResource>(`/api/v3/meetings/${id}`);
if (includeAgenda) {
const [meetingResource, agendaResponse] = await Promise.all([
meetingPromise,
opClient
.get<HalCollection<Record<string, unknown>>>(`/api/v3/meetings/${id}/agenda_items`)
.catch(() => undefined),
]);
let agendaItems: AgendaItem[] | undefined;
if (agendaResponse?._embedded?.elements) {
agendaItems = agendaResponse._embedded.elements.map(normalizeAgendaItem);
} else {
agendaItems = [];
}
return normalizeMeetingDetail(meetingResource, agendaItems);
}
const meetingResource = await meetingPromise;
return normalizeMeetingDetail(meetingResource);
}
/**
* Deep searches across meetings by title, location, and agenda item notes.
*/
export async function searchMeetings(
params: {
query: string;
projectId?: string | number;
offset?: number;
pageSize?: number;
},
client?: OpenProjectClient
): Promise<PaginatedResult<MeetingSearchResult>> {
const opClient = resolveClient(client);
const needle = params.query.toLowerCase().trim();
// Fetch candidate meetings scoped to project if specified
const meetingsResult = await listMeetings(
{
projectId: params.projectId,
offset: params.offset ?? 1,
pageSize: params.pageSize ?? 50,
},
opClient
);
const candidateMeetings = meetingsResult.elements;
// Process candidate meetings concurrently
const matchPromises = candidateMeetings.map(async (meeting) => {
const titleMatches = meeting.title.toLowerCase().includes(needle);
const locationMatches = Boolean(
meeting.location && meeting.location.toLowerCase().includes(needle)
);
// Concurrently fetch agenda items for candidate meeting
const agendaResponse = await opClient
.get<HalCollection<Record<string, unknown>>>(`/api/v3/meetings/${meeting.id}/agenda_items`)
.catch(() => undefined);
const rawItems = agendaResponse?._embedded?.elements ?? [];
const agendaItems = rawItems.map(normalizeAgendaItem);
const matchedAgendaItems: Array<{ id: number; title: string; snippet?: string }> = [];
for (const item of agendaItems) {
const itemTitleMatches = item.title.toLowerCase().includes(needle);
const itemNotesMatches = Boolean(item.notes && item.notes.toLowerCase().includes(needle));
const outcomeNotesMatch = item.outcomes.find((o) =>
o.notes.toLowerCase().includes(needle)
);
if (itemTitleMatches || itemNotesMatches || outcomeNotesMatch) {
let snippetText = "";
if (itemNotesMatches && item.notes) {
snippetText = extractSnippet(item.notes, params.query);
} else if (outcomeNotesMatch) {
snippetText = extractSnippet(outcomeNotesMatch.notes, params.query);
} else {
snippetText = item.notes ? extractSnippet(item.notes, params.query) : item.title;
}
matchedAgendaItems.push({
id: item.id,
title: item.title,
snippet: snippetText || undefined,
});
}
}
let matchType: "title" | "location" | "agenda_item" | null = null;
if (titleMatches) {
matchType = "title";
} else if (locationMatches) {
matchType = "location";
} else if (matchedAgendaItems.length > 0) {
matchType = "agenda_item";
}
if (!matchType) {
return null;
}
const searchResult: MeetingSearchResult = {
meeting,
matchType,
matchedAgendaItems: matchedAgendaItems.length > 0 ? matchedAgendaItems : undefined,
};
return searchResult;
});
const resolvedMatches = await Promise.all(matchPromises);
const matchedElements: MeetingSearchResult[] = resolvedMatches.filter(
(item): item is MeetingSearchResult => item !== null
);
return {
total: matchedElements.length,
count: matchedElements.length,
pageSize: params.pageSize ?? matchedElements.length,
offset: params.offset ?? 1,
elements: matchedElements,
items: matchedElements,
};
}
+309
View File
@@ -0,0 +1,309 @@
import { describe, expect, test } from "bun:test";
import { OpenProjectClient } from "../src/client/api-client.ts";
import { runWithContext, type RequestContext } from "../src/context.ts";
import { listMeetings, getMeeting, searchMeetings } from "../src/services/meetings.ts";
describe("Meetings Service", () => {
test("listMeetings serializes project filter and parses meeting items", async () => {
const mockClient = {
get: async (path: string) => {
expect(path).toContain("/api/v3/meetings");
return {
_type: "Collection",
total: 1,
count: 1,
pageSize: 20,
offset: 1,
_embedded: {
elements: [
{
_type: "Meeting",
id: 2,
title: "Weekly Planning",
state: "open",
startTime: "2026-09-08T00:22:53Z",
endTime: "2026-09-08T01:22:53Z",
duration: "PT1H",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" },
author: { href: "/api/v3/users/4", title: "Admin" }
}
}
]
}
};
}
} as unknown as OpenProjectClient;
const res = await listMeetings({ projectId: 1 }, mockClient);
expect(res.total).toBe(1);
expect(res.elements[0].title).toBe("Weekly Planning");
expect(res.elements[0].project.name).toBe("Demo project");
});
test("getMeeting fetches details and embeds agenda items", async () => {
const mockClient = {
get: async (path: string) => {
if (path === "/api/v3/meetings/2") {
return {
_type: "Meeting",
id: 2,
title: "Weekly Planning",
state: "open",
startTime: "2026-09-08T00:22:53Z",
endTime: "2026-09-08T01:22:53Z",
template: false,
notify: false,
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" },
participants: [{ href: "/api/v3/users/4", title: "Admin" }]
}
};
}
if (path === "/api/v3/meetings/2/agenda_items") {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "MeetingAgendaItem",
id: 10,
title: "Good news",
position: 1,
itemType: "simple",
durationInMinutes: 5,
notes: { raw: "What went well this week?" },
_embedded: { outcomes: [] },
_links: { outcomes: [] }
}
]
}
};
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const meeting = await getMeeting(2, { includeAgendaItems: true }, mockClient);
expect(meeting.title).toBe("Weekly Planning");
expect(meeting.agendaItems).toHaveLength(1);
expect(meeting.agendaItems![0].title).toBe("Good news");
expect(meeting.agendaItems![0].notes).toBe("What went well this week?");
});
test("searchMeetings finds matches in agenda notes", async () => {
const mockClient = {
get: async (path: string) => {
if (path.startsWith("/api/v3/meetings?")) {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "Meeting",
id: 2,
title: "Weekly Planning",
state: "open",
startTime: "2026-09-08T00:22:53Z",
endTime: "2026-09-08T01:22:53Z",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" }
}
}
]
}
};
}
if (path === "/api/v3/meetings/2/agenda_items") {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "MeetingAgendaItem",
id: 10,
title: "Sprint Review",
notes: { raw: "Discussed critical architecture improvements." }
}
]
}
};
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const res = await searchMeetings({ query: "architecture" }, mockClient);
expect(res.total).toBe(1);
expect(res.elements[0].matchType).toBe("agenda_item");
expect(res.elements[0].matchedAgendaItems![0].snippet).toContain("architecture");
});
test("searchMeetings matches by title and location without calling agenda items", async () => {
let agendaItemsCalled = false;
const mockClient = {
get: async (path: string) => {
if (path.startsWith("/api/v3/meetings?")) {
return {
_type: "Collection",
total: 2,
_embedded: {
elements: [
{
_type: "Meeting",
id: 20,
title: "Sprint Retrospective",
state: "closed",
startTime: "2026-09-01T10:00:00Z",
endTime: "2026-09-01T11:00:00Z",
location: "Room A",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo" }
}
},
{
_type: "Meeting",
id: 21,
title: "Standup",
state: "open",
startTime: "2026-09-02T10:00:00Z",
endTime: "2026-09-02T10:15:00Z",
location: "Virtual Stage",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo" }
}
}
]
}
};
}
if (path.includes("/agenda_items")) {
agendaItemsCalled = true;
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const titleRes = await searchMeetings({ query: "Retrospective" }, mockClient);
expect(titleRes.total).toBe(1);
expect(titleRes.elements[0].matchType).toBe("title");
expect(titleRes.elements[0].meeting.id).toBe(20);
const locRes = await searchMeetings({ query: "Virtual" }, mockClient);
expect(locRes.total).toBe(1);
expect(locRes.elements[0].matchType).toBe("location");
expect(locRes.elements[0].meeting.id).toBe(21);
});
test("listMeetings supports time filter (upcoming and past)", async () => {
let capturedPath = "";
const mockClient = {
get: async (path: string) => {
capturedPath = path;
return {
_type: "Collection",
total: 0,
count: 0,
pageSize: 20,
offset: 1,
_embedded: { elements: [] }
};
}
} as unknown as OpenProjectClient;
await listMeetings({ time: "upcoming" }, mockClient);
expect(decodeURIComponent(capturedPath)).toContain('"time":{"operator":"upcoming","values":[]}');
await listMeetings({ time: "past" }, mockClient);
expect(decodeURIComponent(capturedPath)).toContain('"time":{"operator":"past","values":[]}');
});
test("listMeetings resolves project identifier string to ID", async () => {
let capturedPath = "";
const mockClient = {
get: async (path: string) => {
if (path === "projects/demo-project") {
return { id: 77 };
}
capturedPath = path;
return {
_type: "Collection",
total: 0,
count: 0,
pageSize: 20,
offset: 1,
_embedded: { elements: [] }
};
}
} as unknown as OpenProjectClient;
await listMeetings({ projectId: "demo-project" }, mockClient);
expect(decodeURIComponent(capturedPath)).toContain('"project_id":{"operator":"=","values":["77"]}');
});
test("listMeetings resolves ambient RequestContext client when client is omitted", async () => {
const ambientClient = {
get: async (path: string) => {
expect(path).toContain("/api/v3/meetings");
return {
_type: "Collection",
total: 0,
count: 0,
pageSize: 20,
offset: 1,
_embedded: { elements: [] }
};
}
} as unknown as OpenProjectClient;
const context: RequestContext = {
client: ambientClient,
isReadOnly: false
};
await runWithContext(context, async () => {
const res = await listMeetings();
expect(res.total).toBe(0);
expect(res.elements).toEqual([]);
});
});
test("getMeeting with includeAgendaItems=false does not request agenda items", async () => {
let agendaCalled = false;
const mockClient = {
get: async (path: string) => {
if (path === "/api/v3/meetings/5") {
return {
_type: "Meeting",
id: 5,
title: "Quick Sync",
state: "open",
startTime: "2026-09-08T00:00:00Z",
endTime: "2026-09-08T00:30:00Z",
template: true,
notify: true,
_links: {
project: { href: "/api/v3/projects/1", title: "Demo" }
}
};
}
if (path.includes("/agenda_items")) {
agendaCalled = true;
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const detail = await getMeeting(5, { includeAgendaItems: false }, mockClient);
expect(detail.id).toBe(5);
expect(detail.template).toBe(true);
expect(detail.notify).toBe(true);
expect(detail.agendaItems).toBeUndefined();
expect(agendaCalled).toBe(false);
});
});