Add page trash: soft delete, restore, and purge job (#31)
All checks were successful
CD / Build and push images (push) Successful in 2m5s
CI / Lint, typecheck, test (push) Successful in 1m45s
CI / Auth e2e pack (push) Successful in 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 10s

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
This commit is contained in:
Claude Sonnet 5 2026-07-08 12:48:17 +02:00
parent c9011cb44f
commit a645763679
27 changed files with 1078 additions and 22 deletions

View File

@ -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")
);

View File

@ -180,14 +180,17 @@ model PondUsage {
/// Uploaded file (ADR 0011, issue #27). Bytes live on the uploads volume at /// Uploaded file (ADR 0011, issue #27). Bytes live on the uploads volume at
/// `<uploadsDir>/<pondId>/<id>` (FileStorageService); this row carries the /// `<uploadsDir>/<pondId>/<id>` (FileStorageService); this row carries the
/// metadata needed to serve and account for it. `pageId` is nullable and /// metadata needed to serve and account for it. `pageId` starts unset —
/// left unset by #27's endpoints — images are uploaded before the page /// images are uploaded before the page referencing them is known
/// referencing them is known (paste-then-insert, issue #28); a later story /// (paste-then-insert, issue #28) — and is set on every page state save to
/// wires pages to set it once they track their embedded attachments. /// whichever page's document currently embeds the file (issue #31,
/// `deletedAt` is unused by #27 (its `DELETE /files/:id` hard-deletes /// `PagesService.saveState`); the trash-purge job uses that link to delete
/// immediately, bytes and all) — reserved for the page-trash-purge flow /// a purged page's files. Not touched when an image is later removed from
/// (#31, ADR 0011 "orphan cleanup"), which soft-deletes an attachment when /// its page's content — an orphan-file sweep to reclaim those is a
/// its page is purged before the nightly job physically removes it. /// 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 { model Attachment {
id String @id @default(uuid()) id String @id @default(uuid())
pondId String @map("pond_id") pondId String @map("pond_id")
@ -299,3 +302,32 @@ model MailOutbox {
@@index([status, nextAttemptAt]) @@index([status, nextAttemptAt])
@@map("mail_outbox") @@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")
}

View File

@ -15,6 +15,7 @@ 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';
import { TrashModule } from './trash/trash.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
@Module({ @Module({
@ -28,6 +29,7 @@ import { UsersModule } from './users/users.module';
PondsModule, PondsModule,
PagesModule, PagesModule,
FilesModule, FilesModule,
TrashModule,
AuthModule, AuthModule,
AdminModule, AdminModule,
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({

View File

@ -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();
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ClockService } from './clock.service';
@Module({
providers: [ClockService],
exports: [ClockService],
})
export class CommonModule {}

View File

@ -11,5 +11,6 @@ import { FilesService } from './files.service';
imports: [PondsModule, QuotasModule], imports: [PondsModule, QuotasModule],
controllers: [FilesController], controllers: [FilesController],
providers: [FilesService, FileStorageService], providers: [FilesService, FileStorageService],
exports: [FileStorageService],
}) })
export class FilesModule {} export class FilesModule {}

View File

@ -163,11 +163,19 @@ export class PagesService {
async getStateBySlug(user: User, pondId: string, slug: string): Promise<PageStateView> { async getStateBySlug(user: User, pondId: string, slug: string): Promise<PageStateView> {
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({
where: { pondId, slug, deletedAt: null }, where: { pondId, slug },
include: { pond: true }, include: { pond: true },
}); });
if (!page) throw new NotFoundException(); if (!page || !this.access.canSeePond(user, page.pond)) throw new NotFoundException();
this.access.assertCanSee(user, page.pond); 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); 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'); this.logger.info({ pageId: id, userId: user.id }, 'audit: page state saved');
return this.stateViewOf(updated); return this.stateViewOf(updated);
} }

View File

@ -53,6 +53,22 @@ export interface DerivedPageContent {
markdown: string; markdown: string;
html: string; html: string;
outline: OutlineEntry[]; 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), markdown: docToMarkdown(doc),
html: docToHtml(doc), html: docToHtml(doc),
outline: extractOutline(doc), outline: extractOutline(doc),
imageFileIds: imageFileIdsOf(doc),
}; };
} }

View File

@ -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 {}

View File

@ -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);
});
});

View File

