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
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:
parent
eb6b0d5d02
commit
12ff3c099f
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)');
|
||||||
}
|
}
|
||||||
|
|||||||
227
apps/api/src/trash/tree-trash.e2e.db.test.ts
Normal file
227
apps/api/src/trash/tree-trash.e2e.db.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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'),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user