Ponds: data model, CRUD API, personal pond on verification (#21)
All checks were successful
CD / Build and push images (push) Successful in 1m46s
CI / Lint, typecheck, test (push) Successful in 1m19s
CI / Auth e2e pack (push) Successful in 1m42s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m5s
CD / Promote to Int (push) Successful in 10s

- Pond model with pond-level trash columns (ADR 0013) and settings jsonb
  holding only deviations from the defaults (sidebar sort, font slots per
  ADR 0016); migration 20260705090100_ponds
- shared: pond schemas/views and slugify (German transliteration,
  URL-safe, length-capped); deterministic -2/-3 suffixes for collisions
- InterimAccessService: single place answering pond access questions
  until the real role model lands in M5
- POST/GET /ponds, GET /ponds/:slug, PATCH/DELETE /ponds/:id, Site-Admin
  trash + restore; personal pond auto-created on e-mail verification and
  for active seed fixtures; personal ponds cannot be trashed
- e2e pack covering verify-flow pond creation, slug suffixes, rename,
  foreign-pond 404s, trash/restore; slugify unit tests

Closes #21

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpQz6ypHJsLfMf4S6fyQEB
This commit is contained in:
Claude Fable 5 2026-07-05 11:08:16 +02:00
parent bef6d8e4dc
commit f0850eecd3
17 changed files with 700 additions and 3 deletions

View File

@ -0,0 +1,28 @@
-- CreateEnum
CREATE TYPE "PondType" AS ENUM ('PERSONAL', 'SHARED');
-- CreateTable
CREATE TABLE "ponds" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"type" "PondType" NOT NULL,
"owner_id" TEXT NOT NULL,
"settings" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
"deleted_by" TEXT,
CONSTRAINT "ponds_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ponds_slug_key" ON "ponds"("slug");
-- CreateIndex
CREATE INDEX "ponds_owner_id_idx" ON "ponds"("owner_id");
-- AddForeignKey
ALTER TABLE "ponds" ADD CONSTRAINT "ponds_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -45,10 +45,39 @@ model User {
identities UserIdentity[]
sessions Session[]
authTokens AuthToken[]
ponds Pond[]
@@map("users")
}
enum PondType {
PERSONAL
SHARED
}
/// Top-level content container (data-model.md §ponds). Personal ponds are
/// created automatically on e-mail verification; `settings` stores only
/// deviations from the defaults (pondSettingsSchema in @dorfteich/shared).
/// `deletedAt`/`deletedBy` implement the pond-level trash (ADR 0013).
model Pond {
id String @id @default(uuid())
slug String @unique
name String
description String @default("")
type PondType
ownerId String @map("owner_id")
settings Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by")
owner User @relation(fields: [ownerId], references: [id])
@@index([ownerId])
@@map("ponds")
}
/// One row per login method. `provider` is "password" today and
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
/// password identities.

View File

