diff --git a/apps/api/prisma/migrations/20260705090100_ponds/migration.sql b/apps/api/prisma/migrations/20260705090100_ponds/migration.sql new file mode 100644 index 0000000..606dad3 --- /dev/null +++ b/apps/api/prisma/migrations/20260705090100_ponds/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 57ad530..cc446e1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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:" later; `credential` holds the Argon2id hash for /// password identities. diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index e2c71ab..7339b55 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -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 { }, 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 { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cfba086..365e80b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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({ diff --git a/apps/api/src/auth/auth.e2e.db.test.ts b/apps/api/src/auth/auth.e2e.db.test.ts index f60c2e8..e92a8f2 100644 --- a/apps/api/src/auth/auth.e2e.db.test.ts +++ b/apps/api/src/auth/auth.e2e.db.test.ts @@ -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(); diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 7a0e394..d9c4887 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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, diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index edfb9d5..104a956 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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. */ diff --git a/apps/api/src/ponds/interim-access.service.ts b/apps/api/src/ponds/interim-access.service.ts new file mode 100644 index 0000000..6853e87 --- /dev/null +++ b/apps/api/src/ponds/interim-access.service.ts @@ -0,0 +1,36 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Pond, Prisma, User } from '@prisma/client'; + +/** + * INTERIM access control, M2–M4 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(); + } +} diff --git a/apps/api/src/ponds/ponds.controller.ts b/apps/api/src/ponds/ponds.controller.ts new file mode 100644 index 0000000..19ed7ae --- /dev/null +++ b/apps/api/src/ponds/ponds.controller.ts @@ -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 { + return this.ponds.createShared(request.user!, input); + } + + @Get() + async list(@Req() request: AuthedRequest): Promise { + 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 { + return this.ponds.listTrash(); + } + + @Get(':slug') + async bySlug(@Param('slug') slug: string, @Req() request: AuthedRequest): Promise { + 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 { + return this.ponds.update(request.user!, id, input); + } + + @Delete(':id') + @HttpCode(204) + async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.ponds.softDelete(request.user!, id); + } + + @Post(':id/restore') + @UseGuards(SiteAdminGuard) + async restore(@Param('id') id: string): Promise { + try { + return await this.ponds.restore(id); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { + throw new NotFoundException(); + } + throw error; + } + } +} diff --git a/apps/api/src/ponds/ponds.e2e.db.test.ts b/apps/api/src/ponds/ponds.e2e.db.test.ts new file mode 100644 index 0000000..b50b71d --- /dev/null +++ b/apps/api/src/ponds/ponds.e2e.db.test.ts @@ -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 { + 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'); + }); +}); diff --git a/apps/api/src/ponds/ponds.module.ts b/apps/api/src/ponds/ponds.module.ts new file mode 100644 index 0000000..5e55616 --- /dev/null +++ b/apps/api/src/ponds/ponds.module.ts @@ -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 {} diff --git a/apps/api/src/ponds/ponds.service.ts b/apps/api/src/ponds/ponds.service.ts new file mode 100644 index 0000000..37524a9 --- /dev/null +++ b/apps/api/src/ponds/ponds.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/api/src/ponds/slug.test.ts b/apps/api/src/ponds/slug.test.ts new file mode 100644 index 0000000..591b1ae --- /dev/null +++ b/apps/api/src/ponds/slug.test.ts @@ -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); + }); +}); diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index f55709d..1bbc320 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -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." } } diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index c38c4b8..66b6ea2 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -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." } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b8d3fcf..fcfe891 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,3 +3,4 @@ export * from './auth'; export * from './env'; export * from './health'; export * from './i18n-tools'; +export * from './ponds'; diff --git a/packages/shared/src/ponds.ts b/packages/shared/src/ponds.ts new file mode 100644 index 0000000..8c5cddb --- /dev/null +++ b/packages/shared/src/ponds.ts @@ -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; + +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; + +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; + +/** 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(/-+$/, ''); +}