Delete modes and subtree trash semantics for the page tree (#107)
All checks were successful
CD / Build and push images (push) Successful in 3m59s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m20s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m42s
CI / Import/export fidelity gate (push) Successful in 47s

DELETE /pages/:id?mode=promote|subtree — promote (the default) moves
the page's live children up to its parent; subtree trashes every live
descendant with one timestamp and requires write permission on all of
them (no partial deletes; trash access is write capability, ADR 0013).

Trashed pages keep their parentId. Restore re-attaches to the nearest
live ancestor (else root), which makes restore order-independent:
restoring a parent afterwards never re-claims an already-restored
child. Purge promotes any remaining children to the purged page's
parent; the FK's SetNull stays as backstop only.

tree-trash.e2e.db.test.ts covers promote, subtree + one-timestamp,
the 403 descendant gate (label-DENY editor), order-independent
restore, and child promotion on purge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-14 10:05:10 +02:00
parent eb6b0d5d02
commit 12ff3c099f
5 changed files with 349 additions and 10 deletions

View File

@ -9,18 +9,21 @@ import {
Patch, Patch,
Post, Post,
Put, Put,
Query,
Req, Req,
Res, Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
CollabTokenResponse, CollabTokenResponse,
CreatePageInput, CreatePageInput,
PageDeleteQuery,
PageListItemView, PageListItemView,
PageStateView, PageStateView,
PageView, PageView,
RepositionPageInput, RepositionPageInput,
UpdatePageInput, UpdatePageInput,
createPageInputSchema, createPageInputSchema,
pageDeleteQuerySchema,
repositionPageInputSchema, repositionPageInputSchema,
updatePageInputSchema, updatePageInputSchema,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
@ -144,10 +147,16 @@ export class PagesController {
return this.pages.update(request.user!, id, input); return this.pages.update(request.user!, id, input);
} }
/** Trash a page; `?mode=subtree` takes the live descendants along, the
* default `promote` re-attaches them to the page's parent (issue #107). */
@Delete('pages/:id') @Delete('pages/:id')
@HttpCode(204) @HttpCode(204)
@RequiresPagePermission('write', { idParam: 'id' }) @RequiresPagePermission('write', { idParam: 'id' })
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> { async remove(
await this.pages.softDelete(request.user!, id); @Param('id') id: string,
@Query(new ZodValidationPipe(pageDeleteQuerySchema)) query: PageDeleteQuery,
@Req() request: AuthedRequest,
): Promise<void> {
await this.pages.softDelete(request.user!, id, query.mode);
} }
} }

View File

@ -1,9 +1,15 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { import {
CollabTokenResponse, CollabTokenResponse,
CreatePageInput, CreatePageInput,
MAX_PAGE_DEPTH, MAX_PAGE_DEPTH,
OutlineEntry, OutlineEntry,
PageDeleteMode,
PageListItemView, PageListItemView,
PageStateView, PageStateView,
PageView, PageView,
@ -459,12 +465,62 @@ export class PagesService {
return { id: page.id, title: page.title, pondId: page.pondId, slug: page.slug }; return { id: page.id, title: page.title, pondId: page.pondId, slug: page.slug };
} }
async softDelete(user: User, id: string): Promise<void> { /**
* Trash a page (issue #23; delete modes issue #107). `promote` (default)
* re-attaches the page's live children to its parent so nothing else leaves
* the sidebar; `subtree` trashes every live descendant with the same
* timestamp gated on write permission over all of them (403 otherwise;
* trash access is write capability, ADR 0013). Trashed pages keep their
* `parentId`; restore re-attaches to the nearest live ancestor.
*/
async softDelete(user: User, id: string, mode: PageDeleteMode = 'promote'): Promise<void> {
const page = await this.findLivePage(id); const page = await this.findLivePage(id);
await this.prisma.page.update({ const tree = await this.livePageTree(page.pondId);
where: { id: page.id }, const descendantIds = [...collectSubtreeIds(tree, page.id)].filter((pid) => pid !== page.id);
data: { deletedAt: new Date(), deletedBy: user.id },
if (mode === 'subtree' && descendantIds.length > 0) {
const descendants = await this.prisma.page.findMany({
where: { id: { in: descendantIds } },
include: { labels: { select: { labelId: true } } },
});
const writable = await this.permissions.filterPages(
user,
page.pondId,
descendants.map((d) => ({ id: d.id, labelIds: d.labels.map((l) => l.labelId) })),
'write',
);
// No partial deletes: one unwritable descendant blocks the whole subtree.
if (descendants.some((d) => !writable.has(d.id))) {
throw new ForbiddenException({ code: 'forbidden' });
}
}
const deletedAt = new Date();
await this.prisma.$transaction(async (tx) => {
if (mode === 'subtree') {
await tx.page.updateMany({
where: { id: { in: [page.id, ...descendantIds] }, deletedAt: null },
data: { deletedAt, deletedBy: user.id },
});
} else {
await tx.page.updateMany({
where: { parentId: page.id, deletedAt: null },
data: { parentId: page.parentId },
});
await tx.page.update({
where: { id: page.id },
data: { deletedAt, deletedBy: user.id },
});
}
}); });
this.logger.info({ pageId: id, userId: user.id }, 'audit: page trashed'); this.logger.info(
{
pageId: id,
userId: user.id,
mode,
descendants: mode === 'subtree' ? descendantIds.length : 0,
},
'audit: page trashed',
);
} }
} }