@ -12,6 +12,7 @@
* (test/int), set FIXTURE_ADMIN_PASSWORD / FIXTURE_USER_PASSWORD to give
* those two accounts non-public passwords.
*/
import { slugify } from '@dorfteich/shared';
import { PrismaClient, UserStatus } from '@prisma/client';
import { hashPassword } from '../src/users/password';
@ -73,6 +74,24 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise<void> {
},
update: { credential },
});
// Active accounts get their personal pond, mirroring what e-mail
// verification does for real signups (issue #21).
if (fixture.status === 'ACTIVE') {
const existing = await prisma.pond.findFirst({
where: { ownerId: user.id, type: 'PERSONAL' },
select: { id: true },
});
if (!existing) {
await prisma.pond.create({
data: {
slug: slugify(fixture.displayName) || fixture.username,
name: fixture.displayName,
type: 'PERSONAL',
ownerId: user.id,
},
});
}
}
}
async function main(): Promise<void> {

View File

@ -9,6 +9,7 @@ import { AppConfig } from './config/app-config.service';
import { ConfigModule } from './config/config.module';
import { HealthModule } from './health/health.module';
import { MailModule } from './mail/mail.module';
import { PondsModule } from './ponds/ponds.module';
import { PrismaModule } from './prisma/prisma.module';
import { RateLimitModule } from './rate-limit/rate-limit.module';
import { SettingsModule } from './settings/settings.module';
@ -22,6 +23,7 @@ import { UsersModule } from './users/users.module';
MailModule,
SettingsModule,
UsersModule,
PondsModule,
AuthModule,
AdminModule,
LoggerModule.forRootAsync({

View File

@ -45,6 +45,8 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
});
afterAll(async () => {
// Verified users own a personal pond (#21) — remove it before them.
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.$disconnect();

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { MailModule } from '../mail/mail.module';
import { PondsModule } from '../ponds/ponds.module';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthGuard } from './auth.guard';
@ -10,7 +11,7 @@ import { AuthTokensService } from './auth-tokens.service';
import { SessionsModule } from './sessions.module';
@Module({
imports: [UsersModule, MailModule, SessionsModule],
imports: [UsersModule, MailModule, SessionsModule, PondsModule],
controllers: [AuthController],
providers: [
AuthService,

View File

@ -10,6 +10,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { MailService } from '../mail/mail.service';
import { PondsService } from '../ponds/ponds.service';
import { PrismaService } from '../prisma/prisma.service';
import { RateLimitService } from '../rate-limit/rate-limit.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
@ -30,6 +31,7 @@ export class AuthService {
private readonly tokens: AuthTokensService,
private readonly sessions: SessionsService,
private readonly mail: MailService,
private readonly ponds: PondsService,
private readonly rateLimits: RateLimitService,
private readonly config: AppConfig,
private readonly settings: InstanceSettingsService,
@ -56,6 +58,9 @@ export class AuthService {
await this.users.markEmailVerified(userId);
this.logger.info({ userId }, 'audit: e-mail verified');
}
// Every verified account owns a personal pond (issue #21). Idempotent,
// so re-verification attempts and races cannot create duplicates.
await this.ponds.ensurePersonalPond(user);
}
/** Always succeeds outwardly — never reveals whether the address exists. */

View File

@ -0,0 +1,36 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Pond, Prisma, User } from '@prisma/client';
/**
* INTERIM access control, M2M4 only: a pond is visible and editable for
* its owner and for Site Admins, nobody else. The real role model
* (docs/architecture/permissions.md) arrives with M5 and replaces this
* service. Until then, every access question about ponds and their
* content MUST be asked here and nowhere else that keeps the M5 swap
* confined to this one file (issue #21 acceptance criterion).
*/
@Injectable()
export class InterimAccessService {
canSeePond(user: User, pond: Pond): boolean {
return user.isSiteAdmin || pond.ownerId === user.id;
}
canModifyPond(user: User, pond: Pond): boolean {
// Interim rule: seeing and modifying coincide until M5 separates roles.
return this.canSeePond(user, pond);
}
/** Prisma `where` fragment restricting pond queries to visible rows. */
visiblePondsWhere(user: User): Prisma.PondWhereInput {
return user.isSiteAdmin ? {} : { ownerId: user.id };
}
/** 404 — not 403 — so outsiders cannot probe which slugs exist. */
assertCanSee(user: User, pond: Pond | null): asserts pond is Pond {
if (!pond || !this.canSeePond(user, pond)) throw new NotFoundException();
}
assertCanModify(user: User, pond: Pond | null): asserts pond is Pond {
if (!pond || !this.canModifyPond(user, pond)) throw new NotFoundException();
}
}

View File

@ -0,0 +1,85 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import {
CreatePondInput,
PondView,
UpdatePondInput,
createPondInputSchema,
updatePondInputSchema,
} from '@dorfteich/shared';
import { Prisma } from '@prisma/client';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { PondsService } from './ponds.service';
/** Pond CRUD (issue #21). Access rules live in InterimAccessService. */
@Controller('ponds')
export class PondsController {
constructor(private readonly ponds: PondsService) {}
@Post()
async create(
@Body(new ZodValidationPipe(createPondInputSchema)) input: CreatePondInput,
@Req() request: AuthedRequest,
): Promise<PondView> {
return this.ponds.createShared(request.user!, input);
}
@Get()
async list(@Req() request: AuthedRequest): Promise<PondView[]> {
return this.ponds.listVisible(request.user!);
}
// Declared before ':slug' so "trash" is not read as a pond slug.
@Get('trash')
@UseGuards(SiteAdminGuard)
async listTrash(): Promise<PondView[]> {
return this.ponds.listTrash();
}
@Get(':slug')
async bySlug(@Param('slug') slug: string, @Req() request: AuthedRequest): Promise<PondView> {
return this.ponds.getVisibleBySlug(request.user!, slug);
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updatePondInputSchema)) input: UpdatePondInput,
@Req() request: AuthedRequest,
): Promise<PondView> {
return this.ponds.update(request.user!, id, input);
}
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.ponds.softDelete(request.user!, id);
}
@Post(':id/restore')
@UseGuards(SiteAdminGuard)
async restore(@Param('id') id: string): Promise<PondView> {
try {
return await this.ponds.restore(id);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
throw new NotFoundException();
}
throw error;
}
}
}

