From 456c90adba9c42422574bcd2f3b594e18edb9b20 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 7 Sep 2026 02:14:18 +0200 Subject: [PATCH] feat: add listWorkPackageActivities domain service --- src/services/work-packages.ts | 81 +++++++++++++++++++++ tests/work-package-activities.test.ts | 100 ++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 tests/work-package-activities.test.ts diff --git a/src/services/work-packages.ts b/src/services/work-packages.ts index 6885b06..ce8565e 100644 --- a/src/services/work-packages.ts +++ b/src/services/work-packages.ts @@ -91,3 +91,84 @@ export async function searchWorkPackages( client ); } + +export interface ActivityDetail { + format: string; + raw: string; + html?: string; +} + +export interface WorkPackageActivity { + id: number; + version: number; + createdAt: string; + user?: { id: number; name: string }; + comment?: string; + details: ActivityDetail[]; + isComment: boolean; +} + +export interface ListWorkPackageActivitiesOptions { + workPackageId: number; + onlyComments?: boolean; +} + +/** + * Lists activities (field changes, comments, status updates) for a specific work package. + */ +export async function listWorkPackageActivities( + options: ListWorkPackageActivitiesOptions, + client?: OpenProjectClient +): Promise { + const opClient = resolveClient(client); + const response = await opClient.get>( + `/api/v3/work_packages/${options.workPackageId}/activities` + ); + + const elements = (response._embedded as { elements?: unknown[] })?.elements ?? []; + const activities: WorkPackageActivity[] = []; + + for (const item of elements) { + const rawItem = item as Record; + const commentObj = rawItem.comment as { raw?: string } | undefined; + const commentRaw = typeof commentObj?.raw === "string" ? commentObj.raw.trim() : ""; + const isComment = commentRaw.length > 0; + + if (options.onlyComments && !isComment) { + continue; + } + + const links = (rawItem._links as Record) ?? {}; + const userLink = links.user; + let user: { id: number; name: string } | undefined; + if (userLink?.href) { + const match = userLink.href.match(/\/users\/(\d+)/); + if (match) { + user = { + id: Number(match[1]), + name: userLink.title ?? `User #${match[1]}` + }; + } + } + + const rawDetails = (rawItem.details as Array<{ format?: string; raw?: string; html?: string }>) ?? []; + const details: ActivityDetail[] = rawDetails.map((d) => ({ + format: d.format ?? "custom", + raw: d.raw ?? "", + html: d.html, + })); + + activities.push({ + id: Number(rawItem.id), + version: Number(rawItem.version ?? 1), + createdAt: String(rawItem.createdAt ?? ""), + user, + comment: commentRaw || undefined, + details, + isComment, + }); + } + + return activities; +} + diff --git a/tests/work-package-activities.test.ts b/tests/work-package-activities.test.ts new file mode 100644 index 0000000..7e01331 --- /dev/null +++ b/tests/work-package-activities.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; +import { OpenProjectClient } from "../src/client/api-client.ts"; +import { listWorkPackageActivities } from "../src/services/work-packages.ts"; + +describe("Work Package Activities Service", () => { + test("parses activities and separates comments from field changes", async () => { + const mockClient = { + get: async (path: string) => { + expect(path).toBe("/api/v3/work_packages/38/activities"); + return { + _type: "Collection", + total: 2, + count: 2, + _embedded: { + elements: [ + { + _type: "Activity", + id: 60, + version: 1, + createdAt: "2026-09-05T14:24:56Z", + comment: { raw: "", html: "" }, + details: [ + { format: "custom", raw: "Status set to In progress" } + ], + _links: { + user: { href: "/api/v3/users/2", title: "Admin User" } + } + }, + { + _type: "Activity", + id: 61, + version: 2, + createdAt: "2026-09-05T15:00:00Z", + comment: { raw: "Fixed in commit abc1234", html: "

Fixed

" }, + details: [], + _links: { + user: { href: "/api/v3/users/3", title: "Dev User" } + } + } + ] + } + }; + } + } as unknown as OpenProjectClient; + + const all = await listWorkPackageActivities({ workPackageId: 38 }, mockClient); + expect(all).toHaveLength(2); + expect(all[0].isComment).toBe(false); + expect(all[1].isComment).toBe(true); + expect(all[1].comment).toBe("Fixed in commit abc1234"); + expect(all[1].user?.name).toBe("Dev User"); + + const commentsOnly = await listWorkPackageActivities({ workPackageId: 38, onlyComments: true }, mockClient); + expect(commentsOnly).toHaveLength(1); + expect(commentsOnly[0].id).toBe(61); + }); + + test("handles empty activities collection and fallback user formatting", async () => { + const mockClient = { + get: async () => ({ + _type: "Collection", + total: 1, + count: 1, + _embedded: { + elements: [ + { + id: 99, + version: 1, + createdAt: "2026-09-05T16:00:00Z", + _links: { + user: { href: "/api/v3/users/42" } + } + } + ] + } + }) + } as unknown as OpenProjectClient; + + const res = await listWorkPackageActivities({ workPackageId: 10 }, mockClient); + expect(res).toHaveLength(1); + expect(res[0].id).toBe(99); + expect(res[0].user).toEqual({ id: 42, name: "User #42" }); + expect(res[0].comment).toBeUndefined(); + expect(res[0].isComment).toBe(false); + expect(res[0].details).toEqual([]); + }); + + test("handles missing _embedded or empty elements gracefully", async () => { + const mockClient = { + get: async () => ({ + _type: "Collection", + total: 0, + count: 0 + }) + } as unknown as OpenProjectClient; + + const res = await listWorkPackageActivities({ workPackageId: 10 }, mockClient); + expect(res).toEqual([]); + }); +});