@ -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<void>;
}
/** 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<string, JobDefinition>();
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<void> {
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<void> {
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 },
});
}
}
}

View File

@ -29,6 +29,9 @@ export const INSTANCE_SETTINGS = {
.int() .int()
.min(0) .min(0)
.default(25 * 1024 * 1024), .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; } as const;
export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS; export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS;

View File

@ -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<PageView[]> {
return this.trash.list(request.user!, pondId);
}
@Post('pages/:id/restore')
async restore(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageView> {
return this.trash.restore(request.user!, id);
}
@Delete('pages/:id/purge')
@HttpCode(204)
async purge(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.trash.purgeNow(request.user!, id);
}
}

View File

@ -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<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);
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' } });
}
});
});

View File

@ -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(),
});
}
}

View File

@ -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<PageView[]> {
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<PageView> {
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<void> {
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<void> {
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<void> {
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)');
}
}

View File

@ -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<ReturnType<typeof contextForUser>>,
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();
});

View File

@ -8,6 +8,7 @@ import { NotFoundPage } from './pages/NotFoundPage';
import { PageEditorPage } from './pages/PageEditorPage'; import { PageEditorPage } from './pages/PageEditorPage';
import { PondHomePage } from './pages/PondHomePage'; import { PondHomePage } from './pages/PondHomePage';
import { SettingsPage } from './pages/SettingsPage'; import { SettingsPage } from './pages/SettingsPage';
import { TrashPage } from './pages/TrashPage';
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage'; import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
import { LoginPage } from './pages/auth/LoginPage'; import { LoginPage } from './pages/auth/LoginPage';
import { ResetPasswordPage } from './pages/auth/ResetPasswordPage'; import { ResetPasswordPage } from './pages/auth/ResetPasswordPage';
@ -32,6 +33,10 @@ export function App(): React.JSX.Element {
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="p/:pondSlug" element={<PondHomePage />} /> <Route path="p/:pondSlug" element={<PondHomePage />} />
{/* 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. */}
<Route path="p/:pondSlug/trash" element={<TrashPage />} />
<Route path="p/:pondSlug/:pageSlug" element={<PageEditorPage />} /> <Route path="p/:pondSlug/:pageSlug" element={<PageEditorPage />} />
</Route> </Route>
<Route element={<RequireSiteAdmin />}> <Route element={<RequireSiteAdmin />}>

View File

