feat: add meetings domain service
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user