Some checks failed
CD / Build and push images (push) Successful in 3m57s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m16s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
The slug-based machine surfaces now see and shape the hierarchy: - REST: page list/detail carry parent (the parent page's slug, nulled when the token's user may not read it — same no-leak rule as the internal list); create accepts parent; PATCH accepts parent (slug nests, null moves to the top level, appended at the end of the new sibling group via the new PagesService.moveToEnd). Cycle/depth refusals keep their regular error codes. OpenAPI updated. - MCP: list_pages returns parent, create_page takes an optional parent slug, update_page moves with parent (slug|null); tool errors carry the api code (page_cycle covered in the e2e pack). - ZIP export deliberately stays flat — noted in features.md; the hierarchy is organizational only. e2e: REST pack covers nested create, list shape, move/root-move, 409 page_cycle, 404 unknown parent; MCP pack covers nested create, list parent, and the cycle tool error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
543 lines
21 KiB
TypeScript
543 lines
21 KiB
TypeScript
import {
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import {
|
|
CollabTokenResponse,
|
|
CreatePageInput,
|
|
MAX_PAGE_DEPTH,
|
|
OutlineEntry,
|
|
PageDeleteMode,
|
|
PageListItemView,
|
|
PageStateView,
|
|
PageView,
|
|
PluginPageSummary,
|
|
RepositionPageInput,
|
|
SidebarSortMode,
|
|
TreeItem,
|
|
UpdatePageInput,
|
|
collectSubtreeIds,
|
|
nodeDepth,
|
|
pondSettingsSchema,
|
|
slugify,
|
|
subtreeHeight,
|
|
} from '@dorfteich/shared';
|
|
import { signCollabToken } from '@dorfteich/shared/token-crypto';
|
|
import { Page, Prisma, User } from '@prisma/client';
|
|
import { generateKeyBetween } from 'fractional-indexing';
|
|
import { PinoLogger } from 'nestjs-pino';
|
|
|
|
import { AppConfig } from '../config/app-config.service';
|
|
import { PermissionService } from '../permissions/permission.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { WatchesService } from '../watches/watches.service';
|
|
import { SearchProvider } from '../search/search.provider';
|
|
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
|
|
import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content';
|
|
|
|
/** `outline` is a plain JSON-serializable array; Prisma's Json input just needs the cast. */
|
|
function contentCacheData(
|
|
content: DerivedPageContent,
|
|
): Prisma.PageContentCacheCreateWithoutPageInput {
|
|
return {
|
|
plainText: content.plainText,
|
|
markdown: content.markdown,
|
|
html: content.html,
|
|
outline: content.outline as unknown as Prisma.InputJsonValue,
|
|
};
|
|
}
|
|
|
|
/** Collaboration tokens are short-lived; the client re-fetches on reconnect
|
|
* (realtime-collaboration.md). 60 s is the ceiling the story specifies. */
|
|
const COLLAB_TOKEN_TTL_SECONDS = 60;
|
|
|
|
@Injectable()
|
|
export class PagesService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly permissions: PermissionService,
|
|
private readonly logger: PinoLogger,
|
|
private readonly config: AppConfig,
|
|
private readonly search: SearchProvider,
|
|
private readonly watches: WatchesService,
|
|
) {
|
|
this.logger.setContext(PagesService.name);
|
|
}
|
|
|
|
viewOf(page: Page): PageView {
|
|
return {
|
|
id: page.id,
|
|
pondId: page.pondId,
|
|
parentId: page.parentId,
|
|
title: page.title,
|
|
slug: page.slug,
|
|
sortKey: page.sortKey,
|
|
createdAt: page.createdAt.toISOString(),
|
|
updatedAt: page.updatedAt.toISOString(),
|
|
deletedAt: page.deletedAt?.toISOString() ?? null,
|
|
};
|
|
}
|
|
|
|
stateViewOf(page: Page): PageStateView {
|
|
return { ...this.viewOf(page), state: Buffer.from(page.ydocState).toString('base64') };
|
|
}
|
|
|
|
/** Deterministic unique slug within one pond (mirrors PondsService). */
|
|
private async generateUniqueSlugInPond(pondId: string, base: string): Promise<string> {
|
|
const slug = slugify(base) || 'page';
|
|
const taken = new Set(
|
|
(
|
|
await this.prisma.page.findMany({
|
|
where: { pondId, OR: [{ slug }, { slug: { startsWith: `${slug}-` } }] },
|
|
select: { slug: true },
|
|
})
|
|
).map((row) => row.slug),
|
|
);
|
|
if (!taken.has(slug)) return slug;
|
|
for (let n = 2; ; n += 1) {
|
|
const candidate = `${slug}-${n}`;
|
|
if (!taken.has(candidate)) return candidate;
|
|
}
|
|
}
|
|
|
|
/** Loads a live page; permission is the guard's job since #52. */
|
|
private async findLivePage(id: string): Promise<Page> {
|
|
const page = await this.prisma.page.findFirst({ where: { id, deletedAt: null } });
|
|
if (!page) throw new NotFoundException();
|
|
return page;
|
|
}
|
|
|
|
/** The live `{id, parentId}` skeleton of a pond — input to the tree walks
|
|
* (issue #106). Trashed pages keep their `parentId` but never count here. */
|
|
private async livePageTree(pondId: string): Promise<TreeItem[]> {
|
|
return this.prisma.page.findMany({
|
|
where: { pondId, deletedAt: null },
|
|
select: { id: true, parentId: true },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validates a page's new parent (issue #106, mirroring `LabelsService`): it
|
|
* must be a live page of the pond (unknown/foreign ids read as 404), may not
|
|
* sit inside the moved page's own subtree (`page_cycle`), and the moved
|
|
* subtree must stay within {@link MAX_PAGE_DEPTH} (`page_depth_exceeded`).
|
|
* `movedId` is null when creating — the new page is a leaf of height 1.
|
|
*/
|
|
private assertValidParent(tree: TreeItem[], parentId: string, movedId: string | null): void {
|
|
if (!tree.some((p) => p.id === parentId)) throw new NotFoundException();
|
|
if (movedId && collectSubtreeIds(tree, movedId).has(parentId)) {
|
|
throw new ConflictException({ code: 'page_cycle' });
|
|
}
|
|
const height = movedId ? subtreeHeight(tree, movedId) : 1;
|
|
if (nodeDepth(tree, parentId) + height > MAX_PAGE_DEPTH) {
|
|
throw new ConflictException({
|
|
code: 'page_depth_exceeded',
|
|
details: { max: [String(MAX_PAGE_DEPTH)] },
|
|
});
|
|
}
|
|
}
|
|
|
|
private static readonly SORT_ORDER: Record<SidebarSortMode, Prisma.PageOrderByWithRelationInput> =
|
|
{
|
|
alpha: { title: 'asc' },
|
|
created: { createdAt: 'asc' },
|
|
// Manual reordering (drag-and-drop) arrives with #45; the fractional
|
|
// `sortKey` already reflects creation order in the meantime.
|
|
manual: { sortKey: 'asc' },
|
|
};
|
|
|
|
/** Sidebar page list, ordered per the pond's persisted sort mode (issue #26),
|
|
* each with its assigned label ids for chips and filtering (issue #44).
|
|
* Filtered to the pages the user may read (issue #52) — a label- or
|
|
* page-scoped reader sees only their slice of the pond. */
|
|
async list(user: User, pondId: string): Promise<PageListItemView[]> {
|
|
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
|
if (!pond) throw new NotFoundException();
|
|
const settings = pondSettingsSchema.parse(pond.settings ?? {});
|
|
const pages = await this.prisma.page.findMany({
|
|
where: { pondId, deletedAt: null },
|
|
orderBy: PagesService.SORT_ORDER[settings.sidebarSort],
|
|
include: { labels: { select: { labelId: true } } },
|
|
});
|
|
const readable = await this.permissions.filterPages(
|
|
user,
|
|
pondId,
|
|
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
|
|
'read',
|
|
);
|
|
return pages
|
|
.filter((page) => readable.has(page.id))
|
|
.map((page) => ({
|
|
...this.viewOf(page),
|
|
// A parent the caller may not read is nulled (issue #106): the child
|
|
// shows at the root and the hidden page's id never leaks.
|
|
parentId: page.parentId && readable.has(page.parentId) ? page.parentId : null,
|
|
labelIds: page.labels.map((l) => l.labelId),
|
|
}));
|
|
}
|
|
|
|
/** `readPond.listPages` for the plugin API (issue #77): the viewer-readable
|
|
* pages with their label *names* — pageTool plugins filter on these and
|
|
* never see ids they could not resolve anyway. */
|
|
async pluginPageSummaries(user: User, pondId: string): Promise<PluginPageSummary[]> {
|
|
const [pages, labels] = await Promise.all([
|
|
this.list(user, pondId),
|
|
this.prisma.label.findMany({ where: { pondId }, select: { id: true, name: true } }),
|
|
]);
|
|
const nameById = new Map(labels.map((label) => [label.id, label.name]));
|
|
return pages.map((page) => ({
|
|
id: page.id,
|
|
title: page.title,
|
|
slug: page.slug,
|
|
labels: page.labelIds
|
|
.map((id) => nameById.get(id))
|
|
.filter((name): name is string => Boolean(name))
|
|
.sort(),
|
|
}));
|
|
}
|
|
|
|
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
|
|
const page = await this.insertPage(
|
|
user,
|
|
pondId,
|
|
input.title,
|
|
emptyPageState(),
|
|
input.parentId ?? null,
|
|
);
|
|
// Auto-watch own pages (issue #93) — preference-gated, never fatal.
|
|
await this.watches.autoWatchPage(user, page.id, 'ownPage').catch(() => {});
|
|
return this.viewOf(page);
|
|
}
|
|
|
|
/**
|
|
* Create a page from a prepared Yjs state (issue #63 import): the imported
|
|
* document is already a full Yjs state whose fragment the editor binds to, so
|
|
* an opening client sees the converted content immediately. Same invariants
|
|
* as {@link create} — unique slug, appended sort key, derived content cache,
|
|
* phantom-link resolution, search indexing. Returns the persisted row so the
|
|
* caller (the import worker) can link the document's media to it.
|
|
*/
|
|
async createWithState(
|
|
user: User,
|
|
pondId: string,
|
|
title: string,
|
|
state: Uint8Array<ArrayBuffer>,
|
|
parentId: string | null = null,
|
|
): Promise<Page> {
|
|
return this.insertPage(user, pondId, title, state, parentId);
|
|
}
|
|
|
|
/**
|
|
* Move a page to a new parent, appended at the end of the pond order
|
|
* (issue #110): the placement clients cannot express when they only speak
|
|
* slugs (public API, MCP). Same validation path as {@link reposition}.
|
|
*/
|
|
async moveToEnd(user: User, id: string, parentId: string | null): Promise<PageView> {
|
|
const page = await this.findLivePage(id);
|
|
const last = await this.prisma.page.findFirst({
|
|
where: { pondId: page.pondId, deletedAt: null, id: { not: id } },
|
|
orderBy: { sortKey: 'desc' },
|
|
select: { id: true },
|
|
});
|
|
return this.reposition(user, id, { afterId: last?.id ?? null, beforeId: null, parentId });
|
|
}
|
|
|
|
private async insertPage(
|
|
user: User,
|
|
pondId: string,
|
|
title: string,
|
|
state: Uint8Array<ArrayBuffer>,
|
|
parentId: string | null = null,
|
|
): Promise<Page> {
|
|
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
|
if (!pond) throw new NotFoundException();
|
|
if (parentId) this.assertValidParent(await this.livePageTree(pond.id), parentId, null);
|
|
|
|
const slug = await this.generateUniqueSlugInPond(pond.id, title);
|
|
const last = await this.prisma.page.findFirst({
|
|
where: { pondId: pond.id },
|
|
orderBy: { sortKey: 'desc' },
|
|
select: { sortKey: true },
|
|
});
|
|
const sortKey = generateKeyBetween(last?.sortKey ?? null, null);
|
|
const content = deriveContent(state);
|
|
|
|
const page = await this.prisma.page.create({
|
|
data: {
|
|
pondId: pond.id,
|
|
parentId,
|
|
title,
|
|
slug,
|
|
sortKey,
|
|
ydocState: state,
|
|
createdBy: user.id,
|
|
contentCache: { create: contentCacheData(content) },
|
|
},
|
|
});
|
|
// A new page may satisfy phantom wikilinks that referenced its slug (#47).
|
|
await this.resolvePhantomLinks(pond.id, slug, page.id);
|
|
// Index the page so a title-only match is findable immediately (#49).
|
|
await this.search.indexPage(page.id);
|
|
this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created');
|
|
return page;
|
|
}
|
|
|
|
/**
|
|
* Point phantom `page_links` (unresolved, `to_page_id` null) whose
|
|
* `target_slug` matches `slug` within the pond at `pageId` (issue #47).
|
|
* Called when a page is created or renamed to a slug that pages already link
|
|
* to. Backlinks store the target's id, so once resolved a later target rename
|
|
* keeps them connected.
|
|
*/
|
|
private async resolvePhantomLinks(pondId: string, slug: string, pageId: string): Promise<void> {
|
|
await this.prisma.$executeRaw`
|
|
UPDATE page_links SET to_page_id = ${pageId}
|
|
WHERE to_page_id IS NULL AND target_slug = ${slug}
|
|
AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`;
|
|
}
|
|
|
|
async getState(_user: User, id: string): Promise<PageStateView> {
|
|
const page = await this.findLivePage(id);
|
|
return this.stateViewOf(page);
|
|
}
|
|
|
|
/**
|
|
* Mint a collaboration token for a page (issue #34). The permission check
|
|
* runs in the api — the guard requires read access, and the collab server
|
|
* never sees session cookies (ADR 0003). `mode` is `rw` for who may write
|
|
* the page per the real grant resolution (issue #52) and `ro` otherwise.
|
|
*
|
|
* `user` is `null` for an anonymous visitor on a public page (issue #53): the
|
|
* guard has already granted read access via a `public` grant, so they receive
|
|
* an `ro` token with a `null` subject.
|
|
*/
|
|
async issueCollabToken(user: User | null, id: string): Promise<CollabTokenResponse> {
|
|
const page = await this.findLivePage(id);
|
|
|
|
const canWrite = await this.permissions.canAccessPage(user, page, 'write');
|
|
const mode = canWrite ? 'rw' : 'ro';
|
|
const userId = user?.id ?? null;
|
|
const token = signCollabToken(
|
|
{ userId, pageId: page.id, mode },
|
|
this.config.env.COLLAB_TOKEN_SECRET,
|
|
COLLAB_TOKEN_TTL_SECONDS,
|
|
);
|
|
// Debug level, and deliberately without the token value (issue #34).
|
|
this.logger.debug({ pageId: page.id, userId, mode }, 'issued collab token');
|
|
return { token, mode, expiresInSeconds: COLLAB_TOKEN_TTL_SECONDS };
|
|
}
|
|
|
|
/** The trashed-page hint for editors (issue #31) moved into the guard. */
|
|
async getStateBySlug(_user: User, pondId: string, slug: string): Promise<PageStateView> {
|
|
const page = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null } });
|
|
if (!page) throw new NotFoundException();
|
|
return this.stateViewOf(page);
|
|
}
|
|
|
|
async update(_user: User, id: string, input: UpdatePageInput): Promise<PageView> {
|
|
const page = await this.findLivePage(id);
|
|
|
|
let slug = page.slug;
|
|
if (input.slug !== undefined) {
|
|
const normalized = slugify(input.slug) || page.slug;
|
|
if (normalized !== page.slug) {
|
|
const clash = await this.prisma.page.findFirst({
|
|
where: { pondId: page.pondId, slug: normalized, id: { not: page.id } },
|
|
select: { id: true },
|
|
});
|
|
if (clash) throw new ConflictException({ code: 'slug_taken' });
|
|
}
|
|
slug = normalized;
|
|
}
|
|
|
|
const updated = await this.prisma.page.update({
|
|
where: { id: page.id },
|
|
data: { title: input.title, slug },
|
|
});
|
|
// Renaming to a slug pages already link to resolves those phantom links (#47).
|
|
if (slug !== page.slug) await this.resolvePhantomLinks(page.pondId, slug, page.id);
|
|
// A changed title changes the (weighted) search entry (#49).
|
|
if (input.title !== undefined && input.title !== page.title) {
|
|
await this.search.indexPage(page.id);
|
|
}
|
|
return this.viewOf(updated);
|
|
}
|
|
|
|
/**
|
|
* Reposition a page in the manual sidebar order (issue #45) and/or move it to
|
|
* a new parent in the page tree (issue #106). Recomputes only the moved
|
|
* page's `sort_key` to a value between its two new neighbours; when that key
|
|
* would grow too long (or the client's neighbours are stale) the whole pond
|
|
* is rebalanced to evenly-spaced keys with the page dropped at the target
|
|
* slot. Per-sibling-group order needs no extra machinery: a key between two
|
|
* siblings keeps the group's relative order under the pond-wide sequence.
|
|
* The order is server-authoritative, so every viewer sees the same sequence.
|
|
* Requires write access; the sort mode does not have to be `manual` (the key
|
|
* is stored regardless, just not applied in other modes).
|
|
*/
|
|
async reposition(_user: User, id: string, input: RepositionPageInput): Promise<PageView> {
|
|
const page = await this.findLivePage(id);
|
|
const { afterId, beforeId } = input;
|
|
if (afterId === id || beforeId === id) {
|
|
throw new ConflictException({ code: 'bad_request' });
|
|
}
|
|
|
|
// An absent `parentId` leaves the parent untouched; a present one (page id
|
|
// or null-for-root) reparents atomically with the placement (issue #106).
|
|
const parentId = input.parentId === page.parentId ? undefined : input.parentId;
|
|
if (parentId != null) {
|
|
this.assertValidParent(await this.livePageTree(page.pondId), parentId, id);
|
|
}
|
|
|
|
const [afterPage, beforePage] = await Promise.all([
|
|
afterId
|
|
? this.prisma.page.findFirst({
|
|
where: { id: afterId, pondId: page.pondId, deletedAt: null },
|
|
select: { sortKey: true },
|
|
})
|
|
: null,
|
|
beforeId
|
|
? this.prisma.page.findFirst({
|
|
where: { id: beforeId, pondId: page.pondId, deletedAt: null },
|
|
select: { sortKey: true },
|
|
})
|
|
: null,
|
|
]);
|
|
if (afterId && !afterPage) throw new NotFoundException();
|
|
if (beforeId && !beforePage) throw new NotFoundException();
|
|
|
|
const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null);
|
|
if (key !== null) {
|
|
const updated = await this.prisma.page.update({
|
|
where: { id },
|
|
data: { sortKey: key, parentId },
|
|
});
|
|
return this.viewOf(updated);
|
|
}
|
|
return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId, parentId);
|
|
}
|
|
|
|
/**
|
|
* Reassign evenly-spaced `sort_key`s to every page in the pond, with the
|
|
* moved page inserted at the slot implied by `afterId`/`beforeId`. Runs in one
|
|
* transaction so the order is never observed half-rebalanced.
|
|
*/
|
|
private async rebalanceAndPlace(
|
|
pondId: string,
|
|
movedId: string,
|
|
afterId: string | null,
|
|
beforeId: string | null,
|
|
parentId?: string | null,
|
|
): Promise<PageView> {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const pages = await tx.page.findMany({
|
|
where: { pondId, deletedAt: null },
|
|
orderBy: { sortKey: 'asc' },
|
|
select: { id: true },
|
|
});
|
|
const order = pages.map((p) => p.id).filter((pid) => pid !== movedId);
|
|
let index = order.length;
|
|
if (afterId) index = order.indexOf(afterId) + 1;
|
|
else if (beforeId) index = Math.max(0, order.indexOf(beforeId));
|
|
order.splice(index, 0, movedId);
|
|
|
|
const keys = evenlySpacedKeys(order.length);
|
|
await Promise.all(
|
|
order.map((pid, i) =>
|
|
tx.page.update({
|
|
where: { id: pid },
|
|
data: { sortKey: keys[i]!, ...(pid === movedId ? { parentId } : {}) },
|
|
}),
|
|
),
|
|
);
|
|
this.logger.info({ pondId, movedId, pages: order.length }, 'audit: sort keys rebalanced');
|
|
return this.viewOf(await tx.page.findUniqueOrThrow({ where: { id: movedId } }));
|
|
});
|
|
}
|
|
|
|
/** Markdown export (issue #30) — serves the already-derived
|
|
* `page_content_cache.markdown` (refreshed on every state save, #23)
|
|
* rather than re-decoding the Yjs state, so export always matches what
|
|
* the app itself considers the page's current Markdown representation. */
|
|
async exportMarkdown(_user: User, id: string): Promise<{ slug: string; markdown: string }> {
|
|
const page = await this.findLivePage(id);
|
|
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
|
return { slug: page.slug, markdown: cache?.markdown ?? '' };
|
|
}
|
|
|
|
/** The page's heading outline from the content cache (plugin API #74).
|
|
* Permission is enforced by the caller's page-read guard. */
|
|
async outline(id: string): Promise<OutlineEntry[]> {
|
|
const page = await this.findLivePage(id);
|
|
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
|
return (cache?.outline as unknown as OutlineEntry[] | undefined) ?? [];
|
|
}
|
|
|
|
/** Minimal page metadata for the plugin API (#74): id, title, pond, slug. */
|
|
async meta(id: string): Promise<{ id: string; title: string; pondId: string; slug: string }> {
|
|
const page = await this.findLivePage(id);
|
|
return { id: page.id, title: page.title, pondId: page.pondId, slug: page.slug };
|
|
}
|
|
|
|
/**
|
|
* 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 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,
|
|
mode,
|
|
descendants: mode === 'subtree' ? descendantIds.length : 0,
|
|
},
|
|
'audit: page trashed',
|
|
);
|
|
}
|
|
}
|