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.
157 lines
5.8 KiB
TypeScript
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(/-+$/, '')
|
|
);
|
|
}
|