@ -74,6 +74,11 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
))} ))}
</select> </select>
)} )}
{isOwner && (
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link">
{t('editor:trash.link')}
</Link>
)}
</div> </div>
{pages.data && pages.data.length > 0 ? ( {pages.data && pages.data.length > 0 ? (

View File

@ -6,10 +6,22 @@ import { BrowserRouter } from 'react-router-dom';
import { App } from './App'; import { App } from './App';
import { AuthProvider } from './auth/auth-context'; import { AuthProvider } from './auth/auth-context';
import './i18n'; import './i18n';
import { ApiError } from './lib/api';
import './styles/tokens.css'; import './styles/tokens.css';
import './styles/base.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'); const container = document.getElementById('root');
if (!container) { if (!container) {

View File

@ -4,7 +4,7 @@ import { Collaboration } from '@tiptap/extension-collaboration';
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from '@tiptap/react';
import { useEffect, useLayoutEffect, useState } from 'react'; import { useEffect, useLayoutEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 * as Y from 'yjs';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
@ -14,7 +14,7 @@ import { Toolbar } from '../editor/Toolbar';
import { usePageStateAutosave } from '../editor/use-page-autosave'; import { usePageStateAutosave } from '../editor/use-page-autosave';
import { decodeBase64 } from '../editor/yjs-base64'; import { decodeBase64 } from '../editor/yjs-base64';
import { useForceSidebarHidden } from '../layout/sidebar-chrome'; 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'; 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-level actions: Markdown export (issue #30, both read from the
* `page_content_cache.markdown` (via the export endpoint), so "copy" and * server-cached `page_content_cache.markdown` so "copy" and "download"
* "download" always agree with each other and with the last saved state. */ * always agree with each other and the last saved state) and moving the
function PageMenu({ pageId, slug }: { pageId: string; slug: string }): React.JSX.Element { * 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 { t } = useTranslation('editor');
const navigate = useNavigate();
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
async function copyMarkdown(): Promise<void> { async function copyMarkdown(): Promise<void> {
@ -93,6 +104,12 @@ function PageMenu({ pageId, slug }: { pageId: string; slug: string }): React.JSX
setTimeout(() => setCopyStatus('idle'), 2000); setTimeout(() => setCopyStatus('idle'), 2000);
} }
async function deletePage(): Promise<void> {
if (!window.confirm(t('page.deleteConfirm'))) return;
await apiDelete(`/pages/${pageId}`);
navigate(`/p/${pondSlug}`);
}
return ( return (
<div className="editor-page__actions"> <div className="editor-page__actions">
<button type="button" className="button" onClick={() => void copyMarkdown()}> <button type="button" className="button" onClick={() => void copyMarkdown()}>
@ -107,6 +124,9 @@ function PageMenu({ pageId, slug }: { pageId: string; slug: string }): React.JSX
> >
{t('page.downloadMarkdown')} {t('page.downloadMarkdown')}
</a> </a>
<button type="button" className="button" onClick={() => void deletePage()}>
{t('page.delete')}
</button>
</div> </div>
); );
} }
@ -139,7 +159,15 @@ export function PageEditorPage(): React.JSX.Element {
} }
if (pond.error || page.error) { if (pond.error || page.error) {
return <FormError error={pond.error ?? page.error} />; // 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 (
<>
<FormError error={pond.error ?? page.error} />
{trashed && <Link to={`/p/${pondSlug}/trash`}>{t('trash.restoreLink')}</Link>}
</>
);
} }
if (!page.data) { if (!page.data) {
return <></>; return <></>;
@ -164,7 +192,7 @@ export function PageEditorPage(): React.JSX.Element {
> >
{mode === 'edit' ? t('mode.view') : t('mode.edit')} {mode === 'edit' ? t('mode.view') : t('mode.edit')}
</button> </button>
<PageMenu pageId={page.data.id} slug={page.data.slug} /> <PageMenu pageId={page.data.id} slug={page.data.slug} pondSlug={pondSlug} />
</div> </div>
<PageEditor page={page.data} mode={mode} /> <PageEditor page={page.data} mode={mode} />
</div> </div>

View File

@ -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<PondView>(`/ponds/${pondSlug}`),
});
const trash = useQuery({
queryKey: ['trash', pond.data?.id],
queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/trash`),
enabled: Boolean(pond.data),
});
async function refresh(): Promise<void> {
await queryClient.invalidateQueries({ queryKey: ['trash', pond.data?.id] });
await queryClient.invalidateQueries({ queryKey: ['pages', pond.data?.id] });
}
async function restore(id: string): Promise<void> {
await apiPost(`/pages/${id}/restore`);
await refresh();
}
async function purge(id: string): Promise<void> {
if (!window.confirm(t('trash.purgeConfirm'))) return;
await apiDelete(`/pages/${id}/purge`);
await refresh();
}
if (pond.error || trash.error) return <FormError error={pond.error ?? trash.error} />;
if (!pond.data || !trash.data) return <></>;
return (
<div className="trash-page">
<h1>{t('trash.title', { pond: pond.data.name })}</h1>
{trash.data.length === 0 ? (
<p>{t('trash.empty')}</p>
) : (
<ul className="trash-page__list">
{trash.data.map((page) => (
<li key={page.id} className="trash-page__item">
<span className="trash-page__title">{page.title}</span>
<span className="trash-page__deleted-at">
{page.deletedAt &&
t('trash.deletedAt', { date: new Date(page.deletedAt).toLocaleDateString() })}
</span>
<button type="button" className="button" onClick={() => void restore(page.id)}>
{t('trash.restore')}
</button>
<button type="button" className="button" onClick={() => void purge(page.id)}>
{t('trash.purge')}
</button>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -117,6 +117,11 @@ button {
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);
} }
.sidebar__trash-link {
font-size: 0.85rem;
white-space: nowrap;
}
.sidebar__pages { .sidebar__pages {
list-style: none; list-style: none;
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
@ -684,3 +689,33 @@ button {
border-color: var(--color-danger); border-color: var(--color-danger);
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;
}

View File

@ -70,6 +70,19 @@
"copyMarkdown": "Als Markdown kopieren", "copyMarkdown": "Als Markdown kopieren",
"markdownCopied": "Kopiert!", "markdownCopied": "Kopiert!",
"markdownCopyFailed": "Kopieren fehlgeschlagen", "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"
} }
} }

View File

@ -22,6 +22,7 @@
"slug_taken": "Dieser Adressname ist in diesem Teich bereits vergeben.", "slug_taken": "Dieser Adressname ist in diesem Teich bereits vergeben.",
"page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).", "page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).",
"invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.", "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.", "unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.",
"file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).", "file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).",
"network": "Der Server war nicht erreichbar.", "network": "Der Server war nicht erreichbar.",

View File

@ -70,6 +70,19 @@
"copyMarkdown": "Copy as Markdown", "copyMarkdown": "Copy as Markdown",
"markdownCopied": "Copied!", "markdownCopied": "Copied!",
"markdownCopyFailed": "Copy failed", "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"
} }
} }

View File

@ -22,6 +22,7 @@
"slug_taken": "This slug is already taken in this pond.", "slug_taken": "This slug is already taken in this pond.",
"page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).", "page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).",
"invalid_page_state": "The submitted page content is invalid.", "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.", "unsupported_file_type": "This file type is not supported.",
"file_too_large": "The file is too large (limit: {{limitBytes}} bytes).", "file_too_large": "The file is too large (limit: {{limitBytes}} bytes).",
"network": "The server could not be reached.", "network": "The server could not be reached.",