1import { Inject, Injectable } from "@nestjs/common";
2import { Prisma } from "@prisma/client";
3import { PrismaService } from "../../common/prisma.service.js";
4import { AuditService } from "../../common/audit.service.js";
5import { EventBus } from "../../common/event-bus.js";
6import { NotificationsService } from "../notifications/notifications.service.js";
7import { ApiError } from "../../common/errors.js";
8import { assertCan } from "../../lib/authorize.js";
9import { between, rebalancePositions } from "../../lib/positions.js";
10import { parsePagination } from "../../lib/pagination.js";
11import { EVENTS, DEFAULT_DB_PROPERTIES } from "../../constants.js";
12import { actorLevel } from "../../lib/authorize.js";
13import {
14 diffSnapshots,
15 resolveTemplateValues,
16 substituteSnapshot,
17 validateTemplateVars,
18 type PageSnapshot as PageSnapshotInput
19} from "@factory/shared";
20import { CANVAS_VERSION, emptyCanvas, validateCanvas } from "@factory/whiteboard";
21import { renderPageMarkdown, exportFilename, type ExportInput } from "./page-export.js";
22
23const PAGE_SELECT = {
24 id: true, workspaceId: true, teamId: true, parentId: true, position: true,
25 title: true, icon: true, coverUrl: true, description: true, isPrivate: true,
26 isFavorite: true, archivedAt: true, trashedAt: true, restoredAt: true,
27 createdBy: true, version: true, createdAt: true, updatedAt: true,
28
29
30 kind: true, reviewState: true,
31
32 deletedAt: true
33} as const;
34type PageRow = Prisma.PageGetPayload<{ select: typeof PAGE_SELECT }>;
35
36
42@Injectable()
43export class PageService {
44 constructor(
45 @Inject(PrismaService) private readonly prisma: PrismaService,
46 @Inject(AuditService) private readonly audit: AuditService,
47 @Inject(EventBus) private readonly events: EventBus,
48 @Inject(NotificationsService) private readonly notifications: NotificationsService
49 ) {}
50
51
52
53 private async getPage(pageId: string): Promise<PageRow> {
54 const page = await this.prisma.page.findUnique({ where: { id: pageId }, select: PAGE_SELECT });
55 if (!page) throw ApiError.notFound();
56 return page;
57 }
58
59 private async assertInWorkspace(pageId: string, workspaceId: string): Promise<PageRow> {
60 const page = await this.getPage(pageId);
61 if (page.workspaceId !== workspaceId) throw ApiError.notFound();
62 return page;
63 }
64
65
74 private async rebalancePageSiblings(tx: Prisma.TransactionClient, workspaceId: string, parentId: string | null): Promise<void> {
75 const rows = await tx.page.findMany({
76 where: { workspaceId, parentId, deletedAt: null, trashedAt: null },
77 orderBy: { position: "asc" },
78 select: { id: true, position: true }
79 });
80 const fresh = rebalancePositions(rows.map((r) => r.position));
81 for (let i = 0; i < rows.length; i++) {
82 if (rows[i]!.position !== fresh[i]) {
83 await tx.page.update({ where: { id: rows[i]!.id }, data: { position: fresh[i] } });
84 }
85 }
86 }
87
88
89 private async appendPagePosition(tx: Prisma.TransactionClient, workspaceId: string, parentId: string | null): Promise<string> {
90 const last = await tx.page.findFirst({
91 where: { workspaceId, parentId, deletedAt: null, trashedAt: null },
92 orderBy: { position: "desc" },
93 select: { position: true }
94 });
95 try {
96 return between(last?.position);
97 } catch (err) {
98 if (!(err instanceof RangeError)) throw err;
99 await this.rebalancePageSiblings(tx, workspaceId, parentId);
100 return this.appendPagePosition(tx, workspaceId, parentId);
101 }
102 }
103
104
105
106 async list(
107 workspaceId: string,
108 userId: string,
109 query: { parentId?: string | null; cursor?: string; limit?: string; trashed?: boolean; favorites?: boolean }
110 ) {
111 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
112 const { cursor, limit } = parsePagination(query);
113 const parentId = query.parentId ?? null;
114
115 if (query.trashed && query.favorites) {
116 throw new ApiError(
117 "VALIDATION_ERROR",
118 400,
119 "trashed and favorites are mutually exclusive.",
120 [{ field: "trashed", issue: "mutually exclusive with favorites" }]
121 );
122 }
123
124
125
126
127
128
129 if (query.favorites) {
130 const or: object[] = [];
131 if (cursor) {
132 or.push({ position: { gt: cursor.position } }, { position: cursor.position, pageId: { gt: cursor.id } });
133 }
134 const favs = await this.prisma.pageFavorite.findMany({
135 where: { workspaceId, userId, ...(or.length ? { OR: or } : {}) },
136 orderBy: [{ position: "asc" }, { pageId: "asc" }],
137 take: limit + 1
138 });
139 const keptFavs = favs.slice(0, limit);
140 const rows = await this.prisma.page.findMany({
141 where: { id: { in: keptFavs.map((f) => f.pageId) }, deletedAt: null },
142 select: { ...PAGE_SELECT, _count: { select: { children: true, blocks: true } } }
143 });
144 const byId = new Map(rows.map((r) => [r.id, r]));
145 const dbIds = await this.databaseIdByPage(keptFavs.map((f) => ({ id: f.pageId })));
146 const data = keptFavs
147 .map((f) => {
148 const r = byId.get(f.pageId);
149 return r
150 ? { ...r, favorite: true, databaseId: dbIds.get(f.pageId) ?? null, childrenCount: r._count.children }
151 : null;
152 })
153 .filter((r): r is NonNullable<typeof r> => r !== null);
154 const next = keptFavs[keptFavs.length - 1];
155 return {
156 data,
157 nextCursor:
158 favs.length > limit && next
159 ? Buffer.from(JSON.stringify({ id: next.pageId, position: next.position })).toString("base64url")
160 : null
161 };
162 }
163
164
165
166
167
168 const isTrash = query.trashed === true;
169 if (isTrash) {
170 const role = await this.prisma.workspaceMember.findUnique({
171 where: { workspaceId_userId: { workspaceId, userId } },
172 select: { role: true }
173 });
174 if (!role || (role.role !== "OWNER" && role.role !== "ADMIN")) {
175 throw new ApiError("FORBIDDEN", 403, "Only workspace Admins/Owners can view the trash.", [
176 { reason: "trash_requires_admin" }
177 ]);
178 }
179 }
180
181 if (isTrash) {
182
183
184
185
186
187
188
189 const or: object[] = [
190 { parentId: null },
191 { parent: { trashedAt: null } }
192 ];
193 if (cursor) {
194 or.push({ position: { gt: cursor.position } }, { position: cursor.position, id: { gt: cursor.id } });
195 }
196 const rows = await this.prisma.page.findMany({
197 where: { workspaceId, deletedAt: null, trashedAt: { not: null }, OR: or },
198 select: { ...PAGE_SELECT, _count: { select: { children: true, blocks: true } } },
199 orderBy: [{ position: "asc" }, { id: "asc" }],
200 take: limit + 1
201 });
202 const page_ = rows.slice(0, limit);
203 const dbIds = await this.databaseIdByPage(page_);
204 const next = rows.length > limit ? page_[page_.length - 1] : undefined;
205 return {
206 data: page_.map((p) => ({
207 ...p,
208 favorite: false,
209 databaseId: dbIds.get(p.id) ?? null,
210 childrenCount: p._count.children
211 })),
212 nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id, position: next.position })).toString("base64url") : null
213 };
214 }
215
216 const rows = await this.prisma.page.findMany({
217 where: {
218 workspaceId,
219 deletedAt: null,
220 trashedAt: null,
221 parentId,
222 ...(cursor ? { position: { gt: cursor.position } } : {})
223 },
224 select: { ...PAGE_SELECT, _count: { select: { children: true, blocks: true } } },
225 orderBy: { position: "asc" },
226 take: limit + 1
227 });
228 const page_ = rows.slice(0, limit);
229 const dbIds = await this.databaseIdByPage(page_);
230 const next = rows.length > limit ? page_[page_.length - 1] : undefined;
231 return {
232 data: page_.map((p) => ({
233 ...p,
234 favorite: p.isFavorite,
235 databaseId: dbIds.get(p.id) ?? null,
236 childrenCount: p._count.children
237 })),
238 nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id, position: next.position })).toString("base64url") : null
239 };
240 }
241
242 async create(workspaceId: string, userId: string, input: { title?: string; parentId?: string; type?: "page" | "database" | "whiteboard"; teamId?: string; templateId?: string }, req: any) {
243 await assertCan(this.prisma, { workspaceId, userId }, "EDIT");
244 const title = input.title ?? "";
245
246 if (input.templateId) {
247 return this.applyTemplate(workspaceId, userId, input.templateId, { parentId: input.parentId, title }, req);
248 }
249
250 const parentId = input.parentId ?? null;
251 let grandparentId: string | null = null;
252 if (parentId) {
253 const parent = await this.assertInWorkspace(parentId, workspaceId);
254 if (parent.trashedAt) throw ApiError.validation("Cannot create under a trashed page.", [{ field: "parentId", issue: "parent_trashed" }]);
255
256
257 grandparentId = parent.parentId;
258 }
259
260 const position = await this.appendPagePosition(this.prisma, workspaceId, parentId);
261
262 const teamId = input.teamId ?? (parentId ? (await this.getPage(parentId)).teamId ?? null : null);
263
264
265
266
267 const isWhiteboard = input.type === "whiteboard";
268
269 const page = await this.prisma.page.create({
270 data: {
271 workspaceId,
272 teamId,
273 parentId,
274 grandparentId,
275 position,
276 title,
277 ...(isWhiteboard ? { kind: "whiteboard" as const } : {}),
278 createdBy: userId,
279 updatedBy: userId
280 },
281 select: PAGE_SELECT
282 });
283
284
285 const isDb = input.type === "database";
286 const titleBlockId = `blk_${Math.random().toString(36).slice(2, 14)}`;
287 const blockIds = [titleBlockId];
288 if (isDb) blockIds.push(`blk_${Math.random().toString(36).slice(2, 14)}`);
289
290 let databaseId: string | undefined;
291 let dbPropertyCount = 0;
292 await this.prisma.$transaction(async (tx) => {
293 await tx.block.create({
294 data: {
295 id: titleBlockId,
296 pageId: page.id,
297 workspaceId,
298 parentId: null,
299 type: "PARAGRAPH",
300 position: "A",
301 content: { text: title },
302 properties: { isTitle: true },
303 createdBy: userId
304 }
305 });
306 if (isDb) {
307 const db = await tx.database.create({
308 data: {
309 workspaceId,
310 pageId: page.id,
311 title,
312 createdBy: userId
313 }
314 });
315 databaseId = db.id;
316 dbPropertyCount = await this.seedDefaultDatabase(tx, db.id);
317 await tx.block.create({
318 data: {
319 id: blockIds[1]!,
320 pageId: page.id,
321 workspaceId,
322 parentId: null,
323 type: "DATABASE",
324 position: "B",
325 content: { databaseId: db.id },
326 createdBy: userId
327 }
328 });
329 }
330 if (isWhiteboard) {
331
332 await tx.whiteboard.create({
333 data: {
334 pageId: page.id,
335 canvas: emptyCanvas() as unknown as Prisma.InputJsonValue,
336 schemaVersion: CANVAS_VERSION,
337 updatedBy: userId
338 }
339 });
340 }
341 });
342 if (databaseId) {
343
344
345
346 await this.audit.audit(req, req?.auth, {
347 action: EVENTS.DATABASE_CREATED,
348 resourceType: "database",
349 resourceId: databaseId,
350 workspaceId,
351 metadata: { title, pageId: page.id }
352 });
353 await this.events.publish(EVENTS.DATABASE_CREATED, {
354 workspaceId,
355 databaseId,
356 pageId: page.id,
357 propertyCount: dbPropertyCount
358 });
359 }
360
361 await this.audit.audit(req, req?.auth, {
362 action: EVENTS.PAGE_CREATED,
363 resourceType: "page",
364 resourceId: page.id,
365 workspaceId,
366 pageId: page.id,
367 metadata: { title, parentId, type: input.type ?? "page" }
368 });
369 await this.events.publish(EVENTS.PAGE_CREATED, { workspaceId, pageId: page.id, title, parentId, databaseId });
370 return { ...page, databaseId, blockIds };
371 }
372
373
377 private async databaseIdByPage(
378 pages: { id: string }[]
379 ): Promise<Map<string, string>> {
380 if (pages.length === 0) return new Map();
381 const rows = await this.prisma.database.findMany({
382 where: { pageId: { in: pages.map((p) => p.id) } },
383 select: { id: true, pageId: true }
384 });
385 const map = new Map<string, string>();
386 for (const r of rows) if (r.pageId) map.set(r.pageId, r.id);
387 return map;
388 }
389
390
391 private async seedDefaultDatabase(
392 tx: Prisma.TransactionClient,
393 databaseId: string
394 ): Promise<number> {
395 let position: string | undefined;
396 for (const seed of DEFAULT_DB_PROPERTIES) {
397 await tx.databaseProperty.create({
398 data: {
399 databaseId,
400 name: seed.name,
401 type: seed.type as never,
402 isTitle: "isTitle" in seed ? Boolean(seed.isTitle) : false,
403 config: ("config" in seed ? seed.config : {}) as object,
404 position: (position = between(position))!
405 }
406 });
407 }
408 await tx.databaseView.create({
409 data: { databaseId, name: "Table View", type: "TABLE", position: "A" }
410 });
411 return DEFAULT_DB_PROPERTIES.length;
412 }
413
414
415
416 async get(pageId: string, userId: string) {
417 const page = await this.getPage(pageId);
418 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
419 const [workspace, childrenCount, blocksCount, database] = await Promise.all([
420 this.prisma.workspace.findUnique({
421 where: { id: page.workspaceId },
422 select: { id: true, name: true, slug: true }
423 }),
424 this.prisma.page.count({ where: { parentId: page.id, deletedAt: null, trashedAt: null } }),
425 this.prisma.block.count({ where: { pageId: page.id, deletedAt: null, parentId: null } }),
426 this.prisma.database.findFirst({
427 where: { pageId: page.id },
428 select: { id: true, title: true, isArchived: true }
429 })
430 ]);
431 return { page, workspace, childrenCount, topLevelBlocks: blocksCount, database };
432 }
433
434 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) {
435 const page = await this.getPage(pageId);
436 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT");
437
438
439
440 const data: Prisma.PageUncheckedUpdateInput = { updatedBy: userId, version: { increment: 1 } };
441 if (input.title !== undefined) data.title = input.title;
442 if (input.icon !== undefined) data.icon = input.icon;
443 if (input.coverUrl !== undefined) data.coverUrl = input.coverUrl;
444 if (input.description !== undefined) data.description = input.description;
445 if (input.teamId !== undefined) data.teamId = input.teamId;
446 if (input.archived !== undefined) {
447 data.archivedAt = input.archived ? new Date() : null;
448 }
449 if (input.parentId !== undefined && input.parentId !== page.parentId) {
450 const newParentId = input.parentId;
451 let grandparentId: string | null = null;
452 if (newParentId === pageId) {
453 throw ApiError.validation("A page cannot be its own parent.", [{ field: "parentId", issue: "circular" }]);
454 }
455 if (newParentId) {
456 const parent = await this.prisma.page.findUnique({ where: { id: newParentId } });
457 if (!parent || parent.workspaceId !== page.workspaceId) throw ApiError.notFound("Parent page not found.");
458 if (parent.trashedAt || parent.deletedAt) throw ApiError.validation("Cannot move under a trashed page.", [{ field: "parentId", issue: "parent_trashed" }]);
459
460 grandparentId = parent.parentId;
461
462 let cursorId: string | null = newParentId;
463 const seen = new Set<string>();
464 while (cursorId) {
465 if (cursorId === pageId) throw ApiError.validation("Move would create a circular page tree.", [{ field: "parentId", issue: "circular" }]);
466 if (seen.has(cursorId)) break;
467 seen.add(cursorId);
468
469
470
471 const row: { parentId: string | null } | null = await this.prisma.page.findUnique({ where: { id: cursorId }, select: { parentId: true } });
472 cursorId = row?.parentId ?? null;
473 }
474 }
475 data.parentId = newParentId;
476 data.grandparentId = grandparentId;
477 data.position = await this.appendPagePosition(this.prisma, page.workspaceId, newParentId);
478 }
479
480 const updated = await this.prisma.page.update({ where: { id: pageId }, data, select: PAGE_SELECT });
481
482 if (input.title !== undefined) {
483 const titleBlock = await this.prisma.block.findFirst({
484 where: { pageId, parentId: null, properties: { path: ["isTitle"], equals: true } },
485 select: { id: true }
486 });
487 if (titleBlock) {
488 await this.prisma.block.update({ where: { id: titleBlock.id }, data: { content: { text: input.title } as object } });
489 }
490 }
491 await this.audit.audit(req, req?.auth, {
492 action: "page.updated",
493 resourceType: "page",
494 resourceId: pageId,
495 workspaceId: page.workspaceId,
496 pageId
497 });
498 await this.events.publish(EVENTS.PAGE_UPDATED, { workspaceId: page.workspaceId, pageId, fields: Object.keys(input) });
499
500
501
502 try { await this.notifications.notifyWatchersForPageUpdate(pageId, userId); } catch { }
503 return updated;
504 }
505
506
507
508 private async assertWhiteboardPage(pageId: string): Promise<PageRow> {
509 const page = await this.getPage(pageId);
510 if (page.kind !== "whiteboard") throw ApiError.notFound();
511 return page;
512 }
513
514
515 async getCanvas(pageId: string, userId: string) {
516 const page = await this.assertWhiteboardPage(pageId);
517 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
518 const wb = await this.prisma.whiteboard.findUnique({ where: { pageId } });
519 if (!wb) throw ApiError.notFound("Whiteboard not found.");
520 const parsed = validateCanvas(wb.canvas);
521 return {
522 pageId,
523 schemaVersion: wb.schemaVersion,
524 canvas: parsed.ok ? parsed.canvas : (wb.canvas as object),
525
526
527 valid: parsed.ok,
528 updatedAt: wb.updatedAt,
529 updatedBy: wb.updatedBy
530 };
531 }
532
533
534 async saveCanvas(pageId: string, userId: string, canvas: unknown, req: any) {
535 const page = await this.assertWhiteboardPage(pageId);
536 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT");
537 const parsed = validateCanvas(canvas);
538 if (!parsed.ok) {
539 throw ApiError.validation("Invalid canvas document.", parsed.errors.map((e) => ({ field: "canvas", issue: e })));
540 }
541 const wb = await this.prisma.whiteboard.upsert({
542 where: { pageId },
543 create: { pageId, canvas: parsed.canvas as object, schemaVersion: parsed.canvas.version, updatedBy: userId },
544 update: { canvas: parsed.canvas as object, schemaVersion: parsed.canvas.version, updatedBy: userId }
545 });
546
547 await this.prisma.page.update({ where: { id: pageId }, data: { updatedBy: userId, version: { increment: 1 } } });
548 await this.audit.audit(req, req?.auth, {
549 action: "page.whiteboard_updated",
550 resourceType: "page",
551 resourceId: pageId,
552 workspaceId: page.workspaceId,
553 pageId,
554 metadata: { elementCount: Object.keys(parsed.canvas.elements).length, schemaVersion: parsed.canvas.version }
555 });
556 await this.events.publish(EVENTS.PAGE_UPDATED, {
557 workspaceId: page.workspaceId,
558 pageId,
559 fields: ["whiteboard"]
560 });
561 return { pageId, schemaVersion: wb.schemaVersion, canvas: parsed.canvas, updatedAt: wb.updatedAt };
562 }
563
564
573 async convertSelection(pageId: string, userId: string, input: { elementIds: string[]; target: "page" | "task"; title?: string; parentId?: string | null }, req: any) {
574 const page = await this.getPage(pageId);
575 if (page.kind !== "whiteboard") throw ApiError.notFound();
576 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT");
577
578 const wb = await this.prisma.whiteboard.findUnique({ where: { pageId } });
579 const parsed = wb ? validateCanvas(wb.canvas) : { ok: true as const, canvas: { version: CANVAS_VERSION, elements: {} as Record<string, never> } };
580 if (!parsed.ok) throw ApiError.validation("Whiteboard canvas failed validation; save a valid document before converting.", parsed.errors.map((e) => ({ field: "canvas", issue: e })));
581
582 const known = parsed.canvas.elements;
583 const ordered = input.elementIds.map((id) => known[id]).filter((el): el is (typeof known)[string] => el !== undefined);
584
585
586 const lines = ordered
587 .filter((el) => el.type === "sticky" || el.type === "text")
588 .flatMap((el) => (el.text ?? "").split("\n"))
589 .map((t) => t.trim())
590 .filter((t) => t.length > 0);
591 if (lines.length === 0) {
592 throw ApiError.validation("No text content to convert — select sticky notes or text objects with text.", [
593 { field: "elementIds", issue: "no_text_content", received: input.elementIds.join(",") }
594 ]);
595 }
596
597 const workspaceId = page.workspaceId;
598 const title = (input.title ?? "").trim() || lines[0]!.split("\n")[0]!.slice(0, 200);
599 const parentId = input.parentId ?? null;
600 if (parentId) {
601 const parent = await this.assertInWorkspace(parentId, workspaceId);
602 if (parent.trashedAt) throw ApiError.validation("Cannot create under a trashed page.", [{ field: "parentId", issue: "parent_trashed" }]);
603 }
604 const last = await this.prisma.page.findFirst({
605 where: { workspaceId, parentId, deletedAt: null, trashedAt: null },
606 orderBy: { position: "desc" },
607 select: { position: true }
608 });
609 const position = between(last?.position);
610 const isTask = input.target === "task";
611 const titleBlockId = `blk_${Math.random().toString(36).slice(2, 14)}`;
612
613 const row = await this.prisma.$transaction(async (tx) => {
614 const newPage = await tx.page.create({
615 data: {
616 workspaceId,
617 parentId,
618 position,
619 title,
620 ...(isTask ? { kind: "task" as const } : {}),
621 createdBy: userId,
622 updatedBy: userId
623 },
624 select: PAGE_SELECT
625 });
626 await tx.block.create({
627 data: {
628 id: titleBlockId,
629 pageId: newPage.id,
630 workspaceId,
631 parentId: null,
632 type: "PARAGRAPH",
633 position: "A",
634 content: { text: title },
635 properties: { isTitle: true },
636 createdBy: userId
637 }
638 });
639
640
641 let pos = "A";
642 for (const line of lines) {
643 pos = between(pos);
644 await tx.block.create({
645 data: {
646 id: `blk_${Math.random().toString(36).slice(2, 14)}`,
647 pageId: newPage.id,
648 workspaceId,
649 parentId: null,
650 type: "PARAGRAPH",
651 position: pos,
652 content: { text: line },
653 createdBy: userId
654 }
655 });
656 }
657 if (isTask) {
658 const task = await tx.task.create({
659 data: { workspaceId, pageId: newPage.id, title, status: "todo", assigneeIds: [], createdBy: userId }
660 });
661 return { page: newPage, task: { id: task.id, status: task.status, pageId: newPage.id } };
662 }
663 return { page: newPage, task: undefined as undefined };
664 });
665
666 await this.audit.audit(req, req?.auth, {
667 action: isTask ? "page.task_converted" : "page.whiteboard_converted",
668 resourceType: "page",
669 resourceId: row.page.id,
670 workspaceId,
671 pageId,
672 metadata: { sourcePageId: pageId, target: input.target, blockCount: lines.length + 1, title }
673 });
674 await this.events.publish(EVENTS.PAGE_CREATED, { workspaceId, pageId: row.page.id, title, parentId });
675 return { page: row.page, task: row.task, blockCount: lines.length + 1 };
676 }
677
678
679
680
681 async trash(pageId: string, userId: string, req: any) {
682 const page = await this.getPage(pageId);
683 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT");
684 const now = new Date();
685 const ids = [pageId, ...(await this.descendantPageIds(pageId))];
686 await this.prisma.page.updateMany({ where: { id: { in: ids } }, data: { trashedAt: now, updatedBy: userId } });
687
688
689
690 await this.audit.audit(req, req?.auth, {
691 action: "page.deleted",
692 resourceType: "page",
693 resourceId: pageId,
694 workspaceId: page.workspaceId,
695 pageId,
696 metadata: { pageIds: ids, descendants: ids.length - 1, permanent: false }
697 });
698 await this.events.publish(EVENTS.PAGE_DELETED, {
699 workspaceId: page.workspaceId,
700 pageId,
701 permanent: false,
702 pageIds: ids
703 });
704 return { trashed: true, pageIds: ids };
705 }
706
707
708 async restore(pageId: string, userId: string, req: any) {
709 const page = await this.getPage(pageId);
710 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT");
711 if (!page.trashedAt) throw ApiError.validation("Page is not in the trash.", [{ issue: "not_trashed" }]);
712 const ids = [pageId, ...(await this.descendantPageIds(pageId))];
713 await this.prisma.page.updateMany({
714 where: { id: { in: ids }, trashedAt: { not: null } },
715 data: { trashedAt: null, restoredAt: new Date(), updatedBy: userId }
716 });
717 await this.audit.audit(req, req?.auth, {
718 action: "page.restored",
719 resourceType: "page",
720 resourceId: pageId,
721 workspaceId: page.workspaceId,
722 pageId
723 });
724 return { restored: true, pageIds: ids };
725 }
726
727
728 async deletePermanent(pageId: string, userId: string, req: any) {
729 const page = await this.getPage(pageId);
730 const level = await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT");
731 if (level !== "FULL_ACCESS") {
732 throw ApiError.forbidden("Permanent delete requires full access (owner/admin).", [
733 { reason: "requires_FULL_ACCESS" }
734 ]);
735 }
736 if (!page.trashedAt) {
737 throw ApiError.validation("Only trashed pages can be permanently deleted.", [{ issue: "must_be_trashed" }]);
738 }
739 const ids = [pageId, ...(await this.descendantPageIds(pageId))];
740 await this.prisma.$transaction(async (tx) => {
741 for (const id of ids) {
742 await tx.databaseRecord.deleteMany({ where: { pageId: id } });
743 await tx.block.deleteMany({ where: { pageId: id } });
744 await tx.page.delete({ where: { id } });
745 }
746 });
747 await this.audit.audit(req, req?.auth, {
748 action: "page.permanent_deleted",
749 resourceType: "page",
750 resourceId: pageId,
751 workspaceId: page.workspaceId,
752 metadata: { pageIds: ids }
753 });
754 await this.events.publish(EVENTS.PAGE_DELETED, { workspaceId: page.workspaceId, pageId, permanent: true, pageIds: ids });
755 }
756
757 private async descendantPageIds(pageId: string): Promise<string[]> {
758 const ids: string[] = [];
759 const queue = [pageId];
760 while (queue.length > 0) {
761 const current = queue.shift()!;
762 const children = await this.prisma.page.findMany({
763 where: { parentId: current },
764 select: { id: true }
765 });
766 for (const c of children) {
767 ids.push(c.id);
768 queue.push(c.id);
769 }
770 }
771 return ids;
772 }
773
774
775
776 async listVersions(pageId: string, userId: string) {
777 const page = await this.getPage(pageId);
778 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
779 const versions = await this.prisma.pageVersion.findMany({
780 where: { pageId },
781 orderBy: { version: "desc" },
782 take: 50,
783 select: { id: true, version: true, note: true, createdBy: true, createdAt: true }
784 });
785 return { data: versions };
786 }
787
788
789 async createVersion(pageId: string, userId: string, note?: string) {
790 const page = await this.getPage(pageId);
791 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT");
792 const max = await this.prisma.pageVersion.aggregate({ where: { pageId }, _max: { version: true } });
793 const blocks = await this.prisma.block.findMany({ where: { pageId, deletedAt: null } });
794
795
796 let whiteboard: { canvas: object; schemaVersion: number; updatedAt: Date } | undefined;
797 if (page.kind === "whiteboard") {
798 const wb = await this.prisma.whiteboard.findUnique({ where: { pageId } });
799 if (wb) whiteboard = { canvas: wb.canvas as object, schemaVersion: wb.schemaVersion, updatedAt: wb.updatedAt };
800 }
801 const version = await this.prisma.pageVersion.create({
802 data: {
803 pageId,
804 version: (max._max.version ?? 0) + 1,
805 snapshot: {
806 page,
807 blocks: blocks.map((b) => ({ id: b.id, type: b.type, position: b.position, content: b.content, properties: b.properties, parentId: b.parentId })),
808 ...(whiteboard ? { whiteboard } : {})
809 } as object,
810 createdBy: userId,
811 note
812 }
813 });
814 return version;
815 }
816
817
818 async getVersionPreview(pageId: string, versionId: string, userId: string) {
819 const page = await this.getPage(pageId);
820 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
821 const version = await this.prisma.pageVersion.findFirst({ where: { id: versionId, pageId } });
822 if (!version) throw ApiError.notFound("Version not found.");
823 return { version: version.version, createdAt: version.createdAt, note: version.note, snapshot: version.snapshot };
824 }
825
826
827 async compareVersions(pageId: string, fromVersion: number, toVersion: number, userId: string) {
828 const page = await this.getPage(pageId);
829 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
830 if (fromVersion === toVersion) throw ApiError.validation("from and to must differ.", [{ field: "from", issue: "same_version" }]);
831 const [fromRow, toRow] = await Promise.all([
832 this.prisma.pageVersion.findFirst({ where: { pageId, version: fromVersion } }),
833 this.prisma.pageVersion.findFirst({ where: { pageId, version: toVersion } })
834 ]);
835 if (!fromRow || !toRow) throw ApiError.notFound(`Version ${fromRow ? toVersion : fromVersion} not found.`);
836 return diffSnapshots(fromRow.snapshot as unknown as PageSnapshotInput, toRow.snapshot as unknown as PageSnapshotInput, fromVersion, toVersion);
837 }
838
839
840 async restoreVersion(pageId: string, versionId: string, userId: string, req: any) {
841 const page = await this.getPage(pageId);
842 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "EDIT");
843 const version = await this.prisma.pageVersion.findFirst({ where: { id: versionId, pageId } });
844 if (!version) throw ApiError.notFound("Version not found.");
845 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 } };
846 await this.prisma.$transaction(async (tx) => {
847 if (snap.page) {
848 await tx.page.update({
849 where: { id: pageId },
850 data: {
851 ...(snap.page.title !== undefined ? { title: snap.page.title } : {}),
852 ...(snap.page.icon !== undefined ? { icon: snap.page.icon } : {}),
853 ...(snap.page.description !== undefined ? { description: snap.page.description } : {})
854 }
855 });
856 }
857 for (const b of snap.blocks ?? []) {
858 await tx.block.upsert({
859 where: { id: b.id },
860 update: { content: b.content, properties: b.properties, parentId: b.parentId },
861 create: { id: b.id, pageId, workspaceId: page.workspaceId, type: "PARAGRAPH", position: "Z", content: b.content, properties: b.properties, parentId: b.parentId, createdBy: userId }
862 });
863 }
864
865
866 if (snap.whiteboard) {
867 const parsed = validateCanvas(snap.whiteboard.canvas);
868 if (parsed.ok) {
869 await tx.whiteboard.upsert({
870 where: { pageId },
871 create: { pageId, canvas: parsed.canvas as object, schemaVersion: parsed.canvas.version, updatedBy: userId },
872 update: { canvas: parsed.canvas as object, schemaVersion: parsed.canvas.version, updatedBy: userId }
873 });
874 }
875 }
876 });
877 await this.audit.audit(req, req?.auth, {
878 action: "page.version_restored",
879 resourceType: "page_version",
880 resourceId: versionId,
881 workspaceId: page.workspaceId,
882 pageId
883 });
884 await this.events.publish(EVENTS.PAGE_UPDATED, { workspaceId: page.workspaceId, pageId, restoredVersion: version.version });
885
886 try { await this.notifications.notifyWatchersForPageUpdate(pageId, userId); } catch { }
887 return { restored: true, version: version.version };
888 }
889
890
891
892
894 async watch(pageId: string, userId: string) {
895 const page = await this.getPage(pageId);
896 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
897 await this.prisma.pageWatch.upsert({
898 where: { pageId_userId: { pageId, userId } },
899 update: {},
900 create: { pageId, userId }
901 });
902 return { watching: true };
903 }
904
905 async unwatch(pageId: string, userId: string) {
906 const page = await this.getPage(pageId);
907 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
908 await this.prisma.pageWatch.deleteMany({ where: { pageId, userId } });
909 return { watching: false };
910 }
911
912 async isWatching(pageId: string, userId: string) {
913 const page = await this.getPage(pageId);
914 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
915 const row = await this.prisma.pageWatch.findUnique({ where: { pageId_userId: { pageId, userId } } });
916 return { watching: Boolean(row) };
917 }
918
919
920
921 async favorite(pageId: string, userId: string) {
922 const page = await this.getPage(pageId);
923 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
924 await this.prisma.pageFavorite.upsert({
925 where: { workspaceId_userId_pageId: { workspaceId: page.workspaceId, userId, pageId } },
926 update: {},
927 create: { workspaceId: page.workspaceId, userId, pageId }
928 });
929 return { favorited: true };
930 }
931
932 async unfavorite(pageId: string, userId: string) {
933 const page = await this.getPage(pageId);
934 await this.prisma.pageFavorite.deleteMany({
935 where: { workspaceId: page.workspaceId, userId, pageId }
936 });
937 return { favorited: false };
938 }
939
940 async isFavorite(pageId: string, userId: string) {
941 const page = await this.getPage(pageId);
942 const row = await this.prisma.pageFavorite.findUnique({
943 where: { workspaceId_userId_pageId: { workspaceId: page.workspaceId, userId, pageId } }
944 });
945 return { favorite: Boolean(row) };
946 }
947
948
949
950
951 async createTemplate(pageId: string, userId: string, input: { name: string; icon?: string; category?: string; description?: string; variables?: unknown }, req: any) {
952 const page = await this.getPage(pageId);
953 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
954 const subtree = await this.captureSubtree(pageId);
955 const vars = input.variables !== undefined ? this.requireValidTemplateVars(input.variables) : undefined;
956 const template = await this.prisma.template.create({
957 data: {
958 workspaceId: page.workspaceId,
959 name: input.name,
960 icon: input.icon ?? page.icon,
961 type: "page",
962 sourceRef: { type: "page", id: pageId } as object,
963 snapshot: subtree as object,
964 category: input.category,
965 description: input.description,
966 variables: vars ? (vars as object) : undefined,
967 createdBy: userId
968 }
969 });
970
971 await this.prisma.templateVersion.create({
972 data: { templateId: template.id, version: 1, snapshot: subtree as object, name: input.name, description: input.description, variables: vars ? (vars as object) : undefined, createdBy: userId }
973 });
974 await this.audit.audit(req, req?.auth, {
975 action: "template.created",
976 resourceType: "template",
977 resourceId: template.id,
978 workspaceId: page.workspaceId,
979 metadata: { sourcePageId: pageId, name: input.name, category: input.category ?? null }
980 });
981 return template;
982 }
983
984
985 async listTemplates(workspaceId: string, userId: string, type?: string, opts?: { category?: string; search?: string }) {
986 const memberships = await this.prisma.workspaceMember.findMany({
987 where: { userId },
988 select: { workspaceId: true }
989 });
990 const ids = [...new Set([workspaceId, ...memberships.map((m) => m.workspaceId)])];
991 const templates = await this.prisma.template.findMany({
992 where: {
993 workspaceId: { in: ids },
994 ...(type ? { type } : {}),
995 ...(opts?.category ? { category: opts.category } : {}),
996 ...(opts?.search
997 ? { OR: [{ name: { contains: opts.search, mode: "insensitive" } }, { description: { contains: opts.search, mode: "insensitive" } }] }
998 : {})
999 },
1000 select: {
1001 id: true, name: true, icon: true, type: true, isBuiltin: true, sourceRef: true,
1002 category: true, description: true, variables: true, defaultFor: true, createdAt: true
1003 },
1004 orderBy: { createdAt: "asc" }
1005 });
1006 return { data: templates };
1007 }
1008
1009
1010
1011
1013 private async requireNotBuiltin(templateId: string, workspaceId: string, userId: string) {
1014 const t = await this.prisma.template.findFirst({ where: { id: templateId, workspaceId }, select: { id: true, isBuiltin: true } });
1015 if (!t) throw ApiError.notFound("Template not found.");
1016 if (t.isBuiltin) {
1017 const level = await assertCan(this.prisma, { workspaceId, userId }, "EDIT");
1018 if (level !== "FULL_ACCESS") {
1019 throw ApiError.forbidden("Built-in templates cannot be modified.", [{ reason: "builtin_template" }]);
1020 }
1021 }
1022 return t;
1023 }
1024
1025 private requireValidTemplateVars(vars: unknown) {
1026 const r = validateTemplateVars(vars);
1027 if (!r.ok) throw ApiError.validation("Invalid template variables.", r.errors.map((issue) => ({ field: "variables", issue })));
1028 return r.vars;
1029 }
1030
1031
1032 async createTemplateVersion(templateId: string, workspaceId: string, userId: string, note?: string, req?: any) {
1033 await this.requireNotBuiltin(templateId, workspaceId, userId);
1034 const template = await this.prisma.template.findFirst({ where: { id: templateId, workspaceId } });
1035 if (!template) throw ApiError.notFound("Template not found.");
1036 const last = await this.prisma.templateVersion.findFirst({
1037 where: { templateId },
1038 orderBy: { version: "desc" },
1039 select: { version: true }
1040 });
1041 const version = (last?.version ?? 0) + 1;
1042 const v = await this.prisma.templateVersion.create({
1043 data: {
1044 templateId,
1045 version,
1046 snapshot: template.snapshot as object,
1047 name: template.name,
1048 description: template.description,
1049 variables: template.variables as object,
1050 note,
1051 createdBy: userId
1052 }
1053 });
1054 if (req) {
1055 await this.audit.audit(req, req?.auth, {
1056 action: "template.versioned",
1057 resourceType: "template",
1058 resourceId: templateId,
1059 workspaceId,
1060 metadata: { version }
1061 });
1062 }
1063 return v;
1064 }
1065
1066 async listTemplateVersions(templateId: string, workspaceId: string, userId: string) {
1067 const t = await this.prisma.template.findFirst({ where: { id: templateId, workspaceId }, select: { id: true } });
1068 if (!t) throw ApiError.notFound("Template not found.");
1069 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
1070 const versions = await this.prisma.templateVersion.findMany({
1071 where: { templateId },
1072 select: { id: true, version: true, note: true, createdBy: true, createdAt: true },
1073 orderBy: { version: "desc" }
1074 });
1075 return { data: versions };
1076 }
1077
1078
1089 async previewTemplate(templateId: string, userId: string) {
1090 const t = await this.prisma.template.findFirst({ where: { id: templateId }, select: { id: true, workspaceId: true, snapshot: true } });
1091 if (!t) throw ApiError.notFound("Template not found.");
1092 const member = await this.prisma.workspaceMember.findFirst({ where: { workspaceId: t.workspaceId, userId }, select: { id: true } });
1093 if (!member) throw ApiError.notFound("Template not found.");
1094 return { data: { snapshot: t.snapshot } };
1095 }
1096
1097
1099 async restoreTemplateVersion(templateId: string, versionId: string, workspaceId: string, userId: string, req: any) {
1100 await this.requireNotBuiltin(templateId, workspaceId, userId);
1101 const template = await this.prisma.template.findFirst({ where: { id: templateId, workspaceId } });
1102 if (!template) throw ApiError.notFound("Template not found.");
1103 const v = await this.prisma.templateVersion.findFirst({ where: { id: versionId, templateId } });
1104 if (!v) throw ApiError.notFound("Template version not found.");
1105 await this.prisma.template.update({
1106 where: { id: templateId },
1107 data: {
1108 snapshot: v.snapshot as object,
1109 name: v.name ?? template.name,
1110 description: v.description ?? template.description,
1111 variables: (v.variables ?? template.variables) as object
1112 }
1113 });
1114 const newVersion = await this.createTemplateVersion(templateId, workspaceId, userId, `restored from v${v.version}`, req);
1115 return { restoredFromVersion: v.version, newVersion: newVersion.version };
1116 }
1117
1118
1119 async applyTemplate(workspaceId: string, userId: string, templateId: string, opts: { parentId?: string; title?: string; values?: Record<string, unknown> }, req: any) {
1120 const template = await this.prisma.template.findFirst({ where: { id: templateId, workspaceId } });
1121 if (!template) throw ApiError.notFound("Template not found.");
1122 await assertCan(this.prisma, { workspaceId, userId }, "EDIT");
1123
1124
1125
1126 type SnapBlock = { type: string; content: object; properties: object; parentId: string | null; position: string } & Record<string, unknown>;
1127 type SubtreeSnapshot = {
1128 page: { title: string; icon: string | null; description: string | null };
1129 blocks: SnapBlock[];
1130 children: { title: string; blocks: SnapBlock[] }[];
1131 };
1132 let snapshot = template.snapshot as SubtreeSnapshot;
1133
1134
1135 if (template.variables) {
1136 const defs = this.requireValidTemplateVars(template.variables);
1137 const resolved = resolveTemplateValues(defs, opts.values ?? {});
1138 if (!resolved.ok) {
1139 throw ApiError.validation("Invalid template variable values.", resolved.errors.map((issue) => ({ field: "values", issue })));
1140 }
1141
1142
1143 snapshot = substituteSnapshot(template.snapshot, resolved.values) as SubtreeSnapshot;
1144 }
1145 const rootTitle = opts.title ?? snapshot.page.title;
1146 const parentId = opts.parentId ?? null;
1147 if (parentId) {
1148 const parent = await this.assertInWorkspace(parentId, workspaceId);
1149 void parent;
1150 }
1151 const created = await this.prisma.$transaction(async (tx) => {
1152 const root = await tx.page.create({
1153 data: {
1154 workspaceId,
1155 parentId,
1156 position: await this.appendPagePosition(tx, workspaceId, parentId),
1157 title: rootTitle,
1158 icon: snapshot.page.icon,
1159 description: snapshot.page.description,
1160 createdBy: userId
1161 }
1162 });
1163 const blockMap = new Map<string, string>();
1164 for (const b of snapshot.blocks) {
1165 const block = await tx.block.create({
1166 data: {
1167 pageId: root.id,
1168 workspaceId,
1169 parentId: b.parentId ? (blockMap.get(b.parentId) ?? null) : null,
1170 type: b.type as never,
1171 position: b.position || "A",
1172 content: b.content,
1173 properties: b.properties,
1174 createdBy: userId
1175 }
1176 });
1177 blockMap.set(b.parentId ?? "root", block.id);
1178 }
1179 for (const child of snapshot.children) {
1180 const childPage = await tx.page.create({
1181 data: { workspaceId, parentId: root.id, position: between(undefined), title: child.title, createdBy: userId }
1182 });
1183 for (const b of child.blocks) {
1184 await tx.block.create({
1185 data: {
1186 pageId: childPage.id,
1187 workspaceId,
1188 parentId: null,
1189 type: b.type as never,
1190 position: b.position || "A",
1191 content: b.content,
1192 properties: b.properties,
1193 createdBy: userId
1194 }
1195 });
1196 }
1197 }
1198 return root;
1199 });
1200
1201 await this.audit.audit(req, req?.auth, {
1202 action: "template.applied",
1203 resourceType: "template",
1204 resourceId: templateId,
1205 workspaceId,
1206 metadata: { newPageId: created.id }
1207 });
1208 await this.events.publish(EVENTS.PAGE_CREATED, { workspaceId, pageId: created.id, title: rootTitle, fromTemplate: templateId });
1209 return created;
1210 }
1211
1212 private async captureSubtree(pageId: string) {
1213 const page = await this.prisma.page.findUniqueOrThrow({ where: { id: pageId } });
1214 const blocks = await this.prisma.block.findMany({
1215 where: { pageId, deletedAt: null },
1216 select: { id: true, type: true, content: true, properties: true, parentId: true, position: true }
1217 });
1218 const children = await this.prisma.page.findMany({ where: { parentId: pageId, deletedAt: null }, select: { id: true, title: true } });
1219 const childSnapshots: { title: string; blocks: typeof blocks }[] = [];
1220 for (const c of children) {
1221 const cb = await this.prisma.block.findMany({
1222 where: { pageId: c.id, deletedAt: null },
1223 select: { id: true, type: true, content: true, properties: true, parentId: true, position: true }
1224 });
1225 childSnapshots.push({ title: c.title, blocks: cb });
1226 }
1227 return {
1228 page: { title: page.title, icon: page.icon, description: page.description },
1229 blocks,
1230 children: childSnapshots
1231 };
1232 }
1233
1234
1235
1236
1247 async exportMarkdown(pageId: string, userId: string, opts: { includeChildren?: boolean }, req: any) {
1248 const page = await this.getPage(pageId);
1249 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
1250
1251 const subtree = await this.captureSubtree(pageId);
1252 const markdown = renderPageMarkdown({
1253 title: page.title,
1254 icon: page.icon,
1255 description: page.description,
1256 blocks: subtree.blocks as ExportInput["blocks"],
1257 children: opts.includeChildren
1258 ? (subtree.children as unknown as ExportInput["children"])
1259 : undefined,
1260 includeChildren: opts.includeChildren
1261 });
1262
1263 await this.audit.audit(req, req?.auth, {
1264 action: "page.exported",
1265 resourceType: "page",
1266 resourceId: page.id,
1267 workspaceId: page.workspaceId,
1268 pageId: page.id,
1269 metadata: { format: "markdown", includeChildren: opts.includeChildren === true, bytes: Buffer.byteLength(markdown, "utf8") }
1270 });
1271
1272 return { markdown, filename: exportFilename(page.title), title: page.title };
1273 }
1274
1275
1276
1277
1285 async duplicate(pageId: string, userId: string, input: { parentId?: string; title?: string }, req: any) {
1286 const src = await this.getPage(pageId);
1287 await assertCan(this.prisma, { workspaceId: src.workspaceId, userId, resource: { pageId: src.id } }, "EDIT");
1288 const parentId = input.parentId ?? src.parentId;
1289 if (parentId) {
1290 const parent = await this.assertInWorkspace(parentId, src.workspaceId);
1291 if (parent.trashedAt) throw ApiError.validation("Cannot duplicate under a trashed page.", [{ field: "parentId", issue: "parent_trashed" }]);
1292 }
1293 const wsId = src.workspaceId;
1294
1295 const created = await this.prisma.$transaction(async (tx) => {
1296 const copyPage = async (
1297 page: { id: string; title: string; icon: string | null; description: string | null; kind: string },
1298 underParentId: string | null
1299 ): Promise<string> => {
1300 const position = await this.appendPagePosition(tx, wsId, underParentId);
1301 const newPage = await tx.page.create({
1302 data: {
1303 workspaceId: wsId,
1304 parentId: underParentId,
1305 position,
1306 title: page.title,
1307 icon: page.icon,
1308 description: page.description,
1309 kind: page.kind,
1310 createdBy: userId,
1311 updatedBy: userId
1312 }
1313 });
1314
1315
1316
1317
1318 const blocks = await tx.block.findMany({
1319 where: { pageId: page.id, deletedAt: null },
1320 orderBy: { position: "asc" }
1321 });
1322 const blockMap = new Map<string, string>();
1323 const blockById = new Map(blocks.map((b) => [b.id, b]));
1324 const remaining = new Set(blocks.map((b) => b.id));
1325 let progress = true;
1326 while (progress && remaining.size > 0) {
1327 progress = false;
1328 for (const id of [...remaining]) {
1329 const b = blockById.get(id)!;
1330 if (b.parentId && !blockMap.has(b.parentId)) continue;
1331 remaining.delete(id);
1332 progress = true;
1333 const newBlock = await tx.block.create({
1334 data: {
1335 pageId: newPage.id,
1336 workspaceId: wsId,
1337 parentId: b.parentId ? (blockMap.get(b.parentId) ?? null) : null,
1338 type: b.type,
1339 position: b.position,
1340 content: b.content as object,
1341 properties: b.properties as object,
1342 createdBy: userId
1343 }
1344 });
1345 blockMap.set(b.id, newBlock.id);
1346 }
1347 }
1348
1349
1350 void remaining;
1351
1352
1353 const db = await tx.database.findFirst({ where: { pageId: page.id } });
1354 if (db) {
1355 const newDb = await tx.database.create({
1356 data: {
1357 workspaceId: wsId,
1358 pageId: newPage.id,
1359 title: db.title,
1360 isArchived: false,
1361 createdBy: userId
1362 }
1363 });
1364 const props = await tx.databaseProperty.findMany({
1365 where: { databaseId: db.id },
1366 orderBy: { position: "asc" }
1367 });
1368 for (const p of props) {
1369 await tx.databaseProperty.create({
1370 data: {
1371 databaseId: newDb.id,
1372 name: p.name,
1373 type: p.type,
1374 isTitle: p.isTitle,
1375 config: p.config as object,
1376 position: p.position
1377 }
1378 });
1379 }
1380 const views = await tx.databaseView.findMany({
1381 where: { databaseId: db.id },
1382 orderBy: { position: "asc" }
1383 });
1384 for (const v of views) {
1385 await tx.databaseView.create({
1386 data: {
1387 databaseId: newDb.id,
1388 name: v.name,
1389 type: v.type,
1390 position: v.position,
1391 config: v.config as object
1392 }
1393 });
1394 }
1395 }
1396 const children = await tx.page.findMany({
1397 where: { parentId: page.id, deletedAt: null, trashedAt: null },
1398 orderBy: { position: "asc" },
1399 select: { id: true, title: true, icon: true, description: true, kind: true }
1400 });
1401 for (const c of children) {
1402 await copyPage(c, newPage.id);
1403 }
1404 return newPage.id;
1405 };
1406 return copyPage(
1407 { id: src.id, title: input.title ?? (src.title ? `${src.title} (copy)` : "Untitled (copy)"), icon: src.icon, description: src.description, kind: src.kind },
1408 parentId
1409 );
1410 });
1411
1412 await this.audit.audit(req, req?.auth, {
1413 action: "page.duplicated",
1414 resourceType: "page",
1415 resourceId: created,
1416 workspaceId: wsId,
1417 pageId: created,
1418 metadata: { sourcePageId: pageId, parentId }
1419 });
1420 await this.events.publish(EVENTS.PAGE_CREATED, { workspaceId: wsId, pageId: created, title: src.title, duplicatedFrom: pageId });
1421 return { id: created, sourcePageId: pageId };
1422 }
1423
1424
1425
1426
1433 async moveCrossWorkspace(pageId: string, userId: string, input: { toWorkspaceId: string; toParentId?: string }, req: any) {
1434 const src = await this.getPage(pageId);
1435 if (src.trashedAt || src.deletedAt) throw ApiError.validation("Cannot move a trashed page.", [{ issue: "trashed" }]);
1436 await assertCan(this.prisma, { workspaceId: src.workspaceId, userId, resource: { pageId: src.id } }, "EDIT");
1437
1438 const toWorkspaceId = input.toWorkspaceId;
1439 if (toWorkspaceId === src.workspaceId) {
1440 throw ApiError.validation("Page is already in that workspace.", [{ field: "toWorkspaceId", issue: "same_workspace" }]);
1441 }
1442 const destWs = await this.prisma.workspace.findUnique({ where: { id: toWorkspaceId } });
1443 if (!destWs || destWs.deletedAt) throw ApiError.notFound("Destination workspace not found.");
1444 await assertCan(this.prisma, { workspaceId: toWorkspaceId, userId }, "EDIT");
1445
1446 const toParentId = input.toParentId ?? null;
1447 if (toParentId) {
1448 const parent = await this.prisma.page.findUnique({ where: { id: toParentId } });
1449 if (!parent || parent.workspaceId !== toWorkspaceId) throw ApiError.notFound("Destination parent page not found.");
1450 if (parent.trashedAt || parent.deletedAt) throw ApiError.validation("Cannot move under a trashed page.", [{ field: "toParentId", issue: "parent_trashed" }]);
1451 }
1452
1453 const subtree = [pageId, ...(await this.descendantPageIds(pageId))];
1454 const position = await this.appendPagePosition(this.prisma, toWorkspaceId, toParentId);
1455 await this.prisma.page.updateMany({
1456 where: { id: { in: subtree } },
1457 data: { workspaceId: toWorkspaceId, updatedBy: userId }
1458 });
1459
1460
1461 await this.prisma.page.update({ where: { id: pageId }, data: { parentId: toParentId, position } });
1462
1463
1464 await this.prisma.block.updateMany({ where: { pageId: { in: subtree } }, data: { workspaceId: toWorkspaceId } });
1465 await this.prisma.databaseRecord.updateMany({ where: { pageId: { in: subtree } }, data: { workspaceId: toWorkspaceId } });
1466 await this.prisma.file.updateMany({ where: { pageId: { in: subtree } }, data: { workspaceId: toWorkspaceId } });
1467
1468 await this.audit.audit(req, req?.auth, {
1469 action: "page.moved.workspace",
1470 resourceType: "page",
1471 resourceId: pageId,
1472 workspaceId: toWorkspaceId,
1473 pageId,
1474 metadata: { fromWorkspaceId: src.workspaceId, toWorkspaceId, toParentId, pageIds: subtree.length }
1475 });
1476 await this.events.publish(EVENTS.PAGE_UPDATED, {
1477 workspaceId: toWorkspaceId,
1478 pageId,
1479 movedFromWorkspaceId: src.workspaceId,
1480 pageIds: subtree
1481 });
1482 return { moved: true, pageId, fromWorkspaceId: src.workspaceId, toWorkspaceId, pageIds: subtree.length };
1483 }
1484
1485
1486
1487
1493 async bulk(
1494 workspaceId: string,
1495 userId: string,
1496 input: { action: "trash" | "archive" | "unarchive" | "favorite" | "unfavorite"; ids: string[] },
1497 req: any
1498 ) {
1499
1500 const ids = [...new Set(input.ids)];
1501 const pages = await this.prisma.page.findMany({
1502 where: { workspaceId, id: { in: ids }, deletedAt: null },
1503 select: { id: true, trashedAt: true, archivedAt: true }
1504 });
1505 const byId = new Map(pages.map((p) => [p.id, p]));
1506 const succeeded: string[] = [];
1507 const failed: { id: string; code: string }[] = [];
1508
1509 for (const id of ids) {
1510 const page = byId.get(id);
1511 if (!page) {
1512 failed.push({ id, code: "NOT_FOUND" });
1513 continue;
1514 }
1515 try {
1516 switch (input.action) {
1517 case "trash":
1518
1519 await this.trash(id, userId, req);
1520 break;
1521 case "archive":
1522 await assertCan(this.prisma, { workspaceId, userId, resource: { pageId: id } }, "EDIT");
1523 await this.prisma.page.update({ where: { id }, data: { archivedAt: new Date(), updatedBy: userId } });
1524 break;
1525 case "unarchive":
1526 await assertCan(this.prisma, { workspaceId, userId, resource: { pageId: id } }, "EDIT");
1527 await this.prisma.page.update({ where: { id }, data: { archivedAt: null, updatedBy: userId } });
1528 break;
1529 case "favorite":
1530 await this.favorite(id, userId);
1531 break;
1532 case "unfavorite":
1533 await this.unfavorite(id, userId);
1534 break;
1535 }
1536 succeeded.push(id);
1537 } catch (err) {
1538 const code = err instanceof ApiError ? (err.httpStatus === 403 ? "FORBIDDEN" : err.code) : "ERROR";
1539 failed.push({ id, code });
1540 }
1541 }
1542
1543 await this.audit.audit(req, req?.auth, {
1544 action: "page.bulk",
1545 resourceType: "page",
1546 resourceId: ids[0] ?? workspaceId,
1547 workspaceId,
1548 metadata: { action: input.action, total: ids.length, succeeded: succeeded.length, failed: failed.length }
1549 });
1550 return { action: input.action, succeeded, failed };
1551 }
1552
1553
1554
1555
1563 async backlinks(pageId: string, userId: string) {
1564 const page = await this.getPage(pageId);
1565 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId, resource: { pageId: page.id } }, "VIEW");
1566 const wsId = page.workspaceId;
1567
1568 const refs = new Set<string>();
1569
1570
1571 const childPageBlocks = await this.prisma.block.findMany({
1572 where: { workspaceId: wsId, deletedAt: null, type: "CHILD_PAGE", content: { path: ["pageId"], equals: pageId } },
1573 select: { pageId: true }
1574 });
1575 for (const b of childPageBlocks) refs.add(b.pageId);
1576
1577
1578 const db = await this.prisma.database.findFirst({ where: { pageId }, select: { id: true } });
1579 if (db) {
1580 const linkedDbBlocks = await this.prisma.block.findMany({
1581 where: { workspaceId: wsId, deletedAt: null, type: "LINKED_DATABASE", content: { path: ["databaseId"], equals: db.id } },
1582 select: { pageId: true }
1583 });
1584 for (const b of linkedDbBlocks) refs.add(b.pageId);
1585 }
1586
1587
1588 const comments = await this.prisma.comment.findMany({
1589 where: { workspaceId: wsId, pageMentionIds: { has: pageId } },
1590 select: { pageId: true }
1591 });
1592 for (const c of comments) if (c.pageId) refs.add(c.pageId);
1593
1594 refs.delete(pageId);
1595
1596
1597 if (refs.size === 0) return { data: [] as Array<{ pageId: string; title: string; via: string[] }> };
1598 const sources = await this.prisma.page.findMany({
1599 where: { id: { in: [...refs] }, deletedAt: null },
1600 select: { id: true, title: true, icon: true, kind: true }
1601 });
1602 const data: Array<{ pageId: string; title: string; icon: string | null; kind: string }> = [];
1603 for (const s of sources) {
1604 const level = await actorLevel(this.prisma, { workspaceId: wsId, userId, resource: { pageId: s.id } });
1605 if (level === "NONE") continue;
1606 data.push({ pageId: s.id, title: s.title, icon: s.icon, kind: s.kind });
1607 }
1608 data.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
1609 return { data };
1610 }
1611}
1612