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
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:
parent
c9011cb44f
commit
a645763679
@ -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")
|
||||
);
|
||||
@ -180,14 +180,17 @@ model PondUsage {
|
||||
|
||||
/// Uploaded file (ADR 0011, issue #27). Bytes live on the uploads volume at
|
||||
/// `<uploadsDir>/<pondId>/<id>` (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")
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
15
apps/api/src/common/clock.service.ts
Normal file
15
apps/api/src/common/clock.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
apps/api/src/common/common.module.ts
Normal file
9
apps/api/src/common/common.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ClockService } from './clock.service';
|
||||
|
||||
@Module({
|
||||
providers: [ClockService],
|
||||
exports: [ClockService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
@ -11,5 +11,6 @@ import { FilesService } from './files.service';
|
||||
imports: [PondsModule, QuotasModule],
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService, FileStorageService],
|
||||
exports: [FileStorageService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
|
||||
@ -163,11 +163,19 @@ export class PagesService {
|
||||
|
||||
async getStateBySlug(user: User, pondId: string, slug: string): Promise<PageStateView> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
12
apps/api/src/scheduler/scheduler.module.ts
Normal file
12
apps/api/src/scheduler/scheduler.module.ts
Normal 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 {}
|
||||
85
apps/api/src/scheduler/scheduler.service.db.test.ts
Normal file
85
apps/api/src/scheduler/scheduler.service.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
118
apps/api/src/scheduler/scheduler.service.ts
Normal file
118
apps/api/src/scheduler/scheduler.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
28
apps/api/src/trash/trash.controller.ts
Normal file
28
apps/api/src/trash/trash.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
280
apps/api/src/trash/trash.e2e.db.test.ts
Normal file
280
apps/api/src/trash/trash.e2e.db.test.ts
Normal 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' } });
|
||||
}
|
||||
});
|
||||
});
|
||||
35
apps/api/src/trash/trash.module.ts
Normal file
35
apps/api/src/trash/trash.module.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
103
apps/api/src/trash/trash.service.ts
Normal file
103
apps/api/src/trash/trash.service.ts
Normal 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)');
|
||||
}
|
||||
}
|
||||
99
apps/web/e2e/trash.spec.ts
Normal file
99
apps/web/e2e/trash.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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 {
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<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>
|
||||
<Route element={<RequireSiteAdmin />}>
|
||||
|
||||
@ -74,6 +74,11 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{isOwner && (
|
||||
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link">
|
||||
{t('editor:trash.link')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pages.data && pages.data.length > 0 ? (
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<void> {
|
||||
@ -93,6 +104,12 @@ function PageMenu({ pageId, slug }: { pageId: string; slug: string }): React.JSX
|
||||
setTimeout(() => setCopyStatus('idle'), 2000);
|
||||
}
|
||||
|
||||
async function deletePage(): Promise<void> {
|
||||
if (!window.confirm(t('page.deleteConfirm'))) return;
|
||||
await apiDelete(`/pages/${pageId}`);
|
||||
navigate(`/p/${pondSlug}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor-page__actions">
|
||||
<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')}
|
||||
</a>
|
||||
<button type="button" className="button" onClick={() => void deletePage()}>
|
||||
{t('page.delete')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -139,7 +159,15 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
}
|
||||
|
||||
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) {
|
||||
return <></>;
|
||||
@ -164,7 +192,7 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
>
|
||||
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
|
||||
</button>
|
||||
<PageMenu pageId={page.data.id} slug={page.data.slug} />
|
||||
<PageMenu pageId={page.data.id} slug={page.data.slug} pondSlug={pondSlug} />
|
||||
</div>
|
||||
<PageEditor page={page.data} mode={mode} />
|
||||
</div>
|
||||
|
||||
70
apps/web/src/pages/TrashPage.tsx
Normal file
70
apps/web/src/pages/TrashPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user