#148: Seitenlisten filtern nach createdSince/updatedSince
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
parent
b4247f4832
commit
d73b120d06
@ -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");
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<PageListItemView[]> {
|
||||
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')
|
||||
|
||||
@ -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<PageListItemView[]> {
|
||||
* 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<PageListItemView[]> {
|
||||
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 } } },
|
||||
});
|
||||
|
||||
@ -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') }),
|
||||
},
|
||||
|
||||
@ -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<PublicPageListItemView[]> {
|
||||
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')
|
||||
|
||||
@ -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`)
|
||||
|
||||
@ -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<PublicPageListItemView[]> {
|
||||
async listPages(
|
||||
user: User,
|
||||
pondSlug: string,
|
||||
query?: PageListQuery,
|
||||
): Promise<PublicPageListItemView[]> {
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)_ |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)_ |
|
||||
|
||||
@ -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}` |
|
||||
|
||||
@ -92,6 +92,21 @@ export const publicUpdateLabelInputSchema = z
|
||||
.partial();
|
||||
export type PublicUpdateLabelInput = z.infer<typeof publicUpdateLabelInputSchema>;
|
||||
|
||||
/** 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<typeof pageListQuerySchema>;
|
||||
|
||||
export const publicSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, 'validation.required').max(200),
|
||||
pond: z.string().trim().optional(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user