From d73b120d06fe087c676eca6baebfa39c13c2352f Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 00:49:52 +0200 Subject: [PATCH] #148: Seitenlisten filtern nach createdSince/updatedSince MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues pageListQuerySchema (ISO 8601, Kulanz für Datum ohne Zeit), Query-Parameter auf interner und Public-API-Seitenliste, Prisma-where mit gte; neue Indizes (pondId, createdAt)/(pondId, updatedAt) als Migration. OpenAPI-Parameter, MCP-Parität (list_pages created_since/updated_since), Doku (api-guide, mcp-guide, public-api.md), DB-Test inkl. 400 bei ungültigem Datum. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- .../migration.sql | 5 +++ apps/api/prisma/schema.prisma | 4 ++ apps/api/src/mcp/mcp-tools.ts | 33 ++++++++++++-- apps/api/src/mcp/mcp.service.ts | 12 ++++- apps/api/src/pages/pages.controller.ts | 10 ++++- apps/api/src/pages/pages.service.ts | 18 ++++++-- apps/api/src/public-api/openapi.ts | 18 +++++++- .../src/public-api/public-api.controller.ts | 10 ++++- .../src/public-api/public-api.e2e.db.test.ts | 44 +++++++++++++++++++ apps/api/src/public-api/public-api.service.ts | 9 +++- docs/de/manual/api-guide.md | 3 ++ docs/de/manual/mcp-guide.md | 2 +- docs/manual/api-guide.md | 3 ++ docs/manual/mcp-guide.md | 2 +- docs/self-hosting/public-api.md | 2 +- packages/shared/src/public-api.ts | 15 +++++++ 16 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 apps/api/prisma/migrations/20260719223755_page_time_filter_indexes/migration.sql diff --git a/apps/api/prisma/migrations/20260719223755_page_time_filter_indexes/migration.sql b/apps/api/prisma/migrations/20260719223755_page_time_filter_indexes/migration.sql new file mode 100644 index 0000000..6098cd9 --- /dev/null +++ b/apps/api/prisma/migrations/20260719223755_page_time_filter_indexes/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "pages_pond_id_created_at_idx" ON "pages"("pond_id", "created_at"); + +-- CreateIndex +CREATE INDEX "pages_pond_id_updated_at_idx" ON "pages"("pond_id", "updated_at"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c316ff6..0175645 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -291,6 +291,10 @@ model Page { @@unique([pondId, slug]) @@index([pondId]) @@index([parentId]) + // Time-filtered listings (issue #148): "pages of this pond created/updated + // since X" hit these instead of scanning the pond. + @@index([pondId, createdAt]) + @@index([pondId, updatedAt]) @@map("pages") } diff --git a/apps/api/src/mcp/mcp-tools.ts b/apps/api/src/mcp/mcp-tools.ts index f2182fc..b748ae7 100644 --- a/apps/api/src/mcp/mcp-tools.ts +++ b/apps/api/src/mcp/mcp-tools.ts @@ -12,9 +12,21 @@ import { z } from 'zod'; const pond = z.string().min(1); const page = z.string().min(1); +/** ISO 8601 instant for `…_since` filters (issue #148). */ +const sinceInstant = z + .string() + .trim() + .regex(/^\d{4}-\d{2}-\d{2}([T ].+)?$/) + .refine((value) => !Number.isNaN(Date.parse(value))) + .transform((value) => new Date(value)); + export const MCP_TOOL_INPUTS = { list_ponds: z.object({}), - list_pages: z.object({ pond }), + list_pages: z.object({ + pond, + created_since: sinceInstant.optional(), + updated_since: sinceInstant.optional(), + }), read_page: z.object({ pond, page }), search: z.object({ query: z.string().min(1), @@ -68,8 +80,23 @@ export const MCP_TOOL_DEFINITIONS: { name: 'list_pages', description: 'List the readable pages of a pond: slug, title, parent (the page tree), labels, ' + - 'timestamps.', - inputSchema: { type: 'object', properties: { pond: pondProp }, required: ['pond'] }, + 'timestamps. Optional created_since/updated_since (ISO 8601) narrow to pages ' + + 'created/changed at or after that instant.', + inputSchema: { + type: 'object', + properties: { + pond: pondProp, + created_since: { + type: 'string', + description: 'ISO 8601 instant — only pages created at/after it', + }, + updated_since: { + type: 'string', + description: 'ISO 8601 instant — only pages updated at/after it', + }, + }, + required: ['pond'], + }, }, { name: 'read_page', diff --git a/apps/api/src/mcp/mcp.service.ts b/apps/api/src/mcp/mcp.service.ts index 91b861b..d1bb66b 100644 --- a/apps/api/src/mcp/mcp.service.ts +++ b/apps/api/src/mcp/mcp.service.ts @@ -97,9 +97,17 @@ export class McpService { case 'list_ponds': return asJson(await this.publicApi.listPonds(user, token, 'mcp')); - case 'list_pages': + case 'list_pages': { await this.assertPondExposed(input.pond!, token); - return asJson(await this.publicApi.listPages(user, input.pond!)); + // The zod input already turned the `…_since` strings into Dates (#148). + const since = args as { created_since?: Date; updated_since?: Date }; + return asJson( + await this.publicApi.listPages(user, input.pond!, { + createdSince: since.created_since, + updatedSince: since.updated_since, + }), + ); + } case 'read_page': await this.assertPondExposed(input.pond!, token); diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index c81f6f3..e1f2449 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -24,6 +24,7 @@ import { UpdatePageInput, createPageInputSchema, pageDeleteQuerySchema, + pageListQuerySchema, repositionPageInputSchema, updatePageInputSchema, } from '@dorfteich/shared'; @@ -57,9 +58,16 @@ export class PagesController { @RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters per page async list( @Param('pondId') pondId: string, + @Query('createdSince') createdSince: string | undefined, + @Query('updatedSince') updatedSince: string | undefined, @Req() request: AuthedRequest, ): Promise { - return this.pages.list(request.user!, pondId); + // Optional time filters (issue #148); an invalid instant → 400. + const query = new ZodValidationPipe(pageListQuerySchema).transform({ + createdSince: createdSince || undefined, + updatedSince: updatedSince || undefined, + }); + return this.pages.list(request.user!, pondId, query); } @Get('pages/:id') diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 7b80284..f118813 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -11,6 +11,7 @@ import { OutlineEntry, PageDeleteMode, PageListItemView, + PageListQuery, PageStateView, PageView, PluginPageSummary, @@ -151,13 +152,24 @@ export class PagesService { /** Sidebar page list, ordered per the pond's persisted sort mode (issue #26), * each with its assigned label ids for chips and filtering (issue #44). * Filtered to the pages the user may read (issue #52) — a label- or - * page-scoped reader sees only their slice of the pond. */ - async list(user: User, pondId: string): Promise { + * page-scoped reader sees only their slice of the pond. Optional time + * filters (issue #148) narrow to pages created/updated at or after an + * instant. */ + async list( + user: User | null, + pondId: string, + query?: PageListQuery, + ): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); if (!pond) throw new NotFoundException(); const settings = pondSettingsSchema.parse(pond.settings ?? {}); const pages = await this.prisma.page.findMany({ - where: { pondId, deletedAt: null }, + where: { + pondId, + deletedAt: null, + ...(query?.createdSince ? { createdAt: { gte: query.createdSince } } : {}), + ...(query?.updatedSince ? { updatedAt: { gte: query.updatedSince } } : {}), + }, orderBy: PagesService.SORT_ORDER[settings.sidebarSort], include: { labels: { select: { labelId: true } } }, }); diff --git a/apps/api/src/public-api/openapi.ts b/apps/api/src/public-api/openapi.ts index 24b142c..559ee87 100644 --- a/apps/api/src/public-api/openapi.ts +++ b/apps/api/src/public-api/openapi.ts @@ -273,7 +273,23 @@ export function buildOpenApiDocument(): object { '/ponds/{pondSlug}/pages': { get: { summary: 'Readable pages of the pond.', - parameters: [pondParam], + parameters: [ + pondParam, + { + name: 'createdSince', + in: 'query', + required: false, + schema: { type: 'string', format: 'date-time' }, + description: 'Only pages created at or after this ISO 8601 instant (issue #148).', + }, + { + name: 'updatedSince', + in: 'query', + required: false, + schema: { type: 'string', format: 'date-time' }, + description: 'Only pages updated at or after this ISO 8601 instant (issue #148).', + }, + ], responses: { '200': jsonResponse('Pages', { type: 'array', items: ref('PageListItem') }), }, diff --git a/apps/api/src/public-api/public-api.controller.ts b/apps/api/src/public-api/public-api.controller.ts index 9ac57f1..200be74 100644 --- a/apps/api/src/public-api/public-api.controller.ts +++ b/apps/api/src/public-api/public-api.controller.ts @@ -17,6 +17,7 @@ import { commentListQuerySchema, createCommentInputSchema, createLabelInputSchema, + pageListQuerySchema, publicCreatePageInputSchema, publicSearchQuerySchema, publicUpdateLabelInputSchema, @@ -88,9 +89,16 @@ export class PublicApiController { @RequiresPondRole('reader', POND) listPages( @Param('pondSlug') pondSlug: string, + @Query('createdSince') createdSince: string | undefined, + @Query('updatedSince') updatedSince: string | undefined, @Req() request: PublicApiRequest, ): Promise { - return this.publicApi.listPages(request.user!, pondSlug); + // Optional time filters (issue #148); an invalid instant → 400. + const query = new ZodValidationPipe(pageListQuerySchema).transform({ + createdSince: createdSince || undefined, + updatedSince: updatedSince || undefined, + }); + return this.publicApi.listPages(request.user!, pondSlug, query); } @Post('ponds/:pondSlug/pages') diff --git a/apps/api/src/public-api/public-api.e2e.db.test.ts b/apps/api/src/public-api/public-api.e2e.db.test.ts index b8b91f6..704c81b 100644 --- a/apps/api/src/public-api/public-api.e2e.db.test.ts +++ b/apps/api/src/public-api/public-api.e2e.db.test.ts @@ -460,6 +460,50 @@ describe.skipIf(!hasTestDb)('public api v1 (e2e, issue #104)', () => { .expect(404); }); + it('filters the page list by createdSince/updatedSince (issue #148)', async () => { + const old = await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('editor')) + .send({ title: `Since Old ${suffix}` }) + .expect(201); + const fresh = await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('editor')) + .send({ title: `Since Fresh ${suffix}` }) + .expect(201); + const oldSlug = (old.body as PublicPageView).slug; + const freshSlug = (fresh.body as PublicPageView).slug; + // Backdate the old page below the cutoff (raw row: timestamps only). + await prisma.page.updateMany({ + where: { pondId, slug: oldSlug }, + data: { + createdAt: new Date('2000-01-01T00:00:00Z'), + updatedAt: new Date('2000-01-02T00:00:00Z'), + }, + }); + + for (const param of ['createdSince', 'updatedSince'] as const) { + const filtered = await pub() + .get(`/api/public/v1/ponds/${pondSlug}/pages?${param}=2020-01-01T00:00:00Z`) + .set('Authorization', bearer('reader')) + .expect(200); + const slugs = (filtered.body as PublicPageListItemView[]).map((p) => p.slug); + expect(slugs).toContain(freshSlug); + expect(slugs).not.toContain(oldSlug); + } + + // Unfiltered, both are there; an invalid instant is a 400. + const all = await pub() + .get(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('reader')) + .expect(200); + expect((all.body as PublicPageListItemView[]).map((p) => p.slug)).toContain(oldSlug); + await pub() + .get(`/api/public/v1/ponds/${pondSlug}/pages?updatedSince=not-a-date`) + .set('Authorization', bearer('reader')) + .expect(400); + }); + it('manages labels with pond-admin rights and assigns them to pages', async () => { const label = await pub() .post(`/api/public/v1/ponds/${pondSlug}/labels`) diff --git a/apps/api/src/public-api/public-api.service.ts b/apps/api/src/public-api/public-api.service.ts index 8dc0382..d4a0fce 100644 --- a/apps/api/src/public-api/public-api.service.ts +++ b/apps/api/src/public-api/public-api.service.ts @@ -7,6 +7,7 @@ import { pondFeatureEnabled, pondSettingsSchema, type CommentListFilter, + type PageListQuery, type CreateCommentInput, type CreateLabelInput, type LabelTreeNode, @@ -93,10 +94,14 @@ export class PublicApiService { return this.pondView(pond); } - async listPages(user: User, pondSlug: string): Promise { + async listPages( + user: User, + pondSlug: string, + query?: PageListQuery, + ): Promise { const pond = await this.requirePond(pondSlug); const [items, labelNames] = await Promise.all([ - this.pages.list(user, pond.id), + this.pages.list(user, pond.id, query), this.labelNames(pond.id), ]); // parentId is already permission-nulled by the list (#106); mapping it diff --git a/docs/de/manual/api-guide.md b/docs/de/manual/api-guide.md index 894cf85..6f0150e 100644 --- a/docs/de/manual/api-guide.md +++ b/docs/de/manual/api-guide.md @@ -52,6 +52,9 @@ curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds # Seiten eines Teichs: Slug, Titel, Parent (Seitenbaum-Slug), Labels, Zeitstempel curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages +# Nur Seiten, die seit einem ISO-8601-Zeitpunkt erstellt/geändert wurden (#148) +curl -H "$AUTH" "https://wiki.example.com/api/public/v1/ponds/team/pages?updatedSince=2026-07-01T00:00:00Z" + # Eine Seite — Markdown-Quelle UND gerendertes, bereinigtes HTML curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes diff --git a/docs/de/manual/mcp-guide.md b/docs/de/manual/mcp-guide.md index b792e30..b9f6b45 100644 --- a/docs/de/manual/mcp-guide.md +++ b/docs/de/manual/mcp-guide.md @@ -65,7 +65,7 @@ Stdio-Clients überbrücken mit `mcp-remote`: | Tool | Tut | | ----------------------------------------------------- | -------------------------------------------------- | | `list_ponds` | die Teiche, die dieses Token erreicht | -| `list_pages(pond)` | Seiten mit Slug, Titel, Parent, Labels | +| `list_pages(pond, created_since?, updated_since?)` | Seiten mit Slug, Titel, Parent, Labels | | `read_page(pond, page)` | eine Seite als Markdown plus Metadaten | | `search(query, pond?, label?)` | Volltextsuche mit Snippets | | `create_page(pond, title, markdown, parent?)` | neue Seite aus Markdown _(write)_ | diff --git a/docs/manual/api-guide.md b/docs/manual/api-guide.md index 180166e..d1632f1 100644 --- a/docs/manual/api-guide.md +++ b/docs/manual/api-guide.md @@ -49,6 +49,9 @@ curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds # Pages of a pond: slug, title, parent (page-tree slug), labels, timestamps curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages +# Only pages created/updated at or after an ISO 8601 instant (issue #148) +curl -H "$AUTH" "https://wiki.example.com/api/public/v1/ponds/team/pages?updatedSince=2026-07-01T00:00:00Z" + # One page — Markdown source AND rendered, sanitized HTML curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes diff --git a/docs/manual/mcp-guide.md b/docs/manual/mcp-guide.md index d6654f5..d5bee02 100644 --- a/docs/manual/mcp-guide.md +++ b/docs/manual/mcp-guide.md @@ -64,7 +64,7 @@ clients bridge with `mcp-remote`: | Tool | Does | | ----------------------------------------------------- | ------------------------------------------- | | `list_ponds` | the ponds this token can reach | -| `list_pages(pond)` | pages with slug, title, parent, labels | +| `list_pages(pond, created_since?, updated_since?)` | pages with slug, title, parent, labels | | `read_page(pond, page)` | a page as Markdown plus metadata | | `search(query, pond?, label?)` | full-text search with snippets | | `create_page(pond, title, markdown, parent?)` | new page from Markdown _(write)_ | diff --git a/docs/self-hosting/public-api.md b/docs/self-hosting/public-api.md index 5089bef..51c2087 100644 --- a/docs/self-hosting/public-api.md +++ b/docs/self-hosting/public-api.md @@ -43,7 +43,7 @@ curl -H "Authorization: Bearer dt_pat_..." \ | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Identity | `GET /me` | | Ponds | `GET /ponds`, `GET /ponds/{slug}` | -| Pages | `GET/POST /ponds/{slug}/pages`, `GET/PATCH/DELETE /ponds/{slug}/pages/{pageSlug}` | +| Pages | `GET/POST /ponds/{slug}/pages` (list filters `?createdSince=`/`?updatedSince=`), `GET/PATCH/DELETE /ponds/{slug}/pages/{pageSlug}` | | Search | `GET /search?q=&pond=&label=` | | Export | `GET /ponds/{slug}/export/markdown` (ZIP) | | Labels | `GET/POST /ponds/{slug}/labels`, `PATCH/DELETE /ponds/{slug}/labels/{id}`, `PUT/DELETE /ponds/{slug}/pages/{pageSlug}/labels/{id}` | diff --git a/packages/shared/src/public-api.ts b/packages/shared/src/public-api.ts index 482a735..ffea41d 100644 --- a/packages/shared/src/public-api.ts +++ b/packages/shared/src/public-api.ts @@ -92,6 +92,21 @@ export const publicUpdateLabelInputSchema = z .partial(); export type PublicUpdateLabelInput = z.infer; +/** An ISO 8601 instant (date or date-time) for `…Since` filters (issue #148). */ +const sinceInstant = z + .string() + .trim() + .regex(/^\d{4}-\d{2}-\d{2}([T ].+)?$/, 'validation.invalid') + .refine((value) => !Number.isNaN(Date.parse(value)), 'validation.invalid') + .transform((value) => new Date(value)); + +/** Optional time filters for page listings (issue #148), applied as `>=`. */ +export const pageListQuerySchema = z.object({ + createdSince: sinceInstant.optional(), + updatedSince: sinceInstant.optional(), +}); +export type PageListQuery = z.infer; + export const publicSearchQuerySchema = z.object({ q: z.string().trim().min(1, 'validation.required').max(200), pond: z.string().trim().optional(),