feat(core,server): log feed, titles on changes, work titles on copies

GET log (engagements and listens, keyset over at/kind/id), listChanges
title and intoTitle, listCopies workTitle, all via batched lookups.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 22:11:53 +01:00
co-authored by Claude Opus 5.5
parent 5fa59310f3
commit c2e8c83f86
12 changed files with 561 additions and 7 deletions
+2
View File
@@ -8,6 +8,7 @@ import { engagementRoutes } from "./routes/engagements.ts";
import { entryRoutes } from "./routes/entries.ts";
import { exportRoutes } from "./routes/export.ts";
import { listRoutes } from "./routes/lists.ts";
import { logRoutes } from "./routes/log.ts";
import { metaRoutes } from "./routes/meta.ts";
import { sourceRoutes } from "./routes/sources.ts";
import { workRoutes } from "./routes/works.ts";
@@ -26,6 +27,7 @@ export function createApp(deps: AppDeps): App {
...engagementRoutes,
...workRoutes,
...listRoutes,
...logRoutes,
...copyRoutes,
...customFieldRoutes,
...attachmentRoutes,
+15
View File
@@ -0,0 +1,15 @@
import { listLog } from "@sttracker/core/log";
import { z } from "zod";
import { route } from "../http.ts";
import { qInt } from "./schemas.ts";
export const logRoutes = [
route({
method: "GET",
path: "log",
summary: "Your Log: engagements and listens, newest first (keyset cursor).",
auth: "any:read",
query: z.object({ limit: qInt.optional(), cursor: z.string().max(2000).optional() }),
handler: async ({ deps, user, query }) => listLog(deps.db, user.id, query),
}),
];
+19
View File
@@ -329,6 +329,25 @@ forEachDialect("api", (getDb) => {
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "5" }, noCsrf: true })).status).toBe(403);
});
test("log: engagements newest first with keyset paging; copies carry workTitle", async () => {
const ana = await signUp(app, "ana");
const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string; workId: string };
for (const value of ["2026-01-02", "2026-03-04"]) {
const r = await call(app, "POST", `entries/${e.id}/engagements`, { cookie: ana, body: { finishedAt: { value, precision: "day" } } });
expect(r.status).toBe(201);
}
const page1 = (await (await call(app, "GET", "log?limit=1", { cookie: ana })).json()) as { items: { at: string; title: string; kind: string }[]; nextCursor: string };
expect(page1.items).toEqual([expect.objectContaining({ kind: "engagement", at: "2026-03-04", title: "Dune" })]);
const page2 = (await (await call(app, "GET", `log?limit=1&cursor=${page1.nextCursor}`, { cookie: ana })).json()) as { items: { at: string }[]; nextCursor: string | null };
expect(page2.items.map((i) => i.at)).toEqual(["2026-01-02"]);
expect(page2.nextCursor).toBeNull();
expect((await call(app, "GET", "log?cursor=bad", { cookie: ana })).status).toBe(400);
expect((await call(app, "GET", "log")).status).toBe(401);
await call(app, "POST", "copies", { cookie: ana, body: { workId: e.workId, format: "Paperback" } });
expect(await (await call(app, "GET", "copies", { cookie: ana })).json()).toEqual([expect.objectContaining({ workTitle: "Dune", format: "Paperback" })]);
});
test("JSON bodies need Content-Type application/json (415)", async () => {
const res = await app.fetch(
new Request(`${BASE}/auth/register`, {