diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index 4c66fc4..c81f6f3 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -9,18 +9,21 @@ import { Patch, Post, Put, + Query, Req, Res, } from '@nestjs/common'; import { CollabTokenResponse, CreatePageInput, + PageDeleteQuery, PageListItemView, PageStateView, PageView, RepositionPageInput, UpdatePageInput, createPageInputSchema, + pageDeleteQuerySchema, repositionPageInputSchema, updatePageInputSchema, } from '@dorfteich/shared'; @@ -144,10 +147,16 @@ export class PagesController { 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') @HttpCode(204) @RequiresPagePermission('write', { idParam: 'id' }) - async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { - await this.pages.softDelete(request.user!, id); + async remove( + @Param('id') id: string, + @Query(new ZodValidationPipe(pageDeleteQuerySchema)) query: PageDeleteQuery, + @Req() request: AuthedRequest, + ): Promise { + await this.pages.softDelete(request.user!, id, query.mode); } } diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 04a7506..3508751 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -1,9 +1,15 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CollabTokenResponse, CreatePageInput, MAX_PAGE_DEPTH, OutlineEntry, + PageDeleteMode, PageListItemView, PageStateView, PageView, @@ -459,12 +465,62 @@ export class PagesService { return { id: page.id, title: page.title, pondId: page.pondId, slug: page.slug }; } - async softDelete(user: User, id: string): Promise { + /** + * 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 { const page = await this.findLivePage(id); - await this.prisma.page.update({ - where: { id: page.id }, - data: { deletedAt: new Date(), deletedBy: user.id }, + const tree = await this.livePageTree(page.pondId); + const descendantIds = [...collectSubtreeIds(tree, page.id)].filter((pid) => pid !== page.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', + ); } } diff --git a/apps/api/src/trash/trash.service.ts b/apps/api/src/trash/trash.service.ts index d4faddc..4973ca0 100644 --- a/apps/api/src/trash/trash.service.ts +++ b/apps/api/src/trash/trash.service.ts @@ -55,14 +55,40 @@ export class TrashService { 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 { const page = await this.prisma.page.findFirst({ where: { id } }); 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([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({ 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); } @@ -105,6 +131,13 @@ export class TrashService { await this.prisma.pageContentCache.deleteMany({ where: { pageId } }); await this.prisma.pageUpdate.deleteMany({ where: { 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 } }); this.logger.info({ pageId }, 'audit: page purged (retention)'); } diff --git a/apps/api/src/trash/tree-trash.e2e.db.test.ts b/apps/api/src/trash/tree-trash.e2e.db.test.ts new file mode 100644 index 0000000..70616b3 --- /dev/null +++ b/apps/api/src/trash/tree-trash.e2e.db.test.ts @@ -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 { + 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 { + 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 { + const res = await api() + .get(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .expect(200); + return res.body as PageView[]; + } + + async function trashTitles(): Promise { + 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(); + }); +}); diff --git a/packages/shared/src/pages.ts b/packages/shared/src/pages.ts index 3112787..8915b3d 100644 --- a/packages/shared/src/pages.ts +++ b/packages/shared/src/pages.ts @@ -60,6 +60,20 @@ export const repositionPageInputSchema = z.object({ }); export type RepositionPageInput = z.infer; +/** + * 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; + export const savePageStateInputSchema = z.object({ /** Base64-encoded Yjs state (`Y.encodeStateAsUpdate`). */ state: z.string().min(1, 'validation.required'),