dorfteich/packages/shared/src/ponds.ts
Claude Opus 5 45f1925917
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m28s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Auth e2e pack (pull_request) Failing after 4m1s
CI / Build container images (pull_request) Successful in 4m3s
#302: configurable pond start page, created with every new pond
Opening a pond landed on whatever sorted first in the sidebar — stable,
but a rule nobody could see, and one whose target moved as soon as
someone added a page ahead of it. New ponds landed on the empty-pond hint
instead of anything useful.

- `startPageId` joins the pond settings. No migration: `Pond.settings` is
  already jsonb. It stores an id, not a slug, so renaming or moving the
  page keeps it working.
- `PondHomePage` prefers it, but only when the page is in this user's
  page list. That list already holds just what they may see, so a start
  page hidden by a page-scoped grant — or trashed — falls back silently
  instead of landing them on a 404, and it costs no extra request.
- Both creation paths give the pond a start page, titled from the
  creator's stored locale. It happens after the creating transaction
  commits: the owner's grant is written inside it and permissions cache
  per pond, so creating the page any earlier would ask about rights the
  grant has not published yet. A failure is logged, not fatal — a pond
  without a start page still works.

`PagesModule` imported `PondsModule` without using it. Removing that
vestigial edge let PondsModule depend on PagesModule in the honest
direction instead of tying the two together with forwardRef.

Every pond created through the api now owns a page, which broke eight
suites whose teardown deleted ponds directly — `Page.pond` deliberately
has no cascade, because a real purge removes contents explicitly and
audits it. A shared `deletePondsWhere` helper deletes pages first. Two
tests that counted pages now account for the start page rather than
pretending the pond began empty.
2026-08-01 08:06:35 +02:00

157 lines
5.8 KiB
TypeScript

import { z } from 'zod';
import { COMMENT_POLICIES } from './comments';
/**
* Pond schemas and views shared between api and web (issue #21).
* Ponds are the top-level content container; every self-registered
* person owns exactly one personal pond (data-model.md §ponds).
*/
export const POND_TYPES = ['personal', 'shared'] as const;
export type PondType = (typeof POND_TYPES)[number];
export const SIDEBAR_SORT_MODES = ['alpha', 'created', 'manual'] as const;
export type SidebarSortMode = (typeof SIDEBAR_SORT_MODES)[number];
/** How the sidebar presents a pond's pages (issue #108): as the page tree
* (`folders`) or grouped under the label tree (`labels`). */
export const SIDEBAR_VIEW_MODES = ['folders', 'labels'] as const;
export type SidebarViewMode = (typeof SIDEBAR_VIEW_MODES)[number];
/** One font slot per ADR 0016; values reference the curated catalog. */
const fontSlotSchema = z.object({
family: z.string().min(1).max(80),
weight: z.number().int().min(100).max(900),
});
/** The three font slots, each defaulted to the vision fonts (ADR 0016). */
export const pondFontsSchema = z.object({
heading: fontSlotSchema.default({ family: 'Roboto', weight: 400 }),
body: fontSlotSchema.default({ family: 'Roboto', weight: 200 }),
mono: fontSlotSchema.default({ family: 'Fira Code', weight: 400 }),
});
export type PondFonts = z.infer<typeof pondFontsSchema>;
/**
* Pond `settings` jsonb. Parsing `{}` yields the documented defaults
* (sidebar sort `alpha`; fonts Roboto 400 / Roboto 200 / Fira Code,
* ADR 0016) — persisted settings therefore only need to store what the
* pond actually changed.
*/
/**
* Pond accent theme (issue #186, ADR 0018 stage C): `null` inherits the
* viewer's own theme. Only the accent family is pond-themable; the hex is
* validated here, WCAG conformance arises at render time via
* `deriveAccentTokens` — never as a server-side check.
*/
export const pondThemeSchema = z.object({
accent: z
.string()
.regex(/^#[0-9a-f]{6}$/i, 'validation.invalid')
.nullable()
.default(null),
});
export type PondTheme = z.infer<typeof pondThemeSchema>;
export const pondSettingsSchema = z.object({
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
/** The pond default for the sidebar's page presentation (issue #108);
* every member can override it locally (`ui.sidebar.view.<pondId>`). */
sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'),
fonts: pondFontsSchema.default({}),
/** Which page the pond opens on (issue #302). `null` keeps the historical
* behaviour — the first page in the sidebar's sort order, which is stable
* but invisible to the user and moves when a page sorts ahead of it. Held
* as an id, not a slug, so renaming or moving the page does not break it;
* a dangling id (page trashed) falls back rather than erroring. */
startPageId: z.string().uuid().nullable().default(null),
/** Who may write comments (issue #91): every reader, or editors only. */
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
/** Per-pond opt-in to the public REST API (issue #104, default off):
* without it the pond and its content answer 404 through the API even
* for a token whose user could see them in the app. */
apiEnabled: z.boolean().default(false),
/** Per-pond opt-in to the built-in MCP endpoint (issue #105, default
* off) — independent of the REST opt-in. */
mcpEnabled: z.boolean().default(false),
theme: pondThemeSchema.default({}),
});
export type PondSettings = z.infer<typeof pondSettingsSchema>;
/** The machine-access surfaces a pond can opt into (issues #104/#105). */
export type PondExposureFeature = 'api' | 'mcp';
export function pondFeatureEnabled(settings: PondSettings, feature: PondExposureFeature): boolean {
return feature === 'api' ? settings.apiEnabled : settings.mcpEnabled;
}
export const pondNameSchema = z
.string()
.trim()
.min(1, 'validation.required')
.max(80, 'validation.tooLong');
export const createPondInputSchema = z.object({
name: pondNameSchema,
description: z.string().trim().max(500, 'validation.tooLong').default(''),
});
export type CreatePondInput = z.infer<typeof createPondInputSchema>;
export const updatePondInputSchema = z
.object({
name: pondNameSchema,
description: z.string().trim().max(500, 'validation.tooLong'),
sidebarSort: z.enum(SIDEBAR_SORT_MODES),
sidebarView: z.enum(SIDEBAR_VIEW_MODES),
fonts: pondFontsSchema,
commentPolicy: z.enum(COMMENT_POLICIES),
apiEnabled: z.boolean(),
mcpEnabled: z.boolean(),
theme: pondThemeSchema,
startPageId: z.string().uuid().nullable(),
})
.partial();
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;
/** What the api returns for a pond; settings come fully defaulted. */
export interface PondView {
id: string;
slug: string;
name: string;
description: string;
type: PondType;
ownerId: string;
settings: PondSettings;
createdAt: string;
deletedAt: string | null;
}
const MAX_SLUG_LENGTH = 60;
/**
* Derives a URL-safe slug: German transliteration (ä→ae …), diacritics
* stripped, everything else collapsed to single hyphens. Returns '' when
* nothing survives — callers pick their fallback (e.g. the username).
* Uniqueness (numeric suffixes) is the caller's job, not slugify's.
*/
export function slugify(input: string): string {
return (
input
// Precompose first (#127): macOS file names arrive NFD-decomposed, and
// the umlaut digraphs below only match the precomposed forms.
.normalize('NFC')
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, MAX_SLUG_LENGTH)
.replace(/-+$/, '')
);
}