import { Inject, Injectable } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EventBus } from "../../common/event-bus.js"; import { NotificationsService } from "../notifications/notifications.service.js"; import { ApiError } from "../../common/errors.js"; import { assertCan } from "../../lib/authorize.js"; import { between, rebalancePositions } from "../../lib/positions.js"; import { parsePagination } from "../../lib/pagination.js"; import { EVENTS, DEFAULT_DB_PROPERTIES } from "../../constants.js"; import { actorLevel } from "../../lib/authorize.js"; import { diffSnapshots, resolveTemplateValues, substituteSnapshot, validateTemplateVars, type PageSnapshot as PageSnapshotInput } from "@factory/shared"; import { CANVAS_VERSION, emptyCanvas, validateCanvas } from "@factory/whiteboard"; import { renderPageMarkdown, exportFilename, type ExportInput } from "./page-export.js"; const PAGE_SELECT = { id: true, workspaceId: true, teamId: true, parentId: true, position: true, title: true, icon: true, coverUrl: true, description: true, isPrivate: true, isFavorite: true, archivedAt: true, trashedAt: true, restoredAt: true, createdBy: true, version: true, createdAt: true, updatedAt: true, // T-144: content-type + review-state surface (whiteboard/task pages, // review workflow). Legacy rows read as page/published. kind: true, reviewState: true, // T-149: move guards (a deleted page cannot be moved). deletedAt: true } as const; type PageRow = Prisma.PageGetPayload<{ select: typeof PAGE_SELECT }>; /** * Page service: tree CRUD, trash/restore, versions, favorites, templates. * A "database" page also creates the Database row + default properties/views * (seed kept here to avoid a circular dependency with the databases module — * the databases module owns all database *query* logic). */ @Injectable() export class PageService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus, @Inject(NotificationsService) private readonly notifications: NotificationsService ) {} // ---------- lookups ---------- private async getPage(pageId: string): Promise { const page = await this.prisma.page.findUnique({ where: { id: pageId }, select: PAGE_SELECT }); if (!page) throw ApiError.notFound(); return page; } private async assertInWorkspace(pageId: string, workspaceId: string): Promise { const page = await this.getPage(pageId); if (page.workspaceId !== workspaceId) throw ApiError.notFound(); return page; } /** * Last-resort re-spacing for a page sibling list that exhausted its * fractional headroom (the "position too close to the top" RangeError the * old midpoint-to-top append rule produced after ~127 appends). Rewrites * every live sibling to an even grid inside `tx`; the caller then retries * the position computation. Mirrors the blocks service' * `rebalanceSiblingList` (pages have no per-tx client here, so it takes * the root client). */ private async rebalancePageSiblings(tx: Prisma.TransactionClient, workspaceId: string, parentId: string | null): Promise { const rows = await tx.page.findMany({ where: { workspaceId, parentId, deletedAt: null, trashedAt: null }, orderBy: { position: "asc" }, select: { id: true, position: true } }); const fresh = rebalancePositions(rows.map((r) => r.position)); for (let i = 0; i < rows.length; i++) { if (rows[i]!.position !== fresh[i]) { await tx.page.update({ where: { id: rows[i]!.id }, data: { position: fresh[i] } }); } } } /** Append-position for a page sibling list, with in-tx rebalance on exhaustion. */ private async appendPagePosition(tx: Prisma.TransactionClient, workspaceId: string, parentId: string | null): Promise { const last = await tx.page.findFirst({ where: { workspaceId, parentId, deletedAt: null, trashedAt: null }, orderBy: { position: "desc" }, select: { position: true } }); try { return between(last?.position); } catch (err) { if (!(err instanceof RangeError)) throw err; await this.rebalancePageSiblings(tx, workspaceId, parentId); return this.appendPagePosition(tx, workspaceId, parentId); } } // ---------- list / create ---------- async list( workspaceId: string, userId: string, query: { parentId?: string | null; cursor?: string; limit?: string; trashed?: boolean; favorites?: boolean } ) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); const { cursor, limit } = parsePagination(query); const parentId = query.parentId ?? null; if (query.trashed && query.favorites) { throw new ApiError( "VALIDATION_ERROR", 400, "trashed and favorites are mutually exclusive.", [{ field: "trashed", issue: "mutually exclusive with favorites" }] ); } // --- favorites: the caller's PageFavorite rows (Phase 3 PR-030, ordered // by the favorite's fractional position — currently all "a0" since the // reorder API is not built yet, so `(position, pageId)` is the composite // order + cursor key), joined to live page rows. `deletedAt: null` keeps // pages trashed AFTER being starred (the page's trashedAt shows the state). if (query.favorites) { const or: object[] = []; if (cursor) { or.push({ position: { gt: cursor.position } }, { position: cursor.position, pageId: { gt: cursor.id } }); } const favs = await this.prisma.pageFavorite.findMany({ where: { workspaceId, userId, ...(or.length ? { OR: or } : {}) }, orderBy: [{ position: "asc" }, { pageId: "asc" }], take: limit + 1 }); const keptFavs = favs.slice(0, limit); const rows = await this.prisma.page.findMany({ where: { id: { in: keptFavs.map((f) => f.pageId) }, deletedAt: null }, select: { ...PAGE_SELECT, _count: { select: { children: true, blocks: true } } } }); const byId = new Map(rows.map((r) => [r.id, r])); const dbIds = await this.databaseIdByPage(keptFavs.map((f) => ({ id: f.pageId }))); const data = keptFavs .map((f) => { const r = byId.get(f.pageId); return r ? { ...r, favorite: true, databaseId: dbIds.get(f.pageId) ?? null, childrenCount: r._count.children } : null; }) .filter((r): r is NonNullable => r !== null); const next = keptFavs[keptFavs.length - 1]; return { data, nextCursor: favs.length > limit && next ? Buffer.from(JSON.stringify({ id: next.pageId, position: next.position })).toString("base64url") : null }; } // --- trash: pages with trashedAt set (deletedAt null — purge hard-deletes // after the retention window, so those are gone). The docs gate this // Admin/Owner-only (docs/api/pages.md §3), so a GUEST/VIEW-level member // gets 403 even though the trash is their own workspace. const isTrash = query.trashed === true; if (isTrash) { const role = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } }, select: { role: true } }); if (!role || (role.role !== "OWNER" && role.role !== "ADMIN")) { throw new ApiError("FORBIDDEN", 403, "Only workspace Admins/Owners can view the trash.", [ { reason: "trash_requires_admin" } ]); } } if (isTrash) { // List the TRASHED ROOTS only: a page is in the trash view when its own // trashedAt is set and its parent is not (a trashed subtree shows once, // at its root — restore(root) recursively un-trashes the whole set). // `parentId` is intentionally ignored (a trashed root can sit under any // parent). Ordering is `(position, id)` — position is only unique WITHIN // a sibling set, so across parents the id is a stable tie-breaker and // the cursor predicate is composite (no row skipping on dup positions). const or: object[] = [ { parentId: null }, { parent: { trashedAt: null } } ]; if (cursor) { or.push({ position: { gt: cursor.position } }, { position: cursor.position, id: { gt: cursor.id } }); } const rows = await this.prisma.page.findMany({ where: { workspaceId, deletedAt: null, trashedAt: { not: null }, OR: or }, select: { ...PAGE_SELECT, _count: { select: { children: true, blocks: true } } }, orderBy: [{ position: "asc" }, { id: "asc" }], take: limit + 1 }); const page_ = rows.slice(0, limit); const dbIds = await this.databaseIdByPage(page_); const next = rows.length > limit ? page_[page_.length - 1] : undefined; return { data: page_.map((p) => ({ ...p, favorite: false, databaseId: dbIds.get(p.id) ?? null, childrenCount: p._count.children })), nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id, position: next.position })).toString("base64url") : null }; } const rows = await this.prisma.page.findMany({ where: { workspaceId, deletedAt: null, trashedAt: null, parentId, ...(cursor ? { position: { gt: cursor.position } } : {}) }, select: { ...PAGE_SELECT, _count: { select: { children: true, blocks: true } } }, orderBy: { position: "asc" }, take: limit + 1 }); const page_ = rows.slice(0, limit); const dbIds = await this.databaseIdByPage(page_); const next = rows.length > limit ? page_[page_.length - 1] : undefined; return { data: page_.map((p) => ({ ...p, favorite: p.isFavorite, databaseId: dbIds.get(p.id) ?? null, childrenCount: p._count.children })), nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id, position: next.position })).toString("base64url") : null }; } async create(workspaceId: string, userId: string, input: { title?: string; parentId?: string; type?: "page" | "database" | "whiteboard"; teamId?: string; templateId?: string }, req: any) { await assertCan(this.prisma, { workspaceId, userId }, "EDIT"); const title = input.title ?? ""; if (input.templateId) { return this.applyTemplate(workspaceId, userId, input.templateId, { parentId: input.parentId, title }, req); } const parentId = input.parentId ?? null; let grandparentId: string | null = null; if (parentId) { const parent = await this.assertInWorkspace(parentId, workspaceId); if (parent.trashedAt) throw ApiError.validation("Cannot create under a trashed page.", [{ field: "parentId", issue: "parent_trashed" }]); // Denormalized (spec TASK-0035: "grandparentId maintained by the service // (parent's parentId) on create/move") — the parent's own parent. grandparentId = parent.parentId; } const position = await this.appendPagePosition(this.prisma, workspaceId, parentId); const teamId = input.teamId ?? (parentId ? (await this.getPage(parentId)).teamId ?? null : null); // T-167 (A-001): a whiteboard page carries its canvas in the 1:1 // `whiteboards` sidecar — set the kind at creation (legacy pages default // to "page" and are unaffected). const isWhiteboard = input.type === "whiteboard"; const page = await this.prisma.page.create({ data: { workspaceId, teamId, parentId, grandparentId, position, title, ...(isWhiteboard ? { kind: "whiteboard" as const } : {}), createdBy: userId, updatedBy: userId }, select: PAGE_SELECT }); // Default child blocks: title block + (for databases) a database block. const isDb = input.type === "database"; const titleBlockId = `blk_${Math.random().toString(36).slice(2, 14)}`; const blockIds = [titleBlockId]; if (isDb) blockIds.push(`blk_${Math.random().toString(36).slice(2, 14)}`); let databaseId: string | undefined; let dbPropertyCount = 0; await this.prisma.$transaction(async (tx) => { await tx.block.create({ data: { id: titleBlockId, pageId: page.id, workspaceId, parentId: null, type: "PARAGRAPH", position: "A", content: { text: title }, properties: { isTitle: true }, createdBy: userId } }); if (isDb) { const db = await tx.database.create({ data: { workspaceId, pageId: page.id, title, createdBy: userId } }); databaseId = db.id; dbPropertyCount = await this.seedDefaultDatabase(tx, db.id); await tx.block.create({ data: { id: blockIds[1]!, pageId: page.id, workspaceId, parentId: null, type: "DATABASE", position: "B", content: { databaseId: db.id }, createdBy: userId } }); } if (isWhiteboard) { // The sidecar starts as an empty, version-stamped canvas. await tx.whiteboard.create({ data: { pageId: page.id, canvas: emptyCanvas() as unknown as Prisma.InputJsonValue, schemaVersion: CANVAS_VERSION, updatedBy: userId } }); } }); if (databaseId) { // TASK-0133 audit catalog: creating a database page ALSO fires // `database.created` (catalog requires `title`) — alongside the // existing `page.created` row (a database is both a page AND a database). await this.audit.audit(req, req?.auth, { action: EVENTS.DATABASE_CREATED, resourceType: "database", resourceId: databaseId, workspaceId, metadata: { title, pageId: page.id } }); await this.events.publish(EVENTS.DATABASE_CREATED, { workspaceId, databaseId, pageId: page.id, propertyCount: dbPropertyCount }); } await this.audit.audit(req, req?.auth, { action: EVENTS.PAGE_CREATED, resourceType: "page", resourceId: page.id, workspaceId, pageId: page.id, metadata: { title, parentId, type: input.type ?? "page" } }); await this.events.publish(EVENTS.PAGE_CREATED, { workspaceId, pageId: page.id, title, parentId, databaseId }); return { ...page, databaseId, blockIds }; } /** Map pageId → the seeded Database row's id (the Database row has its OWN * cuid; `create` already returns it for new pages — list endpoints attach * it so clients can tell a database page from a plain page). Pages without * a database row are simply absent from the map. */ private async databaseIdByPage( pages: { id: string }[] ): Promise> { if (pages.length === 0) return new Map(); const rows = await this.prisma.database.findMany({ where: { pageId: { in: pages.map((p) => p.id) } }, select: { id: true, pageId: true } }); const map = new Map(); for (const r of rows) if (r.pageId) map.set(r.pageId, r.id); return map; } /** Seed Title + Status(select) + default table view into a fresh database. */ private async seedDefaultDatabase( tx: Prisma.TransactionClient, databaseId: string ): Promise { let position: string | undefined; for (const seed of DEFAULT_DB_PROPERTIES) { await tx.databaseProperty.create({ data: { databaseId, name: seed.name, type: seed.type as never, isTitle: "isTitle" in seed ? Boolean(seed.isTitle) : false, config: ("config" in seed ? seed.config : {}) as object, position: (position = between(position))! } }); } await tx.databaseView.create({ data: { databaseId, name: "Table View", type: "TABLE", position: "A" } }); return DEFAULT_DB_PROPERTIES.length; } // ---------- get / update ---------- async get(pageId: string, userId: string) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); const [workspace, childrenCount, blocksCount, database] = await Promise.all([ this.prisma.workspace.findUnique({ where: { id: page.workspaceId }, select: { id: true, name: true, slug: true } }), this.prisma.page.count({ where: { parentId: page.id, deletedAt: null, trashedAt: null } }), this.prisma.block.count({ where: { pageId: page.id, deletedAt: null, parentId: null } }), this.prisma.database.findFirst({ where: { pageId: page.id }, select: { id: true, title: true, isArchived: true } }) ]); return { page, workspace, childrenCount, topLevelBlocks: blocksCount, database }; } async update(pageId: string, userId: string, input: { title?: string; parentId?: string | null; teamId?: string | null; archived?: boolean; icon?: string | null; coverUrl?: string | null; description?: string | null }, req: any) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT"); // Unchecked (scalar) variant: parentId/teamId are scalars on Page (the // relation-variant PageUpdateInput only offers `parent`/`team` writes). const data: Prisma.PageUncheckedUpdateInput = { updatedBy: userId, version: { increment: 1 } }; if (input.title !== undefined) data.title = input.title; if (input.icon !== undefined) data.icon = input.icon; if (input.coverUrl !== undefined) data.coverUrl = input.coverUrl; if (input.description !== undefined) data.description = input.description; if (input.teamId !== undefined) data.teamId = input.teamId; if (input.archived !== undefined) { data.archivedAt = input.archived ? new Date() : null; } if (input.parentId !== undefined && input.parentId !== page.parentId) { const newParentId = input.parentId; let grandparentId: string | null = null; if (newParentId === pageId) { throw ApiError.validation("A page cannot be its own parent.", [{ field: "parentId", issue: "circular" }]); } if (newParentId) { const parent = await this.prisma.page.findUnique({ where: { id: newParentId } }); if (!parent || parent.workspaceId !== page.workspaceId) throw ApiError.notFound("Parent page not found."); if (parent.trashedAt || parent.deletedAt) throw ApiError.validation("Cannot move under a trashed page.", [{ field: "parentId", issue: "parent_trashed" }]); // Keep the denormalized grandparent in sync on reparent (TASK-0035). grandparentId = parent.parentId; // reject cycles: new parent must not be a descendant of the page let cursorId: string | null = newParentId; const seen = new Set(); while (cursorId) { if (cursorId === pageId) throw ApiError.validation("Move would create a circular page tree.", [{ field: "parentId", issue: "circular" }]); if (seen.has(cursorId)) break; seen.add(cursorId); // Explicit annotation: `row` reads `cursorId` (its where) while // `cursorId` is reassigned from `row?.parentId`, a type cycle that // otherwise forces an implicit-`any` self-reference (TS7022). const row: { parentId: string | null } | null = await this.prisma.page.findUnique({ where: { id: cursorId }, select: { parentId: true } }); cursorId = row?.parentId ?? null; } } data.parentId = newParentId; data.grandparentId = grandparentId; data.position = await this.appendPagePosition(this.prisma, page.workspaceId, newParentId); } const updated = await this.prisma.page.update({ where: { id: pageId }, data, select: PAGE_SELECT }); // Keep the title block text in sync. if (input.title !== undefined) { const titleBlock = await this.prisma.block.findFirst({ where: { pageId, parentId: null, properties: { path: ["isTitle"], equals: true } }, select: { id: true } }); if (titleBlock) { await this.prisma.block.update({ where: { id: titleBlock.id }, data: { content: { text: input.title } as object } }); } } await this.audit.audit(req, req?.auth, { action: "page.updated", resourceType: "page", resourceId: pageId, workspaceId: page.workspaceId, pageId }); await this.events.publish(EVENTS.PAGE_UPDATED, { workspaceId: page.workspaceId, pageId, fields: Object.keys(input) }); // T-157: watched-page fan-out (canView recheck inside). Awaited so the // watcher's notification is durable before the response returns (deterministic), // but wrapped so it can never fail the triggering request (best-effort). try { await this.notifications.notifyWatchersForPageUpdate(pageId, userId); } catch { /* best-effort */ } return updated; } // ---------- T-167: whiteboard canvas ---------- private async assertWhiteboardPage(pageId: string): Promise { const page = await this.getPage(pageId); if (page.kind !== "whiteboard") throw ApiError.notFound(); // no-leak: a non-whiteboard page has no canvas return page; } /** Read the whiteboard canvas (VIEW). Unknown/non-whiteboard page → 404. */ async getCanvas(pageId: string, userId: string) { const page = await this.assertWhiteboardPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); const wb = await this.prisma.whiteboard.findUnique({ where: { pageId } }); if (!wb) throw ApiError.notFound("Whiteboard not found."); const parsed = validateCanvas(wb.canvas); return { pageId, schemaVersion: wb.schemaVersion, canvas: parsed.ok ? parsed.canvas : (wb.canvas as object), // The web renderer shows a read-only banner when the stored document // cannot be validated (unknown version / corrupted) — still return it. valid: parsed.ok, updatedAt: wb.updatedAt, updatedBy: wb.updatedBy }; } /** Save the whiteboard canvas (EDIT). Body: the full v1 canvas document. */ async saveCanvas(pageId: string, userId: string, canvas: unknown, req: any) { const page = await this.assertWhiteboardPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT"); const parsed = validateCanvas(canvas); if (!parsed.ok) { throw ApiError.validation("Invalid canvas document.", parsed.errors.map((e) => ({ field: "canvas", issue: e }))); } const wb = await this.prisma.whiteboard.upsert({ where: { pageId }, create: { pageId, canvas: parsed.canvas as object, schemaVersion: parsed.canvas.version, updatedBy: userId }, update: { canvas: parsed.canvas as object, schemaVersion: parsed.canvas.version, updatedBy: userId } }); // Keep the page's own version counter in step (content changed). await this.prisma.page.update({ where: { id: pageId }, data: { updatedBy: userId, version: { increment: 1 } } }); await this.audit.audit(req, req?.auth, { action: "page.whiteboard_updated", resourceType: "page", resourceId: pageId, workspaceId: page.workspaceId, pageId, metadata: { elementCount: Object.keys(parsed.canvas.elements).length, schemaVersion: parsed.canvas.version } }); await this.events.publish(EVENTS.PAGE_UPDATED, { workspaceId: page.workspaceId, pageId, fields: ["whiteboard"] }); return { pageId, schemaVersion: wb.schemaVersion, canvas: parsed.canvas, updatedAt: wb.updatedAt }; } /** * T-171: convert a whiteboard SELECTION into a real page (or a task page, * A-006). The canvas is re-read server-side (the caller supplies ids only — * never contents); text-bearing elements (stickies, text) become blocks in * selection order, everything else is skipped. Ids the canvas does not * know are dropped (a selection can go stale), so the only hard errors are * authz (403/404) and "nothing to convert" (400 — a page of zero blocks is * meaningless). */ async convertSelection(pageId: string, userId: string, input: { elementIds: string[]; target: "page" | "task"; title?: string; parentId?: string | null }, req: any) { const page = await this.getPage(pageId); if (page.kind !== "whiteboard") throw ApiError.notFound(); // no-leak await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT"); const wb = await this.prisma.whiteboard.findUnique({ where: { pageId } }); const parsed = wb ? validateCanvas(wb.canvas) : { ok: true as const, canvas: { version: CANVAS_VERSION, elements: {} as Record } }; if (!parsed.ok) throw ApiError.validation("Whiteboard canvas failed validation; save a valid document before converting.", parsed.errors.map((e) => ({ field: "canvas", issue: e }))); const known = parsed.canvas.elements; const ordered = input.elementIds.map((id) => known[id]).filter((el): el is (typeof known)[string] => el !== undefined); // One block per LINE (a sticky note with two lines is two paragraphs of // documentation; empty lines are dropped, selection order preserved). const lines = ordered .filter((el) => el.type === "sticky" || el.type === "text") .flatMap((el) => (el.text ?? "").split("\n")) .map((t) => t.trim()) .filter((t) => t.length > 0); if (lines.length === 0) { throw ApiError.validation("No text content to convert — select sticky notes or text objects with text.", [ { field: "elementIds", issue: "no_text_content", received: input.elementIds.join(",") } ]); } const workspaceId = page.workspaceId; const title = (input.title ?? "").trim() || lines[0]!.split("\n")[0]!.slice(0, 200); const parentId = input.parentId ?? null; if (parentId) { const parent = await this.assertInWorkspace(parentId, workspaceId); if (parent.trashedAt) throw ApiError.validation("Cannot create under a trashed page.", [{ field: "parentId", issue: "parent_trashed" }]); } const last = await this.prisma.page.findFirst({ where: { workspaceId, parentId, deletedAt: null, trashedAt: null }, orderBy: { position: "desc" }, select: { position: true } }); const position = between(last?.position); const isTask = input.target === "task"; const titleBlockId = `blk_${Math.random().toString(36).slice(2, 14)}`; const row = await this.prisma.$transaction(async (tx) => { const newPage = await tx.page.create({ data: { workspaceId, parentId, position, title, ...(isTask ? { kind: "task" as const } : {}), createdBy: userId, updatedBy: userId }, select: PAGE_SELECT }); await tx.block.create({ data: { id: titleBlockId, pageId: newPage.id, workspaceId, parentId: null, type: "PARAGRAPH", position: "A", content: { text: title }, properties: { isTitle: true }, createdBy: userId } }); // One block per line (a sticky with wrapped/entered lines keeps its // line breaks; fractional positions extend from "A"). let pos = "A"; for (const line of lines) { pos = between(pos); await tx.block.create({ data: { id: `blk_${Math.random().toString(36).slice(2, 14)}`, pageId: newPage.id, workspaceId, parentId: null, type: "PARAGRAPH", position: pos, content: { text: line }, createdBy: userId } }); } if (isTask) { const task = await tx.task.create({ data: { workspaceId, pageId: newPage.id, title, status: "todo", assigneeIds: [], createdBy: userId } }); return { page: newPage, task: { id: task.id, status: task.status, pageId: newPage.id } }; } return { page: newPage, task: undefined as undefined }; }); await this.audit.audit(req, req?.auth, { action: isTask ? "page.task_converted" : "page.whiteboard_converted", resourceType: "page", resourceId: row.page.id, workspaceId, pageId, metadata: { sourcePageId: pageId, target: input.target, blockCount: lines.length + 1, title } }); await this.events.publish(EVENTS.PAGE_CREATED, { workspaceId, pageId: row.page.id, title, parentId }); return { page: row.page, task: row.task, blockCount: lines.length + 1 }; } // ---------- trash / restore ---------- /** Move to trash (recursive: all descendants too). */ async trash(pageId: string, userId: string, req: any) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT"); const now = new Date(); const ids = [pageId, ...(await this.descendantPageIds(pageId))]; await this.prisma.page.updateMany({ where: { id: { in: ids } }, data: { trashedAt: now, updatedBy: userId } }); // TASK-0133 audit catalog: the trash action is `page.deleted` (the // catalog's deletion event) with the CATALOG-required `pageIds` (the // whole recursive set — root + descendants). await this.audit.audit(req, req?.auth, { action: "page.deleted", resourceType: "page", resourceId: pageId, workspaceId: page.workspaceId, pageId, metadata: { pageIds: ids, descendants: ids.length - 1, permanent: false } }); await this.events.publish(EVENTS.PAGE_DELETED, { workspaceId: page.workspaceId, pageId, permanent: false, pageIds: ids }); return { trashed: true, pageIds: ids }; } /** Restore from trash (page + trashed descendants). */ async restore(pageId: string, userId: string, req: any) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT"); if (!page.trashedAt) throw ApiError.validation("Page is not in the trash.", [{ issue: "not_trashed" }]); const ids = [pageId, ...(await this.descendantPageIds(pageId))]; await this.prisma.page.updateMany({ where: { id: { in: ids }, trashedAt: { not: null } }, data: { trashedAt: null, restoredAt: new Date(), updatedBy: userId } }); await this.audit.audit(req, req?.auth, { action: "page.restored", resourceType: "page", resourceId: pageId, workspaceId: page.workspaceId, pageId }); return { restored: true, pageIds: ids }; } /** Permanent delete (trash only; owner/admin) — pages, blocks, records. */ async deletePermanent(pageId: string, userId: string, req: any) { const page = await this.getPage(pageId); const level = await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT"); if (level !== "FULL_ACCESS") { throw ApiError.forbidden("Permanent delete requires full access (owner/admin).", [ { reason: "requires_FULL_ACCESS" } ]); } if (!page.trashedAt) { throw ApiError.validation("Only trashed pages can be permanently deleted.", [{ issue: "must_be_trashed" }]); } const ids = [pageId, ...(await this.descendantPageIds(pageId))]; await this.prisma.$transaction(async (tx) => { for (const id of ids) { await tx.databaseRecord.deleteMany({ where: { pageId: id } }); await tx.block.deleteMany({ where: { pageId: id } }); await tx.page.delete({ where: { id } }); } }); await this.audit.audit(req, req?.auth, { action: "page.permanent_deleted", resourceType: "page", resourceId: pageId, workspaceId: page.workspaceId, metadata: { pageIds: ids } }); await this.events.publish(EVENTS.PAGE_DELETED, { workspaceId: page.workspaceId, pageId, permanent: true, pageIds: ids }); } private async descendantPageIds(pageId: string): Promise { const ids: string[] = []; const queue = [pageId]; while (queue.length > 0) { const current = queue.shift()!; const children = await this.prisma.page.findMany({ where: { parentId: current }, select: { id: true } }); for (const c of children) { ids.push(c.id); queue.push(c.id); } } return ids; } // ---------- versions ---------- async listVersions(pageId: string, userId: string) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); const versions = await this.prisma.pageVersion.findMany({ where: { pageId }, orderBy: { version: "desc" }, take: 50, select: { id: true, version: true, note: true, createdBy: true, createdAt: true } }); return { data: versions }; } /** Snapshot the current state as a new version. */ async createVersion(pageId: string, userId: string, note?: string) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT"); const max = await this.prisma.pageVersion.aggregate({ where: { pageId }, _max: { version: true } }); const blocks = await this.prisma.block.findMany({ where: { pageId, deletedAt: null } }); // T-170: a whiteboard page's content lives in the sidecar, not blocks — // snapshot it too so "restore" can bring the canvas back. let whiteboard: { canvas: object; schemaVersion: number; updatedAt: Date } | undefined; if (page.kind === "whiteboard") { const wb = await this.prisma.whiteboard.findUnique({ where: { pageId } }); if (wb) whiteboard = { canvas: wb.canvas as object, schemaVersion: wb.schemaVersion, updatedAt: wb.updatedAt }; } const version = await this.prisma.pageVersion.create({ data: { pageId, version: (max._max.version ?? 0) + 1, snapshot: { page, blocks: blocks.map((b) => ({ id: b.id, type: b.type, position: b.position, content: b.content, properties: b.properties, parentId: b.parentId })), ...(whiteboard ? { whiteboard } : {}) } as object, createdBy: userId, note } }); return version; } /** T-151: read-only version preview — the full snapshot at `versionId`. */ async getVersionPreview(pageId: string, versionId: string, userId: string) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); const version = await this.prisma.pageVersion.findFirst({ where: { id: versionId, pageId } }); if (!version) throw ApiError.notFound("Version not found."); return { version: version.version, createdAt: version.createdAt, note: version.note, snapshot: version.snapshot }; } /** T-150: compare two versions of the same page (keyed by block id). */ async compareVersions(pageId: string, fromVersion: number, toVersion: number, userId: string) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); if (fromVersion === toVersion) throw ApiError.validation("from and to must differ.", [{ field: "from", issue: "same_version" }]); const [fromRow, toRow] = await Promise.all([ this.prisma.pageVersion.findFirst({ where: { pageId, version: fromVersion } }), this.prisma.pageVersion.findFirst({ where: { pageId, version: toVersion } }) ]); if (!fromRow || !toRow) throw ApiError.notFound(`Version ${fromRow ? toVersion : fromVersion} not found.`); return diffSnapshots(fromRow.snapshot as unknown as PageSnapshotInput, toRow.snapshot as unknown as PageSnapshotInput, fromVersion, toVersion); } /** Restore a page version (page fields + block contents). */ async restoreVersion(pageId: string, versionId: string, userId: string, req: any) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT"); const version = await this.prisma.pageVersion.findFirst({ where: { id: versionId, pageId } }); if (!version) throw ApiError.notFound("Version not found."); const snap = version.snapshot as { page?: { title?: string; icon?: string | null; description?: string | null }; blocks?: { id: string; content: object; properties: object; parentId: string | null }[]; whiteboard?: { canvas: object; schemaVersion: number; updatedAt: Date } }; await this.prisma.$transaction(async (tx) => { if (snap.page) { await tx.page.update({ where: { id: pageId }, data: { ...(snap.page.title !== undefined ? { title: snap.page.title } : {}), ...(snap.page.icon !== undefined ? { icon: snap.page.icon } : {}), ...(snap.page.description !== undefined ? { description: snap.page.description } : {}) } }); } for (const b of snap.blocks ?? []) { await tx.block.upsert({ where: { id: b.id }, update: { content: b.content, properties: b.properties, parentId: b.parentId }, create: { id: b.id, pageId, workspaceId: page.workspaceId, type: "PARAGRAPH", position: "Z", content: b.content, properties: b.properties, parentId: b.parentId, createdBy: userId } }); } // T-170: restore the whiteboard canvas (validated before write — a // snapshot from a newer/older schema must not corrupt the board). if (snap.whiteboard) { const parsed = validateCanvas(snap.whiteboard.canvas); if (parsed.ok) { await tx.whiteboard.upsert({ where: { pageId }, create: { pageId, canvas: parsed.canvas as object, schemaVersion: parsed.canvas.version, updatedBy: userId }, update: { canvas: parsed.canvas as object, schemaVersion: parsed.canvas.version, updatedBy: userId } }); } } }); await this.audit.audit(req, req?.auth, { action: "page.version_restored", resourceType: "page_version", resourceId: versionId, workspaceId: page.workspaceId, pageId }); await this.events.publish(EVENTS.PAGE_UPDATED, { workspaceId: page.workspaceId, pageId, restoredVersion: version.version }); // T-157: watched-page fan-out. try { await this.notifications.notifyWatchersForPageUpdate(pageId, userId); } catch { /* best-effort */ } return { restored: true, version: version.version }; } // ---------- T-157: watchlist ---------- /** Watch a page (idempotent). VIEW is required (you can only watch what * you can open). */ async watch(pageId: string, userId: string) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); await this.prisma.pageWatch.upsert({ where: { pageId_userId: { pageId, userId } }, update: {}, create: { pageId, userId } }); return { watching: true }; } async unwatch(pageId: string, userId: string) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); await this.prisma.pageWatch.deleteMany({ where: { pageId, userId } }); return { watching: false }; } async isWatching(pageId: string, userId: string) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); const row = await this.prisma.pageWatch.findUnique({ where: { pageId_userId: { pageId, userId } } }); return { watching: Boolean(row) }; } // ---------- favorites ---------- async favorite(pageId: string, userId: string) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); await this.prisma.pageFavorite.upsert({ where: { workspaceId_userId_pageId: { workspaceId: page.workspaceId, userId, pageId } }, update: {}, create: { workspaceId: page.workspaceId, userId, pageId } }); return { favorited: true }; } async unfavorite(pageId: string, userId: string) { const page = await this.getPage(pageId); await this.prisma.pageFavorite.deleteMany({ where: { workspaceId: page.workspaceId, userId, pageId } }); return { favorited: false }; } async isFavorite(pageId: string, userId: string) { const page = await this.getPage(pageId); const row = await this.prisma.pageFavorite.findUnique({ where: { workspaceId_userId_pageId: { workspaceId: page.workspaceId, userId, pageId } } }); return { favorite: Boolean(row) }; } // ---------- templates ---------- /** Snapshot a page subtree into a template (T-141: +category/description/variables). */ async createTemplate(pageId: string, userId: string, input: { name: string; icon?: string; category?: string; description?: string; variables?: unknown }, req: any) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); const subtree = await this.captureSubtree(pageId); const vars = input.variables !== undefined ? this.requireValidTemplateVars(input.variables) : undefined; const template = await this.prisma.template.create({ data: { workspaceId: page.workspaceId, name: input.name, icon: input.icon ?? page.icon, type: "page", sourceRef: { type: "page", id: pageId } as object, snapshot: subtree as object, category: input.category, description: input.description, variables: vars ? (vars as object) : undefined, createdBy: userId } }); // T-145: the first version snapshot is captured at creation. await this.prisma.templateVersion.create({ data: { templateId: template.id, version: 1, snapshot: subtree as object, name: input.name, description: input.description, variables: vars ? (vars as object) : undefined, createdBy: userId } }); await this.audit.audit(req, req?.auth, { action: "template.created", resourceType: "template", resourceId: template.id, workspaceId: page.workspaceId, metadata: { sourcePageId: pageId, name: input.name, category: input.category ?? null } }); return template; } /** T-141: directory listing with category/search (additive filters). */ async listTemplates(workspaceId: string, userId: string, type?: string, opts?: { category?: string; search?: string }) { const memberships = await this.prisma.workspaceMember.findMany({ where: { userId }, select: { workspaceId: true } }); const ids = [...new Set([workspaceId, ...memberships.map((m) => m.workspaceId)])]; const templates = await this.prisma.template.findMany({ where: { workspaceId: { in: ids }, ...(type ? { type } : {}), ...(opts?.category ? { category: opts.category } : {}), ...(opts?.search ? { OR: [{ name: { contains: opts.search, mode: "insensitive" } }, { description: { contains: opts.search, mode: "insensitive" } }] } : {}) }, select: { id: true, name: true, icon: true, type: true, isBuiltin: true, sourceRef: true, category: true, description: true, variables: true, defaultFor: true, createdAt: true }, orderBy: { createdAt: "asc" } }); return { data: templates }; } // ---------- T-143/T-145: built-in guard + version history ---------- /** Built-in templates are seeded, not user-editable (A-005): the only write * path that touches them is admin versioning; edits/deletes 403. */ private async requireNotBuiltin(templateId: string, workspaceId: string, userId: string) { const t = await this.prisma.template.findFirst({ where: { id: templateId, workspaceId }, select: { id: true, isBuiltin: true } }); if (!t) throw ApiError.notFound("Template not found."); if (t.isBuiltin) { const level = await assertCan(this.prisma, { workspaceId, userId }, "EDIT"); if (level !== "FULL_ACCESS") { throw ApiError.forbidden("Built-in templates cannot be modified.", [{ reason: "builtin_template" }]); } } return t; } private requireValidTemplateVars(vars: unknown) { const r = validateTemplateVars(vars); if (!r.ok) throw ApiError.validation("Invalid template variables.", r.errors.map((issue) => ({ field: "variables", issue }))); return r.vars; } /** T-145: snapshot the template's current state as a new version. */ async createTemplateVersion(templateId: string, workspaceId: string, userId: string, note?: string, req?: any) { await this.requireNotBuiltin(templateId, workspaceId, userId); const template = await this.prisma.template.findFirst({ where: { id: templateId, workspaceId } }); if (!template) throw ApiError.notFound("Template not found."); const last = await this.prisma.templateVersion.findFirst({ where: { templateId }, orderBy: { version: "desc" }, select: { version: true } }); const version = (last?.version ?? 0) + 1; const v = await this.prisma.templateVersion.create({ data: { templateId, version, snapshot: template.snapshot as object, name: template.name, description: template.description, variables: template.variables as object, note, createdBy: userId } }); if (req) { await this.audit.audit(req, req?.auth, { action: "template.versioned", resourceType: "template", resourceId: templateId, workspaceId, metadata: { version } }); } return v; } async listTemplateVersions(templateId: string, workspaceId: string, userId: string) { const t = await this.prisma.template.findFirst({ where: { id: templateId, workspaceId }, select: { id: true } }); if (!t) throw ApiError.notFound("Template not found."); await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); const versions = await this.prisma.templateVersion.findMany({ where: { templateId }, select: { id: true, version: true, note: true, createdBy: true, createdAt: true }, orderBy: { version: "desc" } }); return { data: versions }; } /** * T-141 (read-only preview): return the template's CURRENT snapshot * (the frozen `{ page, blocks, children }` captured at save time). * * ID-scoped (no workspace header) on purpose: `listTemplates` is a * CROSS-WORKSPACE union and its rows carry no `workspaceId`, so a * workspace-scoped lookup 404s for any union row. Authorization is * membership in the template's OWN workspace (any membership ≥ VIEW — * the lowest level); a non-member gets NOT_FOUND (no cross-tenant * existence leak — same shape as the bulk-actions external-id rule). */ async previewTemplate(templateId: string, userId: string) { const t = await this.prisma.template.findFirst({ where: { id: templateId }, select: { id: true, workspaceId: true, snapshot: true } }); if (!t) throw ApiError.notFound("Template not found."); const member = await this.prisma.workspaceMember.findFirst({ where: { workspaceId: t.workspaceId, userId }, select: { id: true } }); if (!member) throw ApiError.notFound("Template not found."); return { data: { snapshot: t.snapshot } }; } /** T-145: restore a version — writes it back as the CURRENT state AND * records a new version (history is never overwritten). */ async restoreTemplateVersion(templateId: string, versionId: string, workspaceId: string, userId: string, req: any) { await this.requireNotBuiltin(templateId, workspaceId, userId); const template = await this.prisma.template.findFirst({ where: { id: templateId, workspaceId } }); if (!template) throw ApiError.notFound("Template not found."); const v = await this.prisma.templateVersion.findFirst({ where: { id: versionId, templateId } }); if (!v) throw ApiError.notFound("Template version not found."); await this.prisma.template.update({ where: { id: templateId }, data: { snapshot: v.snapshot as object, name: v.name ?? template.name, description: v.description ?? template.description, variables: (v.variables ?? template.variables) as object } }); const newVersion = await this.createTemplateVersion(templateId, workspaceId, userId, `restored from v${v.version}`, req); return { restoredFromVersion: v.version, newVersion: newVersion.version }; } /** Instantiate a template: deep-copy the subtree under parentId (T-142: optional `values` for variable templates). */ async applyTemplate(workspaceId: string, userId: string, templateId: string, opts: { parentId?: string; title?: string; values?: Record }, req: any) { const template = await this.prisma.template.findFirst({ where: { id: templateId, workspaceId } }); if (!template) throw ApiError.notFound("Template not found."); await assertCan(this.prisma, { workspaceId, userId }, "EDIT"); // Loose block type: `substituteSnapshot` (T-142) re-shapes the snapshot // and the block fields are only read loosely below (content/position). type SnapBlock = { type: string; content: object; properties: object; parentId: string | null; position: string } & Record; type SubtreeSnapshot = { page: { title: string; icon: string | null; description: string | null }; blocks: SnapBlock[]; children: { title: string; blocks: SnapBlock[] }[]; }; let snapshot = template.snapshot as SubtreeSnapshot; // T-142: template variables — resolve + substitute before instantiating. // (The snapshot is always a plain object, so re-assigning is safe.) if (template.variables) { const defs = this.requireValidTemplateVars(template.variables); const resolved = resolveTemplateValues(defs, opts.values ?? {}); if (!resolved.ok) { throw ApiError.validation("Invalid template variable values.", resolved.errors.map((issue) => ({ field: "values", issue }))); } // substituteSnapshot re-shapes the same snapshot (tokens → values); // the cast is safe — it preserves every block field we read below. snapshot = substituteSnapshot(template.snapshot, resolved.values) as SubtreeSnapshot; } const rootTitle = opts.title ?? snapshot.page.title; const parentId = opts.parentId ?? null; if (parentId) { const parent = await this.assertInWorkspace(parentId, workspaceId); void parent; } const created = await this.prisma.$transaction(async (tx) => { const root = await tx.page.create({ data: { workspaceId, parentId, position: await this.appendPagePosition(tx, workspaceId, parentId), title: rootTitle, icon: snapshot.page.icon, description: snapshot.page.description, createdBy: userId } }); const blockMap = new Map(); for (const b of snapshot.blocks) { const block = await tx.block.create({ data: { pageId: root.id, workspaceId, parentId: b.parentId ? (blockMap.get(b.parentId) ?? null) : null, type: b.type as never, position: b.position || "A", content: b.content, properties: b.properties, createdBy: userId } }); blockMap.set(b.parentId ?? "root", block.id); } for (const child of snapshot.children) { const childPage = await tx.page.create({ data: { workspaceId, parentId: root.id, position: between(undefined), title: child.title, createdBy: userId } }); for (const b of child.blocks) { await tx.block.create({ data: { pageId: childPage.id, workspaceId, parentId: null, type: b.type as never, position: b.position || "A", content: b.content, properties: b.properties, createdBy: userId } }); } } return root; }); await this.audit.audit(req, req?.auth, { action: "template.applied", resourceType: "template", resourceId: templateId, workspaceId, metadata: { newPageId: created.id } }); await this.events.publish(EVENTS.PAGE_CREATED, { workspaceId, pageId: created.id, title: rootTitle, fromTemplate: templateId }); return created; } private async captureSubtree(pageId: string) { const page = await this.prisma.page.findUniqueOrThrow({ where: { id: pageId } }); const blocks = await this.prisma.block.findMany({ where: { pageId, deletedAt: null }, select: { id: true, type: true, content: true, properties: true, parentId: true, position: true } }); const children = await this.prisma.page.findMany({ where: { parentId: pageId, deletedAt: null }, select: { id: true, title: true } }); const childSnapshots: { title: string; blocks: typeof blocks }[] = []; for (const c of children) { const cb = await this.prisma.block.findMany({ where: { pageId: c.id, deletedAt: null }, select: { id: true, type: true, content: true, properties: true, parentId: true, position: true } }); childSnapshots.push({ title: c.title, blocks: cb }); } return { page: { title: page.title, icon: page.icon, description: page.description }, blocks, children: childSnapshots }; } // ---------- T-180: content export (Phase 8) ---------- /** * `GET /pages/:id/export/markdown?includeChildren=1` — render a page (and * its sub-pages, opt-in) to Markdown for download. * * Authorization: **VIEW** on the page (the same level `get` uses) — a * member of the workspace (or a page-level grant holder) can export exactly * what they can read; a restricted page (explicit NONE page grant) 403s. * This is a SENSITIVE operation (content leaves the system), so per the * audit rules (spec §16) an audit event is written on success — * `page.exported` with the byte count + whether children were included. */ async exportMarkdown(pageId: string, userId: string, opts: { includeChildren?: boolean }, req: any) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); const subtree = await this.captureSubtree(pageId); const markdown = renderPageMarkdown({ title: page.title, icon: page.icon, description: page.description, blocks: subtree.blocks as ExportInput["blocks"], children: opts.includeChildren ? (subtree.children as unknown as ExportInput["children"]) : undefined, includeChildren: opts.includeChildren }); await this.audit.audit(req, req?.auth, { action: "page.exported", resourceType: "page", resourceId: page.id, workspaceId: page.workspaceId, pageId: page.id, metadata: { format: "markdown", includeChildren: opts.includeChildren === true, bytes: Buffer.byteLength(markdown, "utf8") } }); return { markdown, filename: exportFilename(page.title), title: page.title }; } // ---------- T-138: duplicate ---------- /** * Recursive duplicate of a page subtree. New page/block ids, positions * re-based into the target sibling list. Database pages are duplicated by * snapshot: the Database row + properties + views are copied (records are * NOT copied — a copy is a fresh database; live relation links are * severed by design, matching the documented behavior). Comments, * shares, favorites, and versions are intentionally NOT copied. */ async duplicate(pageId: string, userId: string, input: { parentId?: string; title?: string }, req: any) { const src = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: src.workspaceId, userId, resource: { pageId: src.id } }, "EDIT"); const parentId = input.parentId ?? src.parentId; if (parentId) { const parent = await this.assertInWorkspace(parentId, src.workspaceId); if (parent.trashedAt) throw ApiError.validation("Cannot duplicate under a trashed page.", [{ field: "parentId", issue: "parent_trashed" }]); } const wsId = src.workspaceId; const created = await this.prisma.$transaction(async (tx) => { const copyPage = async ( page: { id: string; title: string; icon: string | null; description: string | null; kind: string }, underParentId: string | null ): Promise => { const position = await this.appendPagePosition(tx, wsId, underParentId); const newPage = await tx.page.create({ data: { workspaceId: wsId, parentId: underParentId, position, title: page.title, icon: page.icon, description: page.description, kind: page.kind, createdBy: userId, updatedBy: userId } }); // Copy the block tree. Fractional positions order SIBLINGS only, so a // flat `position` sort does NOT guarantee parent-before-child — // process in passes until every parent is mapped (the tree is acyclic // by construction, so this terminates). const blocks = await tx.block.findMany({ where: { pageId: page.id, deletedAt: null }, orderBy: { position: "asc" } }); const blockMap = new Map(); const blockById = new Map(blocks.map((b) => [b.id, b])); const remaining = new Set(blocks.map((b) => b.id)); let progress = true; while (progress && remaining.size > 0) { progress = false; for (const id of [...remaining]) { const b = blockById.get(id)!; if (b.parentId && !blockMap.has(b.parentId)) continue; remaining.delete(id); progress = true; const newBlock = await tx.block.create({ data: { pageId: newPage.id, workspaceId: wsId, parentId: b.parentId ? (blockMap.get(b.parentId) ?? null) : null, type: b.type, position: b.position, content: b.content as object, properties: b.properties as object, createdBy: userId } }); blockMap.set(b.id, newBlock.id); } } // `remaining` is only non-empty if the source tree was corrupt (cycle); // the live source cannot be cyclic (move guard), so this is a no-op. void remaining; // Database snapshot: copy the Database row + properties + views. // (pageId is a plain ref, not unique — findFirst, mirroring page.service get()) const db = await tx.database.findFirst({ where: { pageId: page.id } }); if (db) { const newDb = await tx.database.create({ data: { workspaceId: wsId, pageId: newPage.id, title: db.title, isArchived: false, createdBy: userId } }); const props = await tx.databaseProperty.findMany({ where: { databaseId: db.id }, orderBy: { position: "asc" } }); for (const p of props) { await tx.databaseProperty.create({ data: { databaseId: newDb.id, name: p.name, type: p.type, isTitle: p.isTitle, config: p.config as object, position: p.position } }); } const views = await tx.databaseView.findMany({ where: { databaseId: db.id }, orderBy: { position: "asc" } }); for (const v of views) { await tx.databaseView.create({ data: { databaseId: newDb.id, name: v.name, type: v.type, position: v.position, config: v.config as object } }); } } const children = await tx.page.findMany({ where: { parentId: page.id, deletedAt: null, trashedAt: null }, orderBy: { position: "asc" }, select: { id: true, title: true, icon: true, description: true, kind: true } }); for (const c of children) { await copyPage(c, newPage.id); } return newPage.id; }; return copyPage( { id: src.id, title: input.title ?? (src.title ? `${src.title} (copy)` : "Untitled (copy)"), icon: src.icon, description: src.description, kind: src.kind }, parentId ); }); await this.audit.audit(req, req?.auth, { action: "page.duplicated", resourceType: "page", resourceId: created, workspaceId: wsId, pageId: created, metadata: { sourcePageId: pageId, parentId } }); await this.events.publish(EVENTS.PAGE_CREATED, { workspaceId: wsId, pageId: created, title: src.title, duplicatedFrom: pageId }); return { id: created, sourcePageId: pageId }; } // ---------- T-149: cross-workspace move ---------- /** * Move a page (with its subtree) to another workspace. Requires EDIT on * the SOURCE page AND EDIT in the DESTINATION workspace (create rights) — * both checked server-side (SECURITY_REVIEW §2). Positions are re-based * into the destination sibling list; the search index is refreshed via the * domain event (the indexer re-stamps workspace + grant snapshot). */ async moveCrossWorkspace(pageId: string, userId: string, input: { toWorkspaceId: string; toParentId?: string }, req: any) { const src = await this.getPage(pageId); if (src.trashedAt || src.deletedAt) throw ApiError.validation("Cannot move a trashed page.", [{ issue: "trashed" }]); await assertCan(this.prisma, { workspaceId: src.workspaceId, userId, resource: { pageId: src.id } }, "EDIT"); const toWorkspaceId = input.toWorkspaceId; if (toWorkspaceId === src.workspaceId) { throw ApiError.validation("Page is already in that workspace.", [{ field: "toWorkspaceId", issue: "same_workspace" }]); } const destWs = await this.prisma.workspace.findUnique({ where: { id: toWorkspaceId } }); if (!destWs || destWs.deletedAt) throw ApiError.notFound("Destination workspace not found."); await assertCan(this.prisma, { workspaceId: toWorkspaceId, userId }, "EDIT"); const toParentId = input.toParentId ?? null; if (toParentId) { const parent = await this.prisma.page.findUnique({ where: { id: toParentId } }); if (!parent || parent.workspaceId !== toWorkspaceId) throw ApiError.notFound("Destination parent page not found."); if (parent.trashedAt || parent.deletedAt) throw ApiError.validation("Cannot move under a trashed page.", [{ field: "toParentId", issue: "parent_trashed" }]); } const subtree = [pageId, ...(await this.descendantPageIds(pageId))]; const position = await this.appendPagePosition(this.prisma, toWorkspaceId, toParentId); await this.prisma.page.updateMany({ where: { id: { in: subtree } }, data: { workspaceId: toWorkspaceId, updatedBy: userId } }); // Re-base only the MOVED ROOT into the new sibling list; descendants keep // their relative positions (they are siblings among themselves, unchanged). await this.prisma.page.update({ where: { id: pageId }, data: { parentId: toParentId, position } }); // Blocks/records/files carry workspaceId too — re-stamp them so the // tenant scope stays consistent (the page cascade keeps them attached). await this.prisma.block.updateMany({ where: { pageId: { in: subtree } }, data: { workspaceId: toWorkspaceId } }); await this.prisma.databaseRecord.updateMany({ where: { pageId: { in: subtree } }, data: { workspaceId: toWorkspaceId } }); await this.prisma.file.updateMany({ where: { pageId: { in: subtree } }, data: { workspaceId: toWorkspaceId } }); await this.audit.audit(req, req?.auth, { action: "page.moved.workspace", resourceType: "page", resourceId: pageId, workspaceId: toWorkspaceId, pageId, metadata: { fromWorkspaceId: src.workspaceId, toWorkspaceId, toParentId, pageIds: subtree.length } }); await this.events.publish(EVENTS.PAGE_UPDATED, { workspaceId: toWorkspaceId, pageId, movedFromWorkspaceId: src.workspaceId, pageIds: subtree }); return { moved: true, pageId, fromWorkspaceId: src.workspaceId, toWorkspaceId, pageIds: subtree.length }; } // ---------- T-139: bulk actions ---------- /** * Bulk page actions with per-item permission checks and partial-failure * reporting (spec §4: "Bulk actions with confirmation and partial-failure * handling"). `ids` are all checked in one workspace membership query * context; each item that fails carries a stable code. */ async bulk( workspaceId: string, userId: string, input: { action: "trash" | "archive" | "unarchive" | "favorite" | "unfavorite"; ids: string[] }, req: any ) { // Deduplicate while preserving input order. const ids = [...new Set(input.ids)]; const pages = await this.prisma.page.findMany({ where: { workspaceId, id: { in: ids }, deletedAt: null }, select: { id: true, trashedAt: true, archivedAt: true } }); const byId = new Map(pages.map((p) => [p.id, p])); const succeeded: string[] = []; const failed: { id: string; code: string }[] = []; for (const id of ids) { const page = byId.get(id); if (!page) { failed.push({ id, code: "NOT_FOUND" }); continue; } try { switch (input.action) { case "trash": // Reuse the single-page path (permission + recursion + audit). await this.trash(id, userId, req); break; case "archive": await assertCan(this.prisma, { workspaceId, userId, resource: { pageId: id } }, "EDIT"); await this.prisma.page.update({ where: { id }, data: { archivedAt: new Date(), updatedBy: userId } }); break; case "unarchive": await assertCan(this.prisma, { workspaceId, userId, resource: { pageId: id } }, "EDIT"); await this.prisma.page.update({ where: { id }, data: { archivedAt: null, updatedBy: userId } }); break; case "favorite": await this.favorite(id, userId); break; case "unfavorite": await this.unfavorite(id, userId); break; } succeeded.push(id); } catch (err) { const code = err instanceof ApiError ? (err.httpStatus === 403 ? "FORBIDDEN" : err.code) : "ERROR"; failed.push({ id, code }); } } await this.audit.audit(req, req?.auth, { action: "page.bulk", resourceType: "page", resourceId: ids[0] ?? workspaceId, workspaceId, metadata: { action: input.action, total: ids.length, succeeded: succeeded.length, failed: failed.length } }); return { action: input.action, succeeded, failed }; } // ---------- T-140: backlinks ---------- /** * Backlinks for a page: the pages that reference it — via `child_page` * blocks, `synced_block` (source is the referenced page's root block is * out of scope; we index pages whose block embeds this page), * `linked_database` (this page's database), and comment `pageMentionIds`. * Every source is filtered by the requester's VIEW level (fail-closed: * a source the requester cannot see is excluded — no existence leak). */ async backlinks(pageId: string, userId: string) { const page = await this.getPage(pageId); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW"); const wsId = page.workspaceId; const refs = new Set(); // 1. child_page blocks embedding this page. const childPageBlocks = await this.prisma.block.findMany({ where: { workspaceId: wsId, deletedAt: null, type: "CHILD_PAGE", content: { path: ["pageId"], equals: pageId } }, select: { pageId: true } }); for (const b of childPageBlocks) refs.add(b.pageId); // 2. linked_database blocks pointing at this page's database. const db = await this.prisma.database.findFirst({ where: { pageId }, select: { id: true } }); if (db) { const linkedDbBlocks = await this.prisma.block.findMany({ where: { workspaceId: wsId, deletedAt: null, type: "LINKED_DATABASE", content: { path: ["databaseId"], equals: db.id } }, select: { pageId: true } }); for (const b of linkedDbBlocks) refs.add(b.pageId); } // 3. Comments mentioning this page (pageMentionIds is a plain string[]). const comments = await this.prisma.comment.findMany({ where: { workspaceId: wsId, pageMentionIds: { has: pageId } }, select: { pageId: true } }); for (const c of comments) if (c.pageId) refs.add(c.pageId); refs.delete(pageId); // a page is not its own backlink // 4. Resolve + permission-filter the sources (fail-closed). if (refs.size === 0) return { data: [] as Array<{ pageId: string; title: string; via: string[] }> }; const sources = await this.prisma.page.findMany({ where: { id: { in: [...refs] }, deletedAt: null }, select: { id: true, title: true, icon: true, kind: true } }); const data: Array<{ pageId: string; title: string; icon: string | null; kind: string }> = []; for (const s of sources) { const level = await actorLevel(this.prisma, { workspaceId: wsId, userId, resource: { pageId: s.id } }); if (level === "NONE") continue; data.push({ pageId: s.id, title: s.title, icon: s.icon, kind: s.kind }); } data.sort((a, b) => (a.title || "").localeCompare(b.title || "")); return { data }; } }