M21: API & Feeds — me-Doku (#147), since-Filter (#148), Atom-Feeds (#149) #157

Merged
fable-5 merged 3 commits from m21-api-feeds into main 2026-07-20 01:06:20 +02:00
16 changed files with 174 additions and 16 deletions
Showing only changes of commit d73b120d06 - Show all commits

View File

@ -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");

View File

@ -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")
}

View File

@ -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',

View File

@ -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);

View File

@ -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')

View File

@ -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 } } },
});

View File

@ -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') }),
},

View File

@ -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')

View File

@ -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`)

View File

@ -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

View File

@ -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

View File

@ -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)_ |

View File

@ -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

View File

@ -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)_ |

View File

@ -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}` |

View File

@ -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(),