View File

@ -55,14 +55,40 @@ export class TrashService {
return pages.filter((page) => editable.has(page.id)).map((page) => this.pages.viewOf(page)); return pages.filter((page) => editable.has(page.id)).map((page) => this.pages.viewOf(page));
} }
/**
* Restore a trashed page. Trashed pages keep their `parentId` (issue #107),
* so the original spot may itself be in the trash by now the page
* re-attaches to its nearest **live** ancestor, or to the root when the
* whole chain is gone. That makes restore order-independent: restoring the
* parent afterwards does not re-claim an already-restored child.
*/
async restore(user: User, id: string): Promise<PageView> { async restore(user: User, id: string): Promise<PageView> {
const page = await this.prisma.page.findFirst({ where: { id } }); const page = await this.prisma.page.findFirst({ where: { id } });
if (!page || !page.deletedAt) throw new NotFoundException(); if (!page || !page.deletedAt) throw new NotFoundException();
const pondPages = await this.prisma.page.findMany({
where: { pondId: page.pondId },
select: { id: true, parentId: true, deletedAt: true },
});
const byId = new Map(pondPages.map((p) => [p.id, p]));
let parentId: string | null = null;
const seen = new Set<string>([page.id]);
for (let current = page.parentId; current && !seen.has(current);) {
seen.add(current);
const ancestor = byId.get(current);
if (!ancestor) break;
if (ancestor.deletedAt === null) {
parentId = ancestor.id;
break;
}
current = ancestor.parentId;
}
const restored = await this.prisma.page.update({ const restored = await this.prisma.page.update({
where: { id }, where: { id },
data: { deletedAt: null, deletedBy: null }, data: { deletedAt: null, deletedBy: null, parentId },
}); });
this.logger.info({ pageId: id, userId: user.id }, 'audit: page restored from trash'); this.logger.info({ pageId: id, userId: user.id, parentId }, 'audit: page restored from trash');
return this.pages.viewOf(restored); return this.pages.viewOf(restored);
} }
@ -105,6 +131,13 @@ export class TrashService {
await this.prisma.pageContentCache.deleteMany({ where: { pageId } }); await this.prisma.pageContentCache.deleteMany({ where: { pageId } });
await this.prisma.pageUpdate.deleteMany({ where: { pageId } }); await this.prisma.pageUpdate.deleteMany({ where: { pageId } });
await this.watches.removeForPage(pageId); await this.watches.removeForPage(pageId);
// Children (live or trashed) move up to the purged page's parent (issue
// #107) — the FK's SetNull is only the backstop for rows created outside
// this path.
await this.prisma.page.updateMany({
where: { parentId: pageId },
data: { parentId: page.parentId },
});
await this.prisma.page.delete({ where: { id: pageId } }); await this.prisma.page.delete({ where: { id: pageId } });
this.logger.info({ pageId }, 'audit: page purged (retention)'); this.logger.info({ pageId }, 'audit: page purged (retention)');
} }

