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
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:
parent
bef6d8e4dc
commit
f0850eecd3
@ -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;
|
||||
@ -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.
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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. */
|
||||
|
||||
36
apps/api/src/ponds/interim-access.service.ts
Normal file
36
apps/api/src/ponds/interim-access.service.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
85
apps/api/src/ponds/ponds.controller.ts
Normal file
85
apps/api/src/ponds/ponds.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
189
apps/api/src/ponds/ponds.e2e.db.test.ts
Normal file
189
apps/api/src/ponds/ponds.e2e.db.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
12
apps/api/src/ponds/ponds.module.ts
Normal file
12
apps/api/src/ponds/ponds.module.ts
Normal 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 {}
|
||||
158
apps/api/src/ponds/ponds.service.ts
Normal file
158
apps/api/src/ponds/ponds.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
apps/api/src/ponds/slug.test.ts
Normal file
32
apps/api/src/ponds/slug.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,3 +3,4 @@ export * from './auth';
|
||||
export * from './env';
|
||||
export * from './health';
|
||||
export * from './i18n-tools';
|
||||
export * from './ponds';
|
||||
|
||||
94
packages/shared/src/ponds.ts
Normal file
94
packages/shared/src/ponds.ts
Normal 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(/-+$/, '');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user