View File

@ -0,0 +1,189 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'teichbesitz mit stil 1';
const owner = { username: `pia-${suffix}`, displayName: `Pia Pond ${suffix}` };
const outsider = { username: `otto-${suffix}`, displayName: `Otto Outside ${suffix}` };
let ownerCookie: string;
let outsiderCookie: string;
let adminCookie: string;
const api = () => request(app.getHttpServer());
async function loginOf(username: string): Promise<string> {
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
return sessionCookieOf(res);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
// The owner goes through the real verify flow — that is what must
// produce the personal pond.
const ownerUser = await users.createUser({
username: owner.username,
email: `${owner.username}@example.org`,
displayName: owner.displayName,
password,
locale: 'de',
});
const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
ownerCookie = await loginOf(owner.username);
// The outsider doubles as Site Admin in the trash/restore tests.
const outsiderUser = await users.createUser({
username: outsider.username,
email: `${outsider.username}@example.org`,
displayName: outsider.displayName,
password,
locale: 'en',
});
await users.markEmailVerified(outsiderUser.id);
outsiderCookie = await loginOf(outsider.username);
});
afterAll(async () => {
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('creates the personal pond on e-mail verification', async () => {
const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
const personal = res.body.filter((p: { type: string }) => p.type === 'personal');
expect(personal).toHaveLength(1);
expect(personal[0].name).toBe(owner.displayName);
expect(personal[0].slug).toMatch(/^pia-pond-/);
expect(personal[0].settings.sidebarSort).toBe('alpha');
expect(personal[0].settings.fonts.body.family).toBe('Roboto');
});
it('re-verification does not duplicate the personal pond', async () => {
const tokens = app.get(AuthTokensService);
const user = await prisma.user.findUniqueOrThrow({ where: { username: owner.username } });
const token = await tokens.issue(user.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token }).expect(204);
const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1);
});
it('creates shared ponds with deterministic slug suffixes', async () => {
const name = `Gartenteich ${suffix}`;
const first = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name })
.expect(201);
const second = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name })
.expect(201);
expect(first.body.slug).toBe(`gartenteich-${suffix}`);
expect(second.body.slug).toBe(`gartenteich-${suffix}-2`);
expect(first.body.type).toBe('shared');
});
it('renames a pond without changing its slug', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Umbenannt ${suffix}`, description: 'vorher' })
.expect(201);
const patched = await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ name: `Neuer Name ${suffix}`, description: 'nachher', sidebarSort: 'created' })
.expect(200);
expect(patched.body.name).toBe(`Neuer Name ${suffix}`);
expect(patched.body.slug).toBe(created.body.slug);
expect(patched.body.description).toBe('nachher');
expect(patched.body.settings.sidebarSort).toBe('created');
const fetched = await api()
.get(`/api/v1/ponds/${created.body.slug}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(fetched.body.name).toBe(`Neuer Name ${suffix}`);
});
it('hides foreign ponds (list and slug lookup)', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Privatteich ${suffix}` })
.expect(201);
const list = await api().get('/api/v1/ponds').set('Cookie', outsiderCookie).expect(200);
expect(list.body.map((p: { id: string }) => p.id)).not.toContain(created.body.id);
await api().get(`/api/v1/ponds/${created.body.slug}`).set('Cookie', outsiderCookie).expect(404);
await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', outsiderCookie)
.send({ name: 'gekapert' })
.expect(404);
});
it('soft-deletes a shared pond; Site Admin sees trash and restores', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Wegwerfteich ${suffix}` })
.expect(201);
await api().delete(`/api/v1/ponds/${created.body.id}`).set('Cookie', ownerCookie).expect(204);
const list = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
expect(list.body.map((p: { id: string }) => p.id)).not.toContain(created.body.id);
await api().get(`/api/v1/ponds/${created.body.slug}`).set('Cookie', ownerCookie).expect(404);
// Trash endpoints are Site-Admin-only.
await api().get('/api/v1/ponds/trash').set('Cookie', ownerCookie).expect(403);
await prisma.user.update({
where: { username: outsider.username },
data: { isSiteAdmin: true },
});
adminCookie = await loginOf(outsider.username);
const trash = await api().get('/api/v1/ponds/trash').set('Cookie', adminCookie).expect(200);
expect(trash.body.map((p: { id: string }) => p.id)).toContain(created.body.id);
await api()
.post(`/api/v1/ponds/${created.body.id}/restore`)
.set('Cookie', adminCookie)
.expect(201);
const restored = await api()
.get(`/api/v1/ponds/${created.body.slug}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(restored.body.deletedAt).toBeNull();
});
it('refuses to delete the personal pond', async () => {
const list = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
const personal = list.body.find((p: { type: string }) => p.type === 'personal');
const res = await api()
.delete(`/api/v1/ponds/${personal.id}`)
.set('Cookie', ownerCookie)
.expect(403);
expect(res.body.code).toBe('personal_pond_undeletable');
});
});

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { InterimAccessService } from './interim-access.service';
import { PondsController } from './ponds.controller';
import { PondsService } from './ponds.service';
@Module({
controllers: [PondsController],
providers: [PondsService, InterimAccessService],
exports: [PondsService, InterimAccessService],
})
export class PondsModule {}

View File

@ -0,0 +1,158 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import {
CreatePondInput,
PondView,
UpdatePondInput,
pondSettingsSchema,
slugify,
} from '@dorfteich/shared';
import { Pond, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { PrismaService } from '../prisma/prisma.service';
import { InterimAccessService } from './interim-access.service';
@Injectable()
export class PondsService {
constructor(
private readonly prisma: PrismaService,
private readonly access: InterimAccessService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PondsService.name);
}
viewOf(pond: Pond): PondView {
return {
id: pond.id,
slug: pond.slug,
name: pond.name,
description: pond.description,
type: pond.type === 'PERSONAL' ? 'personal' : 'shared',
ownerId: pond.ownerId,
// Stored settings hold only deviations; the schema fills defaults.
settings: pondSettingsSchema.parse(pond.settings ?? {}),
createdAt: pond.createdAt.toISOString(),
deletedAt: pond.deletedAt?.toISOString() ?? null,
};
}
/**
* Deterministic unique slug: the base slug, else `base-2`, `base-3`,
* (never `-1`, so the unsuffixed original reads as number one). Deleted
* ponds keep their slug reserved restore must not collide.
*/
async generateUniqueSlug(base: string, fallback: string): Promise<string> {
const slug = slugify(base) || slugify(fallback) || 'pond';
const taken = new Set(
(
await this.prisma.pond.findMany({
where: { OR: [{ slug }, { slug: { startsWith: `${slug}-` } }] },
select: { slug: true },
})
).map((row) => row.slug),
);
if (!taken.has(slug)) return slug;
for (let n = 2; ; n += 1) {
const candidate = `${slug}-${n}`;
if (!taken.has(candidate)) return candidate;
}
}
async createShared(owner: User, input: CreatePondInput): Promise<PondView> {
const pond = await this.prisma.pond.create({
data: {
slug: await this.generateUniqueSlug(input.name, owner.username),
name: input.name,
description: input.description,
type: 'SHARED',
ownerId: owner.id,
},
});
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
return this.viewOf(pond);
}
/**
* Creates the personal pond on first e-mail verification (issue #21).
* Idempotent: a user has at most one personal pond, even a trashed one
* blocks re-creation (restore instead of duplicating).
*/
async ensurePersonalPond(user: User): Promise<void> {
const existing = await this.prisma.pond.findFirst({
where: { ownerId: user.id, type: 'PERSONAL' },
select: { id: true },
});
if (existing) return;
const pond = await this.prisma.pond.create({
data: {
slug: await this.generateUniqueSlug(user.displayName, user.username),
name: user.displayName,
type: 'PERSONAL',
ownerId: user.id,
},
});
this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created');
}
async listVisible(user: User): Promise<PondView[]> {
const ponds = await this.prisma.pond.findMany({
where: { ...this.access.visiblePondsWhere(user), deletedAt: null },
orderBy: { name: 'asc' },
});
return ponds.map((pond) => this.viewOf(pond));
}
async getVisibleBySlug(user: User, slug: string): Promise<PondView> {
const pond = await this.prisma.pond.findFirst({ where: { slug, deletedAt: null } });
this.access.assertCanSee(user, pond);
return this.viewOf(pond);
}
async update(user: User, id: string, input: UpdatePondInput): Promise<PondView> {
const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } });
this.access.assertCanModify(user, pond);
const settings =
input.sidebarSort === undefined
? undefined
: { ...(pond.settings as object), sidebarSort: input.sidebarSort };
const updated = await this.prisma.pond.update({
where: { id },
data: { name: input.name, description: input.description, settings },
});
return this.viewOf(updated);
}
async softDelete(user: User, id: string): Promise<void> {
const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } });
this.access.assertCanModify(user, pond);
if (pond.type === 'PERSONAL') {
// The personal pond is the account's home — it cannot be trashed.
throw new ForbiddenException({ code: 'personal_pond_undeletable' });
}
await this.prisma.pond.update({
where: { id },
data: { deletedAt: new Date(), deletedBy: user.id },
});
this.logger.info({ pondId: id, userId: user.id }, 'audit: pond trashed');
}
/** Site-Admin-only (guarded at the controller): the pond-level trash. */
async listTrash(): Promise<PondView[]> {
const ponds = await this.prisma.pond.findMany({
where: { deletedAt: { not: null } },
orderBy: { deletedAt: 'desc' },
});
return ponds.map((pond) => this.viewOf(pond));
}
/** Site-Admin-only (guarded at the controller). */
async restore(id: string): Promise<PondView> {
const pond = await this.prisma.pond.update({
where: { id },
data: { deletedAt: null, deletedBy: null },
});
this.logger.info({ pondId: id }, 'audit: pond restored');
return this.viewOf(pond);
}
}

View File

@ -0,0 +1,32 @@
import { slugify } from '@dorfteich/shared';
import { describe, expect, it } from 'vitest';
describe('slugify', () => {
it('lowercases and hyphenates', () => {
expect(slugify('Mein erster Teich')).toBe('mein-erster-teich');
});
it('transliterates German umlauts and ß', () => {
expect(slugify('Größe & Übermut')).toBe('groesse-uebermut');
expect(slugify('Ärger')).toBe('aerger');
});
it('strips other diacritics', () => {
expect(slugify('Café Résumé')).toBe('cafe-resume');
});
it('collapses separators and trims hyphens', () => {
expect(slugify(' Hello --- World! ')).toBe('hello-world');
});
it('returns an empty string when nothing survives', () => {
expect(slugify('💧💧💧')).toBe('');
expect(slugify('---')).toBe('');
});
it('caps the length without trailing hyphen', () => {
const slug = slugify(`${'a'.repeat(59)} b`);
expect(slug.length).toBeLessThanOrEqual(60);
expect(slug.endsWith('-')).toBe(false);
});
});

View File

@ -17,6 +17,7 @@
"password_incorrect": "Das aktuelle Passwort ist falsch.",
"csrf_origin_mismatch": "Die Anfrage kam von einer unerwarteten Herkunft.",
"cannot_revoke_current_session": "Beende deine aktuelle Sitzung über die Abmeldung.",
"personal_pond_undeletable": "Der persönliche Teich kann nicht gelöscht werden.",
"network": "Der Server war nicht erreichbar.",
"validation": {
"required": "Dieses Feld ist erforderlich.",
@ -36,6 +37,7 @@
},
"displayName": {
"required": "Bitte gib einen Anzeigenamen ein."
}
},
"tooLong": "Die Eingabe ist zu lang."
}
}

View File

@ -17,6 +17,7 @@
"password_incorrect": "The current password is incorrect.",
"csrf_origin_mismatch": "The request came from an unexpected origin.",
"cannot_revoke_current_session": "Use sign-out to end your current session.",
"personal_pond_undeletable": "The personal pond cannot be deleted.",
"network": "The server could not be reached.",
"validation": {
"required": "This field is required.",
@ -36,6 +37,7 @@
},
"displayName": {
"required": "Please enter a display name."
}
},
"tooLong": "The input is too long."
}
}

View File

@ -3,3 +3,4 @@ export * from './auth';
export * from './env';
export * from './health';
export * from './i18n-tools';
export * from './ponds';

View File

@ -0,0 +1,94 @@
import { z } from 'zod';
/**
* 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];
/** 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),
});
/**
* 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.
*/
export const pondSettingsSchema = z.object({
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
fonts: z
.object({
heading: fontSlotSchema.default({ family: 'Roboto', weight: 400 }),
body: fontSlotSchema.default({ family: 'Roboto', weight: 200 }),
mono: fontSlotSchema.default({ family: 'Fira Code', weight: 400 }),
})
.default({}),
});
export type PondSettings = z.infer<typeof pondSettingsSchema>;
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),
})
.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
.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(/-+$/, '');
}