View File

@ -0,0 +1,227 @@
import { INestApplication } from '@nestjs/common';
import { PageView } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Delete modes and subtree trash semantics (issue #107): promote vs subtree,
* the write-on-every-descendant gate, order-independent restore re-attachment,
* and child promotion on purge.
*/
describe.skipIf(!hasTestDb)('page tree trash semantics (e2e, issue #107)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'branches fall upward 12';
let ownerCookie: string;
let editorCookie: string;
let pondId: string;
let secretLabelId: string;
const api = () => request(app.getHttpServer());
async function login(username: string): Promise<string> {
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
return sessionCookieOf(res);
}
async function createPage(title: string, parentId?: string): Promise<PageView> {
const res = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send(parentId ? { title, parentId } : { title })
.expect(201);
return res.body as PageView;
}
async function listPages(): Promise<PageView[]> {
const res = await api()
.get(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
return res.body as PageView[];
}
async function trashTitles(): Promise<string[]> {
const res = await api()
.get(`/api/v1/ponds/${pondId}/trash`)
.set('Cookie', ownerCookie)
.expect(200);
return (res.body as PageView[]).map((p) => p.title);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
const users = app.get(UsersService);
const ownerUser = await users.createUser({
username: `boris-branch-${suffix}`,
email: `boris-branch-${suffix}@example.org`,
displayName: 'Boris Branch',
password,
locale: 'en',
});
await users.markEmailVerified(ownerUser.id);
const editorUser = await users.createUser({
username: `edda-editor-${suffix}`,
email: `edda-editor-${suffix}@example.org`,
displayName: 'Edda Editor',
password,
locale: 'en',
});
await users.markEmailVerified(editorUser.id);
// Pond, labels, and ALL grants provisioned before any permission
// resolution touches the pond — raw rows created later would be invisible
// to the warmed PondPermissionCache.
const pond = await prisma.pond.create({
data: {
slug: `tree-trash-${suffix}`,
name: 'Tree Trash',
type: 'SHARED',
ownerId: ownerUser.id,
},
});
pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, ownerUser.id);
const secret = await prisma.label.create({
data: { pondId, name: 'secret', color: '#334455' },
});
secretLabelId = secret.id;
await prisma.roleGrant.createMany({
data: [
{
pondId,
subjectType: 'USER',
subjectId: editorUser.id,
role: 'EDITOR',
scopeType: 'POND',
effect: 'ALLOW',
createdBy: ownerUser.id,
},
{
pondId,
subjectType: 'USER',
subjectId: editorUser.id,
role: 'EDITOR',
scopeType: 'LABEL',
scopeId: secret.id,
effect: 'DENY',
createdBy: ownerUser.id,
},
],
});
ownerCookie = await login(`boris-branch-${suffix}`);
editorCookie = await login(`edda-editor-${suffix}`);
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.label.deleteMany({ where: { pondId } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('promote (the default) re-attaches live children to the deleted page parent', async () => {
const a = await createPage('Promote A');
const b = await createPage('Promote B', a.id);
const c = await createPage('Promote C', b.id);
await api().delete(`/api/v1/pages/${a.id}`).set('Cookie', ownerCookie).expect(204);
const pages = await listPages();
expect(pages.find((p) => p.id === b.id)?.parentId).toBeNull(); // promoted to A's parent
expect(pages.find((p) => p.id === c.id)?.parentId).toBe(b.id); // untouched
expect(await trashTitles()).toContain('Promote A');
// Restoring A later does not re-claim the promoted children.
await api().post(`/api/v1/pages/${a.id}/restore`).set('Cookie', ownerCookie).expect(201);
const after = await listPages();
expect(after.find((p) => p.id === b.id)?.parentId).toBeNull();
});
it('subtree trashes every live descendant together; restore is order-independent', async () => {
const x = await createPage('Subtree X');
const y = await createPage('Subtree Y', x.id);
const z = await createPage('Subtree Z', y.id);
await api().delete(`/api/v1/pages/${x.id}?mode=subtree`).set('Cookie', ownerCookie).expect(204);
const titles = await trashTitles();
expect(titles).toEqual(expect.arrayContaining(['Subtree X', 'Subtree Y', 'Subtree Z']));
// One shared timestamp marks the subtree operation.
const rows = await prisma.page.findMany({ where: { id: { in: [x.id, y.id, z.id] } } });
const stamps = new Set(rows.map((r) => r.deletedAt?.toISOString()));
expect(stamps.size).toBe(1);
// Restore the deepest page first: its ancestors are still trashed, so it
// re-attaches to the nearest live ancestor — the root.
const zRestored = await api()
.post(`/api/v1/pages/${z.id}/restore`)
.set('Cookie', ownerCookie)
.expect(201);
expect((zRestored.body as PageView).parentId).toBeNull();
// Restore the top, then the middle: Y finds X live again and nests under it.
await api().post(`/api/v1/pages/${x.id}/restore`).set('Cookie', ownerCookie).expect(201);
const yRestored = await api()
.post(`/api/v1/pages/${y.id}/restore`)
.set('Cookie', ownerCookie)
.expect(201);
expect((yRestored.body as PageView).parentId).toBe(x.id);
// Z stays where its restore put it — X does not re-claim it.
const pages = await listPages();
expect(pages.find((p) => p.id === z.id)?.parentId).toBeNull();
});
it('subtree requires write on every live descendant (403, nothing deleted)', async () => {
const parent = await createPage('Gate parent');
const child = await createPage('Gate child', parent.id);
await prisma.pageLabel.create({ data: { pageId: child.id, labelId: secretLabelId } });
await api()
.delete(`/api/v1/pages/${parent.id}?mode=subtree`)
.set('Cookie', editorCookie)
.expect(403);
// Nothing was trashed by the refused subtree delete.
const pages = await listPages();
expect(pages.some((p) => p.id === parent.id)).toBe(true);
expect(pages.some((p) => p.id === child.id)).toBe(true);
// The plain promote delete only needs write on the page itself.
await api().delete(`/api/v1/pages/${parent.id}`).set('Cookie', editorCookie).expect(204);
});
it('purging a parent promotes its children instead of orphaning them', async () => {
const u = await createPage('Purge U');
const v = await createPage('Purge V', u.id);
await api().delete(`/api/v1/pages/${u.id}?mode=subtree`).set('Cookie', ownerCookie).expect(204);
await api().delete(`/api/v1/pages/${u.id}/purge`).set('Cookie', ownerCookie).expect(204);
const vRow = await prisma.page.findUniqueOrThrow({ where: { id: v.id } });
expect(vRow.parentId).toBeNull(); // U's parent was the root
const restored = await api()
.post(`/api/v1/pages/${v.id}/restore`)
.set('Cookie', ownerCookie)
.expect(201);
expect((restored.body as PageView).parentId).toBeNull();
});
});

View File

@ -60,6 +60,20 @@ export const repositionPageInputSchema = z.object({
}); });
export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>; export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
/**
* What happens to a page's live children when it is trashed (issue #107):
* `promote` re-attaches them to the deleted page's parent (the default
* nothing disappears but the page itself); `subtree` trashes every live
* descendant along with it, which requires write permission on all of them.
*/
export const PAGE_DELETE_MODES = ['promote', 'subtree'] as const;
export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number];
export const pageDeleteQuerySchema = z.object({
mode: z.enum(PAGE_DELETE_MODES).default('promote'),
});
export type PageDeleteQuery = z.infer<typeof pageDeleteQuerySchema>;
export const savePageStateInputSchema = z.object({ export const savePageStateInputSchema = z.object({
/** Base64-encoded Yjs state (`Y.encodeStateAsUpdate`). */ /** Base64-encoded Yjs state (`Y.encodeStateAsUpdate`). */
state: z.string().min(1, 'validation.required'), state: z.string().min(1, 'validation.required'),