From a64576367935389d807dc88f40688f20013114dc Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Wed, 8 Jul 2026 12:48:17 +0200 Subject: [PATCH] Add page trash: soft delete, restore, and purge job (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: a generic maintenance-job scheduler (SchedulerService, `jobs` table) that any later maintenance job registers with instead of growing its own timer loop. Due-ness and the run-mutex both live in the DB row (`lastRunAt` survives a restart; claiming a due job is one atomic `UPDATE ... WHERE status != 'RUNNING'`), and an injectable ClockService lets tests simulate retention elapsing without waiting or faking the global clock. Trash endpoints: GET /ponds/:id/trash (list), POST /pages/:id/restore, DELETE /pages/:id/purge (manual, bypasses retention) — all sharing the same purge logic as the scheduled daily job (default 30-day retention, new trash.retentionDays instance setting). Purging deletes a page's content cache, update log, and attachment files/quota; page_versions is a placeholder until M3 exists. Direct navigation to a trashed page now 404s with a distinguishable `page_trashed` code for editors (a plain 404 for everyone else) instead of the generic not-found. Attachment.pageId — added in #27 but never wired up — now gets set on every page state save to whichever page's document currently embeds the file, which is what lets purge find a page's files. Frontend: a per-pond trash view (restore/purge), a "move to trash" action with confirmation in the page menu, and a trash link in the sidebar for pond owners. Also fixes react-query retrying 4xx responses for several seconds by default, which was masking the trash-hint 404 in the UI (and would have affected any other not-found/permission error the same way). Closes #31 --- .../migration.sql | 15 + apps/api/prisma/schema.prisma | 48 ++- apps/api/src/app.module.ts | 2 + apps/api/src/common/clock.service.ts | 15 + apps/api/src/common/common.module.ts | 9 + apps/api/src/files/files.module.ts | 1 + apps/api/src/pages/pages.service.ts | 25 +- apps/api/src/pages/yjs-content.ts | 17 ++ apps/api/src/scheduler/scheduler.module.ts | 12 + .../scheduler/scheduler.service.db.test.ts | 85 ++++++ apps/api/src/scheduler/scheduler.service.ts | 118 ++++++++ .../src/settings/instance-settings.service.ts | 3 + apps/api/src/trash/trash.controller.ts | 28 ++ apps/api/src/trash/trash.e2e.db.test.ts | 280 ++++++++++++++++++ apps/api/src/trash/trash.module.ts | 35 +++ apps/api/src/trash/trash.service.ts | 103 +++++++ apps/web/e2e/trash.spec.ts | 99 +++++++ apps/web/src/App.tsx | 5 + apps/web/src/layout/Sidebar.tsx | 5 + apps/web/src/main.tsx | 14 +- apps/web/src/pages/PageEditorPage.tsx | 44 ++- apps/web/src/pages/TrashPage.tsx | 70 +++++ apps/web/src/styles/base.css | 35 +++ packages/shared/i18n/de/editor.json | 15 +- packages/shared/i18n/de/errors.json | 1 + packages/shared/i18n/en/editor.json | 15 +- packages/shared/i18n/en/errors.json | 1 + 27 files changed, 1078 insertions(+), 22 deletions(-) create mode 100644 apps/api/prisma/migrations/20260708101011_trash_and_jobs/migration.sql create mode 100644 apps/api/src/common/clock.service.ts create mode 100644 apps/api/src/common/common.module.ts create mode 100644 apps/api/src/scheduler/scheduler.module.ts create mode 100644 apps/api/src/scheduler/scheduler.service.db.test.ts create mode 100644 apps/api/src/scheduler/scheduler.service.ts create mode 100644 apps/api/src/trash/trash.controller.ts create mode 100644 apps/api/src/trash/trash.e2e.db.test.ts create mode 100644 apps/api/src/trash/trash.module.ts create mode 100644 apps/api/src/trash/trash.service.ts create mode 100644 apps/web/e2e/trash.spec.ts create mode 100644 apps/web/src/pages/TrashPage.tsx diff --git a/apps/api/prisma/migrations/20260708101011_trash_and_jobs/migration.sql b/apps/api/prisma/migrations/20260708101011_trash_and_jobs/migration.sql new file mode 100644 index 0000000..2d5a8d9 --- /dev/null +++ b/apps/api/prisma/migrations/20260708101011_trash_and_jobs/migration.sql @@ -0,0 +1,15 @@ +-- CreateEnum +CREATE TYPE "JobStatus" AS ENUM ('IDLE', 'RUNNING', 'FAILED'); + +-- CreateTable +CREATE TABLE "jobs" ( + "name" TEXT NOT NULL, + "cadence_seconds" INTEGER NOT NULL, + "status" "JobStatus" NOT NULL DEFAULT 'IDLE', + "last_run_at" TIMESTAMP(3), + "locked_at" TIMESTAMP(3), + "last_error" TEXT, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "jobs_pkey" PRIMARY KEY ("name") +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 477b693..72df17e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -180,14 +180,17 @@ model PondUsage { /// Uploaded file (ADR 0011, issue #27). Bytes live on the uploads volume at /// `//` (FileStorageService); this row carries the -/// metadata needed to serve and account for it. `pageId` is nullable and -/// left unset by #27's endpoints — images are uploaded before the page -/// referencing them is known (paste-then-insert, issue #28); a later story -/// wires pages to set it once they track their embedded attachments. -/// `deletedAt` is unused by #27 (its `DELETE /files/:id` hard-deletes -/// immediately, bytes and all) — reserved for the page-trash-purge flow -/// (#31, ADR 0011 "orphan cleanup"), which soft-deletes an attachment when -/// its page is purged before the nightly job physically removes it. +/// metadata needed to serve and account for it. `pageId` starts unset — +/// images are uploaded before the page referencing them is known +/// (paste-then-insert, issue #28) — and is set on every page state save to +/// whichever page's document currently embeds the file (issue #31, +/// `PagesService.saveState`); the trash-purge job uses that link to delete +/// a purged page's files. Not touched when an image is later removed from +/// its page's content — an orphan-file sweep to reclaim those is a +/// separate future maintenance job (operations.md), not this one. +/// `deletedAt` stays unused for now — purge hard-deletes attachments +/// directly rather than soft-deleting them first — reserved for that same +/// future orphan-sweep job. model Attachment { id String @id @default(uuid()) pondId String @map("pond_id") @@ -299,3 +302,32 @@ model MailOutbox { @@index([status, nextAttemptAt]) @@map("mail_outbox") } + +enum JobStatus { + IDLE + RUNNING + FAILED +} + +/// Generic maintenance-job bookkeeping (data-model.md, operations.md; +/// issue #31). One row per named job; `SchedulerService` is the only +/// writer. `status`/`lockedAt` double as the run-mutex: claiming a due job +/// is a single atomic `UPDATE ... WHERE status != 'RUNNING'`, which is safe +/// under concurrent processes without needing a session-scoped advisory +/// lock (Prisma doesn't guarantee one connection across separate calls). +/// `lastRunAt` is what makes the schedule survive an api restart — cadence +/// is computed from it, not from an in-memory timer start time. The mail +/// outbox worker (#12) predates this table and still runs its own loop; +/// folding it in is left for whenever that file is next touched, not this +/// issue's job to do. +model Job { + name String @id + cadenceSeconds Int @map("cadence_seconds") + status JobStatus @default(IDLE) + lastRunAt DateTime? @map("last_run_at") + lockedAt DateTime? @map("locked_at") + lastError String? @map("last_error") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("jobs") +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index bbabaca..c063409 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -15,6 +15,7 @@ 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'; +import { TrashModule } from './trash/trash.module'; import { UsersModule } from './users/users.module'; @Module({ @@ -28,6 +29,7 @@ import { UsersModule } from './users/users.module'; PondsModule, PagesModule, FilesModule, + TrashModule, AuthModule, AdminModule, LoggerModule.forRootAsync({ diff --git a/apps/api/src/common/clock.service.ts b/apps/api/src/common/clock.service.ts new file mode 100644 index 0000000..c8c6ff0 --- /dev/null +++ b/apps/api/src/common/clock.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; + +/** + * Injectable wall clock. Anything that needs to reason about "now" for + * retention/cadence math should go through this instead of calling `new + * Date()` directly, so tests can simulate time passing (issue #31's + * time-travel purge test) by overriding `now()` on the DI-provided + * instance instead of faking the global clock. + */ +@Injectable() +export class ClockService { + now(): Date { + return new Date(); + } +} diff --git a/apps/api/src/common/common.module.ts b/apps/api/src/common/common.module.ts new file mode 100644 index 0000000..dd6305c --- /dev/null +++ b/apps/api/src/common/common.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { ClockService } from './clock.service'; + +@Module({ + providers: [ClockService], + exports: [ClockService], +}) +export class CommonModule {} diff --git a/apps/api/src/files/files.module.ts b/apps/api/src/files/files.module.ts index 51499cf..26665e0 100644 --- a/apps/api/src/files/files.module.ts +++ b/apps/api/src/files/files.module.ts @@ -11,5 +11,6 @@ import { FilesService } from './files.service'; imports: [PondsModule, QuotasModule], controllers: [FilesController], providers: [FilesService, FileStorageService], + exports: [FileStorageService], }) export class FilesModule {} diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index b523fcf..085e545 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -163,11 +163,19 @@ export class PagesService { async getStateBySlug(user: User, pondId: string, slug: string): Promise { const page = await this.prisma.page.findFirst({ - where: { pondId, slug, deletedAt: null }, + where: { pondId, slug }, include: { pond: true }, }); - if (!page) throw new NotFoundException(); - this.access.assertCanSee(user, page.pond); + if (!page || !this.access.canSeePond(user, page.pond)) throw new NotFoundException(); + if (page.deletedAt) { + // A plain 404 for anyone without edit rights — same as "does not + // exist" — but editors get a distinguishable code so the UI can + // offer a restore link instead of a dead end (issue #31). + if (this.access.canModifyPond(user, page.pond)) { + throw new NotFoundException({ code: 'page_trashed', details: { pageId: page.id } }); + } + throw new NotFoundException(); + } return this.stateViewOf(page); } @@ -203,6 +211,17 @@ export class PagesService { }, }, }); + if (content.imageFileIds.length > 0) { + // Keeps Attachment.pageId pointed at whichever page currently embeds + // the file (issue #31) — scoped to this pond so a client can't point + // an id at someone else's attachment. Not unlinked when an image is + // later removed from the content; see the schema comment on + // Attachment.pageId for why that's an accepted gap for now. + await this.prisma.attachment.updateMany({ + where: { id: { in: content.imageFileIds }, pondId: page.pondId }, + data: { pageId: page.id }, + }); + } this.logger.info({ pageId: id, userId: user.id }, 'audit: page state saved'); return this.stateViewOf(updated); } diff --git a/apps/api/src/pages/yjs-content.ts b/apps/api/src/pages/yjs-content.ts index 31043a6..ff82f5b 100644 --- a/apps/api/src/pages/yjs-content.ts +++ b/apps/api/src/pages/yjs-content.ts @@ -53,6 +53,22 @@ export interface DerivedPageContent { markdown: string; html: string; outline: OutlineEntry[]; + /** fileIds of every `image` node currently embedded in the document + * (issue #31) — `PagesService.saveState` uses this to keep + * `Attachment.pageId` pointed at whichever page's content actually + * embeds the file, which is what the trash-purge job uses to find a + * purged page's files. */ + imageFileIds: string[]; +} + +function imageFileIdsOf(doc: Node): string[] { + const ids: string[] = []; + doc.descendants((node) => { + if (node.type.name === 'image' && typeof node.attrs.fileId === 'string') { + ids.push(node.attrs.fileId); + } + }); + return ids; } /** @@ -67,5 +83,6 @@ export function deriveContent(state: Uint8Array): DerivedPageContent { markdown: docToMarkdown(doc), html: docToHtml(doc), outline: extractOutline(doc), + imageFileIds: imageFileIdsOf(doc), }; } diff --git a/apps/api/src/scheduler/scheduler.module.ts b/apps/api/src/scheduler/scheduler.module.ts new file mode 100644 index 0000000..56d2edd --- /dev/null +++ b/apps/api/src/scheduler/scheduler.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { CommonModule } from '../common/common.module'; + +import { SchedulerService } from './scheduler.service'; + +@Module({ + imports: [CommonModule], + providers: [SchedulerService], + exports: [SchedulerService], +}) +export class SchedulerModule {} diff --git a/apps/api/src/scheduler/scheduler.service.db.test.ts b/apps/api/src/scheduler/scheduler.service.db.test.ts new file mode 100644 index 0000000..1791520 --- /dev/null +++ b/apps/api/src/scheduler/scheduler.service.db.test.ts @@ -0,0 +1,85 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { JobDefinition, SchedulerService } from './scheduler.service'; + +describe.skipIf(!hasTestDb)('SchedulerService (db, issue #31)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let scheduler: SchedulerService; + const jobName = `test-job-${uniqueSuffix()}`; + + beforeAll(async () => { + prisma = createTestPrisma(); + app = await createTestApp(); + scheduler = app.get(SchedulerService); + }); + + afterAll(async () => { + await prisma.job.deleteMany({ where: { name: { startsWith: 'test-job-' } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('never runs the same job twice concurrently (locking)', async () => { + let runs = 0; + const job: JobDefinition = { + name: jobName, + cadenceSeconds: 3600, + run: async () => { + runs += 1; + await new Promise((resolve) => setTimeout(resolve, 150)); + }, + }; + + // Two overlapping ticks racing the same due job — only one may win. + await Promise.all([scheduler.runIfDue(job), scheduler.runIfDue(job)]); + expect(runs).toBe(1); + + const row = await prisma.job.findUniqueOrThrow({ where: { name: jobName } }); + expect(row.status).toBe('IDLE'); + }); + + it('persists lastRunAt so a fresh scheduler instance respects cadence (restart survival)', async () => { + const job: JobDefinition = { name: jobName, cadenceSeconds: 3600, run: async () => {} }; + const before = await prisma.job.findUniqueOrThrow({ where: { name: jobName } }); + + // A brand-new NestJS application = a brand-new SchedulerService with + // empty in-memory state, exactly like a real process restart. It must + // still treat the job as "not due yet" because that lives in the DB. + const restarted = await createTestApp(); + try { + const freshScheduler = restarted.get(SchedulerService); + await freshScheduler.runIfDue(job); + const after = await prisma.job.findUniqueOrThrow({ where: { name: jobName } }); + expect(after.lastRunAt?.getTime()).toBe(before.lastRunAt?.getTime()); + } finally { + await restarted.close(); + } + }); + + it('recovers a stale RUNNING lock instead of blocking forever', async () => { + const staleName = `test-job-stale-${uniqueSuffix()}`; + await prisma.job.create({ + data: { + name: staleName, + cadenceSeconds: 60, + status: 'RUNNING', + lockedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2h ago + lastRunAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + }, + }); + let ran = false; + await scheduler.runIfDue({ + name: staleName, + cadenceSeconds: 60, + run: async () => { + ran = true; + }, + }); + expect(ran).toBe(true); + }); +}); diff --git a/apps/api/src/scheduler/scheduler.service.ts b/apps/api/src/scheduler/scheduler.service.ts new file mode 100644 index 0000000..7e54394 --- /dev/null +++ b/apps/api/src/scheduler/scheduler.service.ts @@ -0,0 +1,118 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { ClockService } from '../common/clock.service'; +import { AppConfig } from '../config/app-config.service'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface JobDefinition { + /** Stable identity — also the `jobs` table primary key. */ + name: string; + cadenceSeconds: number; + run: () => Promise; +} + +/** How often the scheduler checks which registered jobs are due. */ +const TICK_MS = 60_000; +/** A job stuck `RUNNING` past this (crashed process, never released) is + * treated as available again rather than blocked forever. */ +const STALE_LOCK_MS = 60 * 60_000; + +/** + * Generic maintenance-job scheduler (issue #31; data-model.md/`jobs`, + * operations.md's job table). Every later maintenance job (version + * thinning, compaction, quota reconciliation, orphan sweep, …) registers + * here instead of growing its own timer loop. + * + * Due-ness and the run-mutex both live in the `jobs` table, not in + * memory: `lastRunAt` is what makes the schedule survive an api restart, + * and claiming a due job is a single atomic + * `UPDATE ... WHERE status != 'RUNNING'` — the same "row as mutex" + * technique works whether it's this process racing itself (two ticks + * overlapping because a job ran long) or, defensively, two processes. + */ +@Injectable() +export class SchedulerService implements OnModuleInit, OnModuleDestroy { + private readonly jobs = new Map(); + private timer: NodeJS.Timeout | undefined; + + constructor( + private readonly prisma: PrismaService, + private readonly clock: ClockService, + private readonly config: AppConfig, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(SchedulerService.name); + } + + register(job: JobDefinition): void { + this.jobs.set(job.name, job); + } + + onModuleInit(): void { + if (this.config.env.NODE_ENV === 'test') return; // tests drive jobs directly + this.timer = setInterval(() => void this.tick(), TICK_MS); + this.timer.unref(); + } + + onModuleDestroy(): void { + if (this.timer) clearInterval(this.timer); + } + + /** One scheduling pass over every registered job; public for tests/manual triggers. */ + async tick(): Promise { + for (const job of this.jobs.values()) { + await this.runIfDue(job); + } + } + + /** Runs `job` now if due and not already running elsewhere; a no-op otherwise. */ + async runIfDue(job: JobDefinition): Promise { + try { + await this.prisma.job.upsert({ + where: { name: job.name }, + create: { name: job.name, cadenceSeconds: job.cadenceSeconds }, + update: {}, + }); + } catch (error) { + // Two overlapping ticks can both try to create the row for a + // never-seen-before job at once; whichever loses just means the row + // already exists now, which is exactly what this call wants anyway. + const isDuplicate = + error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'; + if (!isDuplicate) throw error; + } + + const now = this.clock.now(); + const dueBefore = new Date(now.getTime() - job.cadenceSeconds * 1000); + const staleLockBefore = new Date(now.getTime() - STALE_LOCK_MS); + + const claim = await this.prisma.job.updateMany({ + where: { + name: job.name, + AND: [ + { OR: [{ lastRunAt: null }, { lastRunAt: { lte: dueBefore } }] }, + { OR: [{ status: { not: 'RUNNING' } }, { lockedAt: { lte: staleLockBefore } }] }, + ], + }, + data: { status: 'RUNNING', lockedAt: now, lastRunAt: now }, + }); + if (claim.count === 0) return; // not due, or another run already holds it + + try { + await job.run(); + await this.prisma.job.update({ + where: { name: job.name }, + data: { status: 'IDLE', lastError: null }, + }); + } catch (error) { + const message = error instanceof Error ? error.message.slice(0, 500) : String(error); + this.logger.error({ job: job.name, err: error }, 'maintenance job failed'); + await this.prisma.job.update({ + where: { name: job.name }, + data: { status: 'FAILED', lastError: message }, + }); + } + } +} diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index 3d3be32..3752013 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -29,6 +29,9 @@ export const INSTANCE_SETTINGS = { .int() .min(0) .default(25 * 1024 * 1024), + // Trash retention (ADR 0013, issue #31): days a soft-deleted page stays + // restorable before the daily purge job removes it for good. + 'trash.retentionDays': z.number().int().min(1).default(30), } as const; export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS; diff --git a/apps/api/src/trash/trash.controller.ts b/apps/api/src/trash/trash.controller.ts new file mode 100644 index 0000000..4eeec66 --- /dev/null +++ b/apps/api/src/trash/trash.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common'; +import { PageView } from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; + +import { TrashService } from './trash.service'; + +/** Page trash: list, restore, purge-single (issue #31). */ +@Controller() +export class TrashController { + constructor(private readonly trash: TrashService) {} + + @Get('ponds/:pondId/trash') + async list(@Param('pondId') pondId: string, @Req() request: AuthedRequest): Promise { + return this.trash.list(request.user!, pondId); + } + + @Post('pages/:id/restore') + async restore(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + return this.trash.restore(request.user!, id); + } + + @Delete('pages/:id/purge') + @HttpCode(204) + async purge(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.trash.purgeNow(request.user!, id); + } +} diff --git a/apps/api/src/trash/trash.e2e.db.test.ts b/apps/api/src/trash/trash.e2e.db.test.ts new file mode 100644 index 0000000..de66370 --- /dev/null +++ b/apps/api/src/trash/trash.e2e.db.test.ts @@ -0,0 +1,280 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { INestApplication } from '@nestjs/common'; +import { editorSchema } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { prosemirrorJSONToYXmlFragment } from 'y-prosemirror'; +import * as Y from 'yjs'; + +import { AuthTokensService } from '../auth/auth-tokens.service'; +import { ClockService } from '../common/clock.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; +import { SchedulerService } from '../scheduler/scheduler.service'; +import { TrashService } from './trash.service'; + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const pngBuffer = (payload = 'trash test png'): Buffer => + Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]); + +/** Encodes a one-paragraph doc embedding `fileId` as an image. */ +function stateWithImage(fileId: string): string { + const ydoc = new Y.Doc(); + const fragment = ydoc.getXmlFragment('default'); + const doc = editorSchema.node('doc', null, [ + editorSchema.node('paragraph', null, [ + editorSchema.node('image', { fileId, alt: 'trash test', width: null }), + ]), + ]); + prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment); + const state = Buffer.from(Y.encodeStateAsUpdate(ydoc)).toString('base64'); + ydoc.destroy(); + return state; +} + +describe.skipIf(!hasTestDb)('page trash (e2e, issue #31)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'trash it like its hot 1'; + + const owner = { username: `tina-trash-${suffix}`, displayName: `Tina Trash ${suffix}` }; + const outsider = { username: `otto-trash-${suffix}`, displayName: `Otto Outside ${suffix}` }; + let ownerCookie: string; + let outsiderCookie: string; + let pondId: 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); + + const ownerUser = await users.createUser({ + username: owner.username, + email: `${owner.username}@example.org`, + displayName: owner.displayName, + password, + locale: 'en', + }); + 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); + + 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); + + const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); + pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id; + }); + + afterAll(async () => { + await prisma.attachment.deleteMany({ where: { pondId } }); + await prisma.page.deleteMany({ + where: { pond: { owner: { username: { contains: suffix } } } }, + }); + const users = await prisma.user.findMany({ + where: { username: { contains: suffix } }, + select: { id: true }, + }); + await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: users.map((u) => u.id) } } }); + await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('excludes a soft-deleted page from the sidebar and 404s the direct URL, with a trash hint for editors', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Trashed Sidebar ${suffix}` }) + .expect(201); + + await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); + + const list = await api() + .get(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .expect(200); + expect((list.body as { id: string }[]).some((p) => p.id === created.body.id)).toBe(false); + + const ownerView = await api() + .get(`/api/v1/ponds/${pondId}/pages/${created.body.slug}`) + .set('Cookie', ownerCookie) + .expect(404); + expect(ownerView.body.code).toBe('page_trashed'); + expect(ownerView.body.details.pageId).toBe(created.body.id); + + const outsiderView = await api() + .get(`/api/v1/ponds/${pondId}/pages/${created.body.slug}`) + .set('Cookie', outsiderCookie) + .expect(404); + expect(outsiderView.body.code).not.toBe('page_trashed'); + }); + + it('lists a pond trash, visible only to editors', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Trash List ${suffix}` }) + .expect(201); + await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); + + const trash = await api() + .get(`/api/v1/ponds/${pondId}/trash`) + .set('Cookie', ownerCookie) + .expect(200); + expect((trash.body as { id: string }[]).some((p) => p.id === created.body.id)).toBe(true); + + await api().get(`/api/v1/ponds/${pondId}/trash`).set('Cookie', outsiderCookie).expect(404); + }); + + it('restore brings content and files back intact', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Restore Me ${suffix}` }) + .expect(201); + + const uploaded = await api() + .post(`/api/v1/ponds/${pondId}/files`) + .set('Cookie', ownerCookie) + .attach('file', pngBuffer(), 'restore.png') + .expect(201); + + await api() + .put(`/api/v1/pages/${created.body.id}/state`) + .set('Cookie', ownerCookie) + .send({ state: stateWithImage(uploaded.body.id) }) + .expect(200); + + // The state save links the embedded image to this page (issue #31). + const linked = await prisma.attachment.findUniqueOrThrow({ where: { id: uploaded.body.id } }); + expect(linked.pageId).toBe(created.body.id); + + await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); + await api() + .post(`/api/v1/pages/${created.body.id}/restore`) + .set('Cookie', ownerCookie) + .expect(201); + + const restoredPage = await api() + .get(`/api/v1/pages/${created.body.id}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(restoredPage.body.deletedAt).toBeNull(); + expect(Buffer.from(restoredPage.body.state, 'base64').length).toBeGreaterThan(0); + + await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200); + }); + + it('purges a single page on demand, removing rows and files', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Purge Me ${suffix}` }) + .expect(201); + const uploaded = await api() + .post(`/api/v1/ponds/${pondId}/files`) + .set('Cookie', ownerCookie) + .attach('file', pngBuffer(), 'purge.png') + .expect(201); + await api() + .put(`/api/v1/pages/${created.body.id}/state`) + .set('Cookie', ownerCookie) + .send({ state: stateWithImage(uploaded.body.id) }) + .expect(200); + + const filePath = join(process.env.UPLOADS_DIR!, pondId, uploaded.body.id); + expect(existsSync(filePath)).toBe(true); + const usageBefore = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } }); + + await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); + await api() + .delete(`/api/v1/pages/${created.body.id}/purge`) + .set('Cookie', ownerCookie) + .expect(204); + + expect(await prisma.page.findUnique({ where: { id: created.body.id } })).toBeNull(); + expect(await prisma.attachment.findUnique({ where: { id: uploaded.body.id } })).toBeNull(); + expect(existsSync(filePath)).toBe(false); + + const usageAfter = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } }); + expect(Number(usageAfter.storageBytesUsed)).toBeLessThan(Number(usageBefore.storageBytesUsed)); + + // Purging a page that no longer exists (or was never trashed) 404s. + await api() + .delete(`/api/v1/pages/${created.body.id}/purge`) + .set('Cookie', ownerCookie) + .expect(404); + }); + + it('the scheduled job purges only pages past retention (time-travel via injected clock)', async () => { + const clock = app.get(ClockService); + const originalNow = clock.now.bind(clock); + const scheduler = app.get(SchedulerService); + const trash = app.get(TrashService); + + // Both deleted at real "now" — a few milliseconds apart at most. "old" + // is backdated only 20 days (still within the 30-day default + // retention under real wall-clock time), so the test can prove the + // job's purge/no-purge split comes from the *injected* clock and not + // from how much real time happened to pass while the test ran. + const recent = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Recent Trash ${suffix}` }) + .expect(201); + const old = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Old Trash ${suffix}` }) + .expect(201); + await api().delete(`/api/v1/pages/${recent.body.id}`).set('Cookie', ownerCookie).expect(204); + await api().delete(`/api/v1/pages/${old.body.id}`).set('Cookie', ownerCookie).expect(204); + const twentyDaysAgo = new Date(originalNow().getTime() - 20 * 24 * 60 * 60 * 1000); + await prisma.page.update({ where: { id: old.body.id }, data: { deletedAt: twentyDaysAgo } }); + + try { + // Travel 15 days into the future: "old" is now 35 days past its + // deletion (past the 30-day retention), "recent" only 15. + const fifteenDaysLater = new Date(originalNow().getTime() + 15 * 24 * 60 * 60 * 1000); + clock.now = () => fifteenDaysLater; + + await scheduler.runIfDue({ + name: 'trash-purge-test-run', + cadenceSeconds: 0, + run: () => trash.purgeDuePages(), + }); + + expect(await prisma.page.findUnique({ where: { id: old.body.id } })).toBeNull(); + expect(await prisma.page.findUnique({ where: { id: recent.body.id } })).not.toBeNull(); + } finally { + clock.now = originalNow; + await prisma.job.deleteMany({ where: { name: 'trash-purge-test-run' } }); + } + }); +}); diff --git a/apps/api/src/trash/trash.module.ts b/apps/api/src/trash/trash.module.ts new file mode 100644 index 0000000..9110e3c --- /dev/null +++ b/apps/api/src/trash/trash.module.ts @@ -0,0 +1,35 @@ +import { Module, OnModuleInit } from '@nestjs/common'; + +import { CommonModule } from '../common/common.module'; +import { FilesModule } from '../files/files.module'; +import { PagesModule } from '../pages/pages.module'; +import { PondsModule } from '../ponds/ponds.module'; +import { QuotasModule } from '../quotas/quotas.module'; +import { SchedulerModule } from '../scheduler/scheduler.module'; +import { SchedulerService } from '../scheduler/scheduler.service'; + +import { TrashController } from './trash.controller'; +import { TrashService } from './trash.service'; + +/** Daily, per operations.md's maintenance-jobs table (ADR 0013). */ +const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60; + +@Module({ + imports: [CommonModule, PondsModule, QuotasModule, FilesModule, PagesModule, SchedulerModule], + controllers: [TrashController], + providers: [TrashService], +}) +export class TrashModule implements OnModuleInit { + constructor( + private readonly scheduler: SchedulerService, + private readonly trash: TrashService, + ) {} + + onModuleInit(): void { + this.scheduler.register({ + name: 'trash-purge', + cadenceSeconds: TRASH_PURGE_CADENCE_SECONDS, + run: () => this.trash.purgeDuePages(), + }); + } +} diff --git a/apps/api/src/trash/trash.service.ts b/apps/api/src/trash/trash.service.ts new file mode 100644 index 0000000..a92173d --- /dev/null +++ b/apps/api/src/trash/trash.service.ts @@ -0,0 +1,103 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PageView } from '@dorfteich/shared'; +import { User } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { ClockService } from '../common/clock.service'; +import { PagesService } from '../pages/pages.service'; +import { InterimAccessService } from '../ponds/interim-access.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { QuotaService } from '../quotas/quota.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; +import { FileStorageService } from '../files/file-storage.service'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Page trash: soft delete (already done by `PagesService.softDelete`, + * issue #23), list/restore/purge-single, and the scheduled purge job + * (issue #31, ADR 0013). Deleting a page never touches its content or + * files — only `purgePage` does, so restore is always intact by + * construction as long as purge hasn't run yet. + */ +@Injectable() +export class TrashService { + constructor( + private readonly prisma: PrismaService, + private readonly access: InterimAccessService, + private readonly pages: PagesService, + private readonly settings: InstanceSettingsService, + private readonly quotas: QuotaService, + private readonly storage: FileStorageService, + private readonly clock: ClockService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(TrashService.name); + } + + /** A pond's trash — same access rule as restoring/purging from it. */ + async list(user: User, pondId: string): Promise { + const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); + this.access.assertCanModify(user, pond); + const pages = await this.prisma.page.findMany({ + where: { pondId, deletedAt: { not: null } }, + orderBy: { deletedAt: 'desc' }, + }); + return pages.map((page) => this.pages.viewOf(page)); + } + + async restore(user: User, id: string): Promise { + const page = await this.prisma.page.findFirst({ where: { id }, include: { pond: true } }); + if (!page || !page.deletedAt) throw new NotFoundException(); + this.access.assertCanModify(user, page.pond); + const restored = await this.prisma.page.update({ + where: { id }, + data: { deletedAt: null, deletedBy: null }, + }); + this.logger.info({ pageId: id, userId: user.id }, 'audit: page restored from trash'); + return this.pages.viewOf(restored); + } + + /** Manual "purge single" (scope's third trash endpoint) — bypasses retention. */ + async purgeNow(user: User, id: string): Promise { + const page = await this.prisma.page.findFirst({ where: { id }, include: { pond: true } }); + if (!page || !page.deletedAt) throw new NotFoundException(); + this.access.assertCanModify(user, page.pond); + await this.purgePage(id); + this.logger.info({ pageId: id, userId: user.id }, 'audit: page purged from trash'); + } + + /** Scheduled job entry point (registered with SchedulerService, trash.module.ts). */ + async purgeDuePages(): Promise { + const retentionDays = await this.settings.get('trash.retentionDays'); + const cutoff = new Date(this.clock.now().getTime() - retentionDays * MS_PER_DAY); + const due = await this.prisma.page.findMany({ + where: { deletedAt: { lte: cutoff } }, + select: { id: true }, + }); + for (const { id } of due) { + await this.purgePage(id); + } + } + + /** + * Deletes state, content cache, files, and (once M3 exists) versions for + * one page — the fixed set of things `#31`'s scope names. Version rows + * are a placeholder: `page_versions` doesn't exist until M3 (#33–#42). + */ + private async purgePage(pageId: string): Promise { + const page = await this.prisma.page.findUnique({ where: { id: pageId } }); + if (!page) return; // already gone — e.g. a manual purge raced the job + + const attachments = await this.prisma.attachment.findMany({ where: { pageId } }); + for (const attachment of attachments) { + await this.storage.delete(attachment.pondId, attachment.id); + await this.quotas.release(attachment.pondId, attachment.sizeBytes); + } + await this.prisma.attachment.deleteMany({ where: { pageId } }); + await this.prisma.pageContentCache.deleteMany({ where: { pageId } }); + await this.prisma.pageUpdate.deleteMany({ where: { pageId } }); + await this.prisma.page.delete({ where: { id: pageId } }); + this.logger.info({ pageId }, 'audit: page purged (retention)'); + } +} diff --git a/apps/web/e2e/trash.spec.ts b/apps/web/e2e/trash.spec.ts new file mode 100644 index 0000000..3aa7102 --- /dev/null +++ b/apps/web/e2e/trash.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Page trash pack (issue #31). Runs against the local dev stack (api + + * web); no Mailpit needed. The fixture pond accumulates pages across many + * e2e runs, so trash-list assertions always scope to this test's own + * unique title rather than matching by loose/shared text. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +async function createPage( + context: Awaited>, + title: string, +): Promise<{ pondSlug: string; pageSlug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title }, + }); + const page = await created.json(); + return { pondSlug: pond.slug, pageSlug: page.slug }; +} + +function acceptDialogs(page: Page): void { + page.on('dialog', (dialog) => void dialog.accept()); +} + +/** The trash list row for an exact page title, scoping restore/purge clicks to it. */ +function trashItem(page: Page, title: string) { + return page.locator('.trash-page__item').filter({ hasText: title }); +} + +test('deleting a page hides it from the sidebar and shows a trash hint on direct URL', async ({ + browser, +}) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E Trash Delete ${Date.now()}`); + const page = await context.newPage(); + acceptDialogs(page); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await page.getByRole('button', { name: /move to trash|papierkorb verschieben/i }).click(); + + // Deleting navigates away; going back to the same URL now 404s with a hint. + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await expect(page.getByText(/moved to the trash|papierkorb verschoben/i)).toBeVisible(); + await expect( + page.getByRole('link', { name: /view in trash|im papierkorb ansehen/i }), + ).toBeVisible(); + + await context.close(); +}); + +test('restoring a page from the trash brings it back', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const title = `E2E Trash Restore ${Date.now()}`; + const { pondSlug, pageSlug } = await createPage(context, title); + const page = await context.newPage(); + acceptDialogs(page); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await page.getByRole('button', { name: /move to trash|papierkorb verschieben/i }).click(); + + await page.goto(`/p/${pondSlug}/trash`); + const item = trashItem(page, title); + await expect(item).toBeVisible(); + await item.getByRole('button', { name: /restore|wiederherstellen/i }).click(); + await expect(item).toHaveCount(0); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await expect(page.locator('.editor-page__title')).toHaveValue(title); + + await context.close(); +}); + +test('purging a page from the trash removes it for good', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const title = `E2E Trash Purge ${Date.now()}`; + const { pondSlug, pageSlug } = await createPage(context, title); + const page = await context.newPage(); + acceptDialogs(page); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await page.getByRole('button', { name: /move to trash|papierkorb verschieben/i }).click(); + + await page.goto(`/p/${pondSlug}/trash`); + const item = trashItem(page, title); + await expect(item).toBeVisible(); + await item.getByRole('button', { name: /delete forever|endgültig löschen/i }).click(); + await expect(item).toHaveCount(0); + + await context.close(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index feea189..ba247c4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,6 +8,7 @@ import { NotFoundPage } from './pages/NotFoundPage'; import { PageEditorPage } from './pages/PageEditorPage'; import { PondHomePage } from './pages/PondHomePage'; import { SettingsPage } from './pages/SettingsPage'; +import { TrashPage } from './pages/TrashPage'; import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage'; import { LoginPage } from './pages/auth/LoginPage'; import { ResetPasswordPage } from './pages/auth/ResetPasswordPage'; @@ -32,6 +33,10 @@ export function App(): React.JSX.Element { }> } /> } /> + {/* Static segment "trash" wins react-router's ranking over the + dynamic :pageSlug sibling below — a page slugged "trash" + would be unreachable via direct URL, an accepted v1 gap. */} + } /> } /> }> diff --git a/apps/web/src/layout/Sidebar.tsx b/apps/web/src/layout/Sidebar.tsx index 4d2db24..f14fd72 100644 --- a/apps/web/src/layout/Sidebar.tsx +++ b/apps/web/src/layout/Sidebar.tsx @@ -74,6 +74,11 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element { ))} )} + {isOwner && ( + + {t('editor:trash.link')} + + )} {pages.data && pages.data.length > 0 ? ( diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 1161bc8..ce2fada 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,10 +6,22 @@ import { BrowserRouter } from 'react-router-dom'; import { App } from './App'; import { AuthProvider } from './auth/auth-context'; import './i18n'; +import { ApiError } from './lib/api'; import './styles/tokens.css'; import './styles/base.css'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // A 4xx won't turn into something else on retry (wrong permissions, + // page in trash, not found, …) — only retry on network/5xx errors. + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false; + return failureCount < 3; + }, + }, + }, +}); const container = document.getElementById('root'); if (!container) { diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 5b1b43b..5a9a5f5 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -4,7 +4,7 @@ import { Collaboration } from '@tiptap/extension-collaboration'; import { EditorContent, useEditor } from '@tiptap/react'; import { useEffect, useLayoutEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; +import { Link, useNavigate, useParams } from 'react-router-dom'; import * as Y from 'yjs'; import { FormError } from '../components/forms'; @@ -14,7 +14,7 @@ import { Toolbar } from '../editor/Toolbar'; import { usePageStateAutosave } from '../editor/use-page-autosave'; import { decodeBase64 } from '../editor/yjs-base64'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; -import { apiGet, apiGetText, apiPatch } from '../lib/api'; +import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api'; type Mode = 'view' | 'edit'; @@ -75,11 +75,22 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React. ); } -/** Markdown export actions (issue #30) — both read from the server-cached - * `page_content_cache.markdown` (via the export endpoint), so "copy" and - * "download" always agree with each other and with the last saved state. */ -function PageMenu({ pageId, slug }: { pageId: string; slug: string }): React.JSX.Element { +/** Page-level actions: Markdown export (issue #30, both read from the + * server-cached `page_content_cache.markdown` so "copy" and "download" + * always agree with each other and the last saved state) and moving the + * page to the trash (issue #31) — a soft delete, so this is reversible via + * the pond's trash view; a plain `confirm()` is enough given that. */ +function PageMenu({ + pageId, + slug, + pondSlug, +}: { + pageId: string; + slug: string; + pondSlug: string; +}): React.JSX.Element { const { t } = useTranslation('editor'); + const navigate = useNavigate(); const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); async function copyMarkdown(): Promise { @@ -93,6 +104,12 @@ function PageMenu({ pageId, slug }: { pageId: string; slug: string }): React.JSX setTimeout(() => setCopyStatus('idle'), 2000); } + async function deletePage(): Promise { + if (!window.confirm(t('page.deleteConfirm'))) return; + await apiDelete(`/pages/${pageId}`); + navigate(`/p/${pondSlug}`); + } + return (
); } @@ -139,7 +159,15 @@ export function PageEditorPage(): React.JSX.Element { } if (pond.error || page.error) { - return ; + // Editors get a distinguishable hint (and a way out) instead of a dead + // end when the page they followed a link to is in the trash (#31). + const trashed = page.error instanceof ApiError && page.error.body.code === 'page_trashed'; + return ( + <> + + {trashed && {t('trash.restoreLink')}} + + ); } if (!page.data) { return <>; @@ -164,7 +192,7 @@ export function PageEditorPage(): React.JSX.Element { > {mode === 'edit' ? t('mode.view') : t('mode.edit')} - + diff --git a/apps/web/src/pages/TrashPage.tsx b/apps/web/src/pages/TrashPage.tsx new file mode 100644 index 0000000..f91550a --- /dev/null +++ b/apps/web/src/pages/TrashPage.tsx @@ -0,0 +1,70 @@ +import type { PageView, PondView } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +import { FormError } from '../components/forms'; +import { apiDelete, apiGet, apiPost } from '../lib/api'; + +/** Pond trash view (issue #31): list, restore, and permanently delete. */ +export function TrashPage(): React.JSX.Element { + const { t } = useTranslation('editor'); + const { pondSlug = '' } = useParams<{ pondSlug: string }>(); + const queryClient = useQueryClient(); + + const pond = useQuery({ + queryKey: ['pond', pondSlug], + queryFn: () => apiGet(`/ponds/${pondSlug}`), + }); + const trash = useQuery({ + queryKey: ['trash', pond.data?.id], + queryFn: () => apiGet(`/ponds/${pond.data!.id}/trash`), + enabled: Boolean(pond.data), + }); + + async function refresh(): Promise { + await queryClient.invalidateQueries({ queryKey: ['trash', pond.data?.id] }); + await queryClient.invalidateQueries({ queryKey: ['pages', pond.data?.id] }); + } + + async function restore(id: string): Promise { + await apiPost(`/pages/${id}/restore`); + await refresh(); + } + + async function purge(id: string): Promise { + if (!window.confirm(t('trash.purgeConfirm'))) return; + await apiDelete(`/pages/${id}/purge`); + await refresh(); + } + + if (pond.error || trash.error) return ; + if (!pond.data || !trash.data) return <>; + + return ( +
+

{t('trash.title', { pond: pond.data.name })}

+ {trash.data.length === 0 ? ( +

{t('trash.empty')}

+ ) : ( +
    + {trash.data.map((page) => ( +
  • + {page.title} + + {page.deletedAt && + t('trash.deletedAt', { date: new Date(page.deletedAt).toLocaleDateString() })} + + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 0908fb9..7a0c5bb 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -117,6 +117,11 @@ button { padding: var(--space-1) var(--space-2); } +.sidebar__trash-link { + font-size: 0.85rem; + white-space: nowrap; +} + .sidebar__pages { list-style: none; margin: 0 0 var(--space-3); @@ -684,3 +689,33 @@ button { border-color: var(--color-danger); color: var(--color-danger); } + +/* Pond trash (issue #31) */ +.trash-page { + max-width: 48rem; + margin: 0 auto; +} + +.trash-page__list { + list-style: none; + margin: var(--space-4) 0 0; + padding: 0; +} + +.trash-page__item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--color-border); +} + +.trash-page__title { + flex: 1; + font-weight: var(--font-weight-heading); +} + +.trash-page__deleted-at { + color: var(--color-text-muted); + font-size: 0.85rem; +} diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 7955427..2a15ba9 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -70,6 +70,19 @@ "copyMarkdown": "Als Markdown kopieren", "markdownCopied": "Kopiert!", "markdownCopyFailed": "Kopieren fehlgeschlagen", - "downloadMarkdown": "Als Markdown herunterladen" + "downloadMarkdown": "Als Markdown herunterladen", + "delete": "In den Papierkorb verschieben", + "deleteConfirm": "Diese Seite in den Papierkorb verschieben? Du kannst sie über den Papierkorb des Teichs wiederherstellen." + }, + "trash": { + "link": "Papierkorb", + "title": "Papierkorb — {{pond}}", + "empty": "Der Papierkorb ist leer.", + "deletedAt": "Gelöscht am {{date}}", + "restore": "Wiederherstellen", + "purge": "Endgültig löschen", + "purgeConfirm": "Diese Seite und ihre Dateien endgültig löschen? Das kann nicht rückgängig gemacht werden.", + "pageTrashedHint": "Diese Seite wurde in den Papierkorb verschoben.", + "restoreLink": "Im Papierkorb ansehen" } } diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 70a89b1..8d7e8bc 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -22,6 +22,7 @@ "slug_taken": "Dieser Adressname ist in diesem Teich bereits vergeben.", "page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).", "invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.", + "page_trashed": "Diese Seite wurde in den Papierkorb verschoben.", "unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.", "file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).", "network": "Der Server war nicht erreichbar.", diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index d9fc201..2a4d12d 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -70,6 +70,19 @@ "copyMarkdown": "Copy as Markdown", "markdownCopied": "Copied!", "markdownCopyFailed": "Copy failed", - "downloadMarkdown": "Download as Markdown" + "downloadMarkdown": "Download as Markdown", + "delete": "Move to trash", + "deleteConfirm": "Move this page to the trash? You can restore it from the pond's trash view." + }, + "trash": { + "link": "Trash", + "title": "Trash — {{pond}}", + "empty": "The trash is empty.", + "deletedAt": "Deleted {{date}}", + "restore": "Restore", + "purge": "Delete forever", + "purgeConfirm": "Permanently delete this page and its files? This cannot be undone.", + "pageTrashedHint": "This page has been moved to the trash.", + "restoreLink": "View in trash" } } diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 0efa574..8463e62 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -22,6 +22,7 @@ "slug_taken": "This slug is already taken in this pond.", "page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).", "invalid_page_state": "The submitted page content is invalid.", + "page_trashed": "This page has been moved to the trash.", "unsupported_file_type": "This file type is not supported.", "file_too_large": "The file is too large (limit: {{limitBytes}} bytes).", "network": "The server could not be reached.",