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[]
|
identities UserIdentity[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
authTokens AuthToken[]
|
authTokens AuthToken[]
|
||||||
|
ponds Pond[]
|
||||||
|
|
||||||
@@map("users")
|
@@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
|
/// One row per login method. `provider` is "password" today and
|
||||||
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
|
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
|
||||||
/// password identities.
|
/// password identities.
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
* (test/int), set FIXTURE_ADMIN_PASSWORD / FIXTURE_USER_PASSWORD to give
|
* (test/int), set FIXTURE_ADMIN_PASSWORD / FIXTURE_USER_PASSWORD to give
|
||||||
* those two accounts non-public passwords.
|
* those two accounts non-public passwords.
|
||||||
*/
|
*/
|
||||||
|
import { slugify } from '@dorfteich/shared';
|
||||||
import { PrismaClient, UserStatus } from '@prisma/client';
|
import { PrismaClient, UserStatus } from '@prisma/client';
|
||||||
|
|
||||||
import { hashPassword } from '../src/users/password';
|
import { hashPassword } from '../src/users/password';
|
||||||
@ -73,6 +74,24 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise<void> {
|
|||||||
},
|
},
|
||||||
update: { credential },
|
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> {
|
async function main(): Promise<void> {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { AppConfig } from './config/app-config.service';
|
|||||||
import { ConfigModule } from './config/config.module';
|
import { ConfigModule } from './config/config.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
|
import { PondsModule } from './ponds/ponds.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
@ -22,6 +23,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
MailModule,
|
MailModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
PondsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
|
|||||||
@ -45,6 +45,8 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
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.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
|
||||||
import { MailModule } from '../mail/mail.module';
|
import { MailModule } from '../mail/mail.module';
|
||||||
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthGuard } from './auth.guard';
|
import { AuthGuard } from './auth.guard';
|
||||||
@ -10,7 +11,7 @@ import { AuthTokensService } from './auth-tokens.service';
|
|||||||
import { SessionsModule } from './sessions.module';
|
import { SessionsModule } from './sessions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, MailModule, SessionsModule],
|
imports: [UsersModule, MailModule, SessionsModule, PondsModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { RateLimitService } from '../rate-limit/rate-limit.service';
|
import { RateLimitService } from '../rate-limit/rate-limit.service';
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
@ -30,6 +31,7 @@ export class AuthService {
|
|||||||
private readonly tokens: AuthTokensService,
|
private readonly tokens: AuthTokensService,
|
||||||
private readonly sessions: SessionsService,
|
private readonly sessions: SessionsService,
|
||||||
private readonly mail: MailService,
|
private readonly mail: MailService,
|
||||||
|
private readonly ponds: PondsService,
|
||||||
private readonly rateLimits: RateLimitService,
|
private readonly rateLimits: RateLimitService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
private readonly settings: InstanceSettingsService,
|
private readonly settings: InstanceSettingsService,
|
||||||
@ -56,6 +58,9 @@ export class AuthService {
|
|||||||
await this.users.markEmailVerified(userId);
|
await this.users.markEmailVerified(userId);
|
||||||
this.logger.info({ userId }, 'audit: e-mail verified');
|
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. */
|
/** 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.",
|
"password_incorrect": "Das aktuelle Passwort ist falsch.",
|
||||||
"csrf_origin_mismatch": "Die Anfrage kam von einer unerwarteten Herkunft.",
|
"csrf_origin_mismatch": "Die Anfrage kam von einer unerwarteten Herkunft.",
|
||||||
"cannot_revoke_current_session": "Beende deine aktuelle Sitzung über die Abmeldung.",
|
"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.",
|
"network": "Der Server war nicht erreichbar.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "Dieses Feld ist erforderlich.",
|
"required": "Dieses Feld ist erforderlich.",
|
||||||
@ -36,6 +37,7 @@
|
|||||||
},
|
},
|
||||||
"displayName": {
|
"displayName": {
|
||||||
"required": "Bitte gib einen Anzeigenamen ein."
|
"required": "Bitte gib einen Anzeigenamen ein."
|
||||||
}
|
},
|
||||||
|
"tooLong": "Die Eingabe ist zu lang."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"password_incorrect": "The current password is incorrect.",
|
"password_incorrect": "The current password is incorrect.",
|
||||||
"csrf_origin_mismatch": "The request came from an unexpected origin.",
|
"csrf_origin_mismatch": "The request came from an unexpected origin.",
|
||||||
"cannot_revoke_current_session": "Use sign-out to end your current session.",
|
"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.",
|
"network": "The server could not be reached.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "This field is required.",
|
"required": "This field is required.",
|
||||||
@ -36,6 +37,7 @@
|
|||||||
},
|
},
|
||||||
"displayName": {
|
"displayName": {
|
||||||
"required": "Please enter a display name."
|
"required": "Please enter a display name."
|
||||||
}
|
},
|
||||||
|
"tooLong": "The input is too long."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,3 +3,4 @@ export * from './auth';
|
|||||||
export * from './env';
|
export * from './env';
|
||||||
export * from './health';
|
export * from './health';
|
||||||
export * from './i18n-tools';
|
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