From 8c3db6830706072aa8c0a20370fd35560c34b224 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 7 Sep 2026 02:22:10 +0200 Subject: [PATCH] feat: add meetings domain service --- src/services/meetings.ts | 469 +++++++++++++++++++++++++++++++++++++++ tests/meetings.test.ts | 309 ++++++++++++++++++++++++++ 2 files changed, 778 insertions(+) create mode 100644 src/services/meetings.ts create mode 100644 tests/meetings.test.ts diff --git a/src/services/meetings.ts b/src/services/meetings.ts new file mode 100644 index 0000000..72eb46a --- /dev/null +++ b/src/services/meetings.ts @@ -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 { + 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; + const selfLink = links.self; + const projectLink = links.project; + const authorLink = links.author; + + const embedded = (resource._embedded ?? {}) as Record; + 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): AgendaItemOutcome { + const links = (outcome._links ?? {}) as Record; + 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): AgendaItem { + const links = (item._links ?? {}) as Record; + const embedded = (item._embedded ?? {}) as Record; + + 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)) + : []; + + 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; + const embedded = (resource._embedded ?? {}) as Record; + + 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> { + const opClient = resolveClient(client); + + const filters: Record[] = []; + + 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>(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 { + const opClient = resolveClient(client); + const includeAgenda = options?.includeAgendaItems ?? true; + + const meetingPromise = opClient.get(`/api/v3/meetings/${id}`); + + if (includeAgenda) { + const [meetingResource, agendaResponse] = await Promise.all([ + meetingPromise, + opClient + .get>>(`/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> { + 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>>(`/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, + }; +} diff --git a/tests/meetings.test.ts b/tests/meetings.test.ts new file mode 100644 index 0000000..96456cc --- /dev/null +++ b/tests/meetings.test.ts @@ -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); + }); +});