import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { SEARCH_HIGHLIGHT_END, SEARCH_HIGHLIGHT_START, editorSchema, markdownToDoc, pondFeatureEnabled, pondSettingsSchema, type CommentListFilter, type PageClassification, type PageListQuery, type CreateCommentInput, type CreateLabelInput, type LabelTreeNode, type LabelView, type PageCommentsView, type PublicCommentView, type PublicCreatePageInput, type PublicMeView, type PublicPageListItemView, type PublicPageView, type PublicPondView, type PublicSearchQuery, type PublicSearchResultView, type PublicUpdateLabelInput, type PublicUpdatePageInput, type PondExposureFeature, type PondView, } from '@dorfteich/shared'; import { Node } from 'prosemirror-model'; import { ApiToken, Page, User } from '@prisma/client'; import { AuditService } from '../audit/audit.service'; import { CommentsService } from '../comments/comments.service'; import { LabelsService } from '../labels/labels.service'; import { docToState } from '../pages/yjs-content'; import { PagesService } from '../pages/pages.service'; import { PermissionService } from '../permissions/permission.service'; import { PondsService } from '../ponds/ponds.service'; import { PrismaService } from '../prisma/prisma.service'; import { SearchProvider } from '../search/search.provider'; import { VersionsService } from '../versions/versions.service'; import { ApiTokensService } from './api-tokens.service'; /** * The public REST API surface (issue #104): thin wrappers over the existing * services — pond/page permission enforcement sits in the route decorators * (the shared PermissionGuard) and in the services themselves; the guard in * front (PublicApiGuard) already handled token auth, scope, rate limit, and * the pond opt-in. This service adds the slug-based resolution, the public * wire shapes, and the write audit trail. */ @Injectable() export class PublicApiService { constructor( private readonly prisma: PrismaService, private readonly ponds: PondsService, private readonly pages: PagesService, private readonly labels: LabelsService, private readonly comments: CommentsService, private readonly versions: VersionsService, private readonly search: SearchProvider, private readonly tokens: ApiTokensService, private readonly audit: AuditService, private readonly permissions: PermissionService, ) {} async me(user: User, token: ApiToken): Promise { const ponds = token.pondIds.length === 0 ? [] : await this.prisma.pond.findMany({ where: { id: { in: token.pondIds }, deletedAt: null }, select: { slug: true }, }); return { user: { id: user.id, username: user.username, displayName: user.displayName }, scope: this.tokens.scopeOf(token), pondSlugs: ponds.map((pond) => pond.slug).sort(), }; } /** The exposed ponds visible to the token's user, within restriction. */ async listPonds( user: User, token: ApiToken, feature: PondExposureFeature = 'api', ): Promise { const ponds = await this.listPondRowsExposed(user, token, feature); return ponds.map((pond) => this.pondView(pond)); } async getPond(slug: string): Promise { const pond = await this.requirePond(slug); return this.pondView(pond); } 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, query), this.labelNames(pond.id), ]); // parentId is already permission-nulled by the list (#106); mapping it // to a slug within the same filtered list keeps that guarantee. const slugById = new Map(items.map((item) => [item.id, item.slug])); return items.map((item) => ({ slug: item.slug, title: item.title, classification: item.classification, parent: (item.parentId && slugById.get(item.parentId)) || null, labels: item.labelIds.map((id) => labelNames.get(id) ?? id).sort(), createdAt: item.createdAt, updatedAt: item.updatedAt, })); } async getPage(user: User, pondSlug: string, pageSlug: string): Promise { const page = await this.requirePage(pondSlug, pageSlug); const [cache, pageLabels, labelNames, parent] = await Promise.all([ this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }), this.prisma.pageLabel.findMany({ where: { pageId: page.id }, select: { labelId: true } }), this.labelNames(page.pondId), this.readableParentSlug(user, page), ]); return { slug: page.slug, title: page.title, pondSlug, // VS-NfD level (#211): part of the versioned representation so API // consumers can carry the marking onward. classification: page.classification.toLowerCase() as PageClassification, parent, markdown: cache?.markdown ?? '', html: cache?.html ?? '', labels: pageLabels.map((row) => labelNames.get(row.labelId) ?? row.labelId).sort(), createdAt: page.createdAt.toISOString(), updatedAt: page.updatedAt.toISOString(), }; } async createPage( user: User, token: ApiToken, pondSlug: string, input: PublicCreatePageInput, ): Promise { const pond = await this.requirePond(pondSlug); const parentId = input.parent ? (await this.requirePage(pondSlug, input.parent)).id : null; const state = this.stateFromMarkdown(input.markdown); const page = await this.pages.createWithState(user, pond.id, input.title, state, parentId); await this.auditWrite(user, token, 'page_created', page.id); return this.getPage(user, pondSlug, page.slug); } /** * Title and/or content update. Content replacement travels through the * collab-owned document path (a MANUAL version + the restore NOTIFY), so * open editors converge and no second document lineage appears — the * rendered content in the response may therefore lag by a moment. */ async updatePage( user: User, token: ApiToken, pondSlug: string, pageSlug: string, input: PublicUpdatePageInput, ): Promise { const page = await this.requirePage(pondSlug, pageSlug); if (input.title !== undefined) { await this.pages.update(user, page.id, { title: input.title }); } if (input.markdown !== undefined) { const state = this.stateFromMarkdown(input.markdown); await this.versions.replaceContent(user, page.id, state, 'API update'); } if (input.parent !== undefined) { // Move in the tree (issue #110): a slug nests, null goes to the root; // cycle/depth refusals surface as their regular error codes. const parentId = input.parent === null ? null : (await this.requirePage(pondSlug, input.parent)).id; await this.pages.moveToEnd(user, page.id, parentId); } await this.auditWrite(user, token, 'page_updated', page.id); return this.getPage(user, pondSlug, page.slug); } async deletePage(user: User, token: ApiToken, pondSlug: string, pageSlug: string): Promise { const page = await this.requirePage(pondSlug, pageSlug); await this.pages.softDelete(user, page.id); await this.auditWrite(user, token, 'page_trashed', page.id); } /** * Permission-filtered search, additionally narrowed to API-exposed ponds: * what a pond did not opt into must not leak through snippets. Highlight * sentinels become Markdown `**…**` — the public surface never asks * clients to know our private-use codepoints. */ async searchPages( user: User, token: ApiToken, query: PublicSearchQuery, feature: PondExposureFeature = 'api', ): Promise { const exposed = await this.exposedPondIds(user, token, feature); let pondId: string | undefined; if (query.pond) { const pond = await this.requirePond(query.pond); pondId = pond.id; } const results = await this.search.search( { q: query.q, pondId, labels: query.label ? [query.label] : undefined }, user, ); return results .filter((result) => exposed.has(result.pondId)) .map((result) => ({ pondSlug: result.pondSlug, pageSlug: result.slug, title: result.title, snippet: result.snippet .replaceAll(SEARCH_HIGHLIGHT_START, '**') .replaceAll(SEARCH_HIGHLIGHT_END, '**'), })); } async listLabels(user: User, pondSlug: string): Promise { const pond = await this.requirePond(pondSlug); return this.labels.list(user, pond.id); } async createLabel( user: User, token: ApiToken, pondSlug: string, input: CreateLabelInput, ): Promise { const pond = await this.requirePond(pondSlug); const label = await this.labels.create(user, pond.id, input); await this.auditWrite(user, token, 'label_created', label.id); return label; } /** Rename/recolour and/or move in one PATCH (the public surface's shape). */ async updateLabel( user: User, token: ApiToken, pondSlug: string, labelId: string, input: PublicUpdateLabelInput, ): Promise { await this.requireLabelInPond(pondSlug, labelId); if (input.name === undefined && input.color === undefined && input.parentId === undefined) { throw new BadRequestException({ code: 'bad_request' }); } let label: LabelView | undefined; if (input.name !== undefined || input.color !== undefined) { label = await this.labels.update(user, labelId, { name: input.name, color: input.color }); } if (input.parentId !== undefined) { label = await this.labels.move(user, labelId, { parentId: input.parentId }); } await this.auditWrite(user, token, 'label_updated', labelId); return label!; } async deleteLabel(user: User, token: ApiToken, pondSlug: string, labelId: string): Promise { await this.requireLabelInPond(pondSlug, labelId); await this.labels.remove(user, labelId, false); await this.auditWrite(user, token, 'label_deleted', labelId); } async assignLabel( user: User, token: ApiToken, pondSlug: string, pageSlug: string, labelId: string, ): Promise { const page = await this.requirePage(pondSlug, pageSlug); await this.requireLabelInPond(pondSlug, labelId); const labels = await this.labels.assign(user, page.id, labelId); await this.auditWrite(user, token, 'label_assigned', page.id); return labels; } async unassignLabel( user: User, token: ApiToken, pondSlug: string, pageSlug: string, labelId: string, ): Promise { const page = await this.requirePage(pondSlug, pageSlug); await this.requireLabelInPond(pondSlug, labelId); await this.labels.unassign(user, page.id, labelId); await this.auditWrite(user, token, 'label_unassigned', page.id); } async listComments( pondSlug: string, pageSlug: string, filter: CommentListFilter, ): Promise { const page = await this.requirePage(pondSlug, pageSlug); return this.comments.list(page.id, filter); } async createComment( user: User, token: ApiToken, pondSlug: string, pageSlug: string, input: CreateCommentInput, ): Promise { const page = await this.requirePage(pondSlug, pageSlug); const comment = await this.comments.create(user, page.id, input); await this.auditWrite(user, token, 'comment_created', comment.id); return comment; } async setCommentResolved( user: User, token: ApiToken, pondSlug: string, pageSlug: string, commentId: string, resolved: boolean, ): Promise { const page = await this.requirePage(pondSlug, pageSlug); // The path names the page — a comment id from elsewhere reads not-found, // whatever its own permissions would say (the opt-in gate is per pond). const row = await this.prisma.comment.findFirst({ where: { id: commentId, pageId: page.id }, select: { id: true }, }); if (!row) throw new NotFoundException(); const comment = await this.comments.setResolved(user, commentId, resolved); await this.auditWrite( user, token, resolved ? 'comment_resolved' : 'comment_reopened', commentId, ); return comment; } /** Live pond by slug as a full PondView (the guard already vetted opt-in). */ async requirePond(slug: string): Promise { const pond = await this.prisma.pond.findFirst({ where: { slug, deletedAt: null } }); if (!pond) throw new NotFoundException(); return { id: pond.id, slug: pond.slug, name: pond.name, description: pond.description, type: pond.type === 'PERSONAL' ? 'personal' : 'shared', ownerId: pond.ownerId, settings: pondSettingsSchema.parse(pond.settings ?? {}), createdAt: pond.createdAt.toISOString(), deletedAt: null, }; } private pondView(pond: PondView): PublicPondView { return { slug: pond.slug, name: pond.name, description: pond.description, type: pond.type, createdAt: pond.createdAt, }; } private async requirePage(pondSlug: string, pageSlug: string): Promise { const page = await this.prisma.page.findFirst({ where: { slug: pageSlug, deletedAt: null, pond: { slug: pondSlug, deletedAt: null } }, }); if (!page) throw new NotFoundException(); return page; } /** The parent's slug, or null when there is none or the user may not read * it (the same no-leak rule as the internal list, issue #106). */ private async readableParentSlug(user: User, page: Page): Promise { if (!page.parentId) return null; const parent = await this.prisma.page.findFirst({ where: { id: page.parentId, deletedAt: null }, }); if (!parent) return null; const readable = await this.permissions.canAccessPage(user, parent, 'read'); return readable ? parent.slug : null; } private async requireLabelInPond(pondSlug: string, labelId: string): Promise { const label = await this.prisma.label.findFirst({ where: { id: labelId, pond: { slug: pondSlug, deletedAt: null } }, select: { id: true }, }); if (!label) throw new NotFoundException(); } private async labelNames(pondId: string): Promise> { const labels = await this.prisma.label.findMany({ where: { pondId }, select: { id: true, name: true }, }); return new Map(labels.map((label) => [label.id, label.name])); } private async exposedPondIds( user: User, token: ApiToken, feature: PondExposureFeature, ): Promise> { const ponds = await this.listPondRowsExposed(user, token, feature); return new Set(ponds.map((pond) => pond.id)); } private async listPondRowsExposed( user: User, token: ApiToken, feature: PondExposureFeature, ): Promise { const visible = await this.ponds.listVisible(user); return visible .filter((pond) => pondFeatureEnabled(pond.settings, feature)) .filter((pond) => token.pondIds.length === 0 || token.pondIds.includes(pond.id)); } private stateFromMarkdown(markdown: string): Uint8Array { const json = markdownToDoc(markdown).toJSON(); return docToState(Node.fromJSON(editorSchema, json)); } private async auditWrite( user: User, token: ApiToken, op: string, targetId: string, ): Promise { await this.audit.record({ action: 'api.write', actorId: user.id, targetType: 'api_write', targetId, details: { op, tokenId: token.id, tokenName: token.name }, }); } }