.github/9
ISSUE_TEMPLATE/3bug_report.md config.yml feature_request.md
workflows/3ci.yml clean-clone.yml release.yml
CODEOWNERS dependabot.yml pull_request_template.md
apps/630
api/250
src/243
common/30
__tests__/3authorize.test.ts bigint-serialization.interceptor.test.ts files-limits.test.ts
filters/1all-exceptions.filter.ts
guards/3auth.guard.ts internal-service.guard.ts rate-limit.guard.ts
interceptors/2actor-context.interceptor.ts bigint-serialization.interceptor.ts
middleware/2idempotency.test.ts idempotency.ts
pipes/1zod-validation.pipe.ts
actor-context.ts audit.service.ts cache.test.ts cache.ts cookies.ts core.module.ts deprecation.ts email.service.ts errors.ts event-bus.ts log-scrubber.test.ts log-scrubber.ts logger.ts permission-cache-bridge.ts prisma.service.ts redis.service.ts trace-metadata.ts types.ts
config/1env.ts
lib/11
__tests__/5crypto.test.ts filter-builder.test.ts jwt.test.ts pagination.test.ts positions.test.ts
authorize.ts crypto.ts filter-builder.ts jwt.ts pagination.ts positions.ts
modules/197
ai/28
__tests__/4ai-writing.test.ts database-ai.test.ts generate-executor.test.ts prompts.test.ts
generator/4executor.ts generate.controller.ts generate.dto.ts generate.service.ts
ai-chat.service.ts ai-rate-limit.ts ai-writing.controller.ts ai-writing.dto.ts ai-writing.service.ts ai.controller.ts ai.module.ts ai.service.ts budget-guard.service.ts database-ai.controller.ts database-ai.dto.ts database-ai.service.ts db-prompts.ts dto.ts gateway-provider.ts ledger.service.ts prompts.ts rag.controller.ts rag.dto.ts rag.service.ts
api-keys/3api-keys.controller.ts api-keys.module.ts api-keys.service.ts
audit/2audit-log.controller.ts audit.module.ts
auth/4auth.controller.ts auth.module.ts auth.service.ts dto.ts
automations/5automations.controller.ts automations.module.ts automations.service.ts dto.ts webhook-incoming.controller.ts
blocks/5
__tests__/1batch-version-cas.test.ts
block.service.ts blocks.controller.ts blocks.module.ts dto.ts
comments/4comments.controller.ts comments.module.ts comments.service.ts dto.ts
databases/9
__tests__/3normalize-select-options.test.ts option-id-canonicalization.test.ts record-open-query-shape.test.ts
database.service.ts databases.controller.ts databases.module.ts dto.ts property-cache-schema.ts query.service.ts
docs/2docs.controller.ts docs.module.ts
files/10
__tests__/1quota.test.ts
dto.ts files.controller.ts files.module.ts files.service.ts quota.ts record.ts s3.ts uploads.controller.ts uploads.service.ts
forms/18debug.controller.ts form.dto.ts forms.controller.ts forms.module.ts forms.service.ts markdown.test.ts markdown.ts optional-auth.guard.ts public-forms.controller.ts public-forms.service.ts question.dto.ts questions.service.ts rate-limit.ts recaptcha.ts slug.ts submissions.controller.ts submissions.service.ts submit-schema.ts
health/3health.controller.ts health.module.ts health.service.ts
integrations/3integrations.controller.ts integrations.module.ts integrations.service.ts
internal/12
__tests__/2flush-query-shape.test.ts internal.endpoints.test.ts
ai-test-seam.controller.ts ai-test-seam.dto.ts ai-test-seam.service.ts internal.controller.ts internal.dto.ts internal.module.ts internal.service.ts test-seam.controller.ts test-seam.dto.ts test-seam.service.ts
notifications/3notifications.controller.ts notifications.module.ts notifications.service.ts
oauth/6
__tests__/1oauth.scopes.test.ts
oauth.controller.ts oauth.module.ts oauth.service.ts scopes.ts token-keys.ts
org/15audit.service.ts embed-preview.controller.ts guards.ts members.service.ts org-admin.controller.ts org-policy.service.ts org.module.ts overview.service.ts policy-effective.controller.ts policy.schema.ts policy.test.ts retention.service.ts totp.test.ts totp.ts two-factor.service.ts
orgs/5dto.ts orgs.controller.ts orgs.module.ts orgs.service.ts util.ts
pages/4dto.ts page.service.ts pages.controller.ts pages.module.ts
scim/15
__tests__/3email-mapping.test.ts etag.test.ts filters.test.ts
etag.ts filters.ts groups.service.ts schemas.ts scim-admin.controller.ts scim-admin.service.ts scim-auth.guard.ts scim-auth.service.ts scim-errors.ts scim.controller.ts scim.module.ts users.service.ts
search/4
__tests__/1search.service.test.ts
search.controller.ts search.module.ts search.service.ts
sessions/3sessions.controller.ts sessions.module.ts sessions.service.ts
share/3share.controller.ts share.module.ts share.service.ts
sso/16
__tests__/4enforcement.test.ts oidc.verify.test.ts provisioning.test.ts saml.validate.test.ts
enforcement.ts oidc.controller.ts oidc.service.ts oidc.ts provisioning.service.ts saml.controller.ts saml.ts schemas.ts sso-admin.controller.ts sso-admin.service.ts sso-secrets.ts sso.module.ts
teams/4dto.ts teams.controller.ts teams.module.ts teams.service.ts
users/4dto.ts users.controller.ts users.module.ts users.service.ts
webhooks/3webhooks.controller.ts webhooks.module.ts webhooks.service.ts
workspaces/4dto.ts workspaces.controller.ts workspaces.module.ts workspaces.service.ts
types/1xmldom.d.ts
app.module.ts constants.ts main.ts
_probe_rawbody.cjs .env.example Dockerfile package.json README.md tsconfig.json vitest.config.ts
realtime/33
src/17auth.ts comments.ts config.ts event-source.ts http-store.ts index.ts metrics.ts notifications.ts page-updated-source.ts presence.ts prisma-store.ts protocol.ts rate-limit.ts room-key.ts rooms.ts server.ts update-log.ts
test/11auth.test.ts config.test.ts event-source.test.ts http-store.test.ts metrics-merge.test.ts page-updated-source.test.ts presence.test.ts protocol.test.ts rate-limit.test.ts rooms.test.ts task-0094-builders.test.ts
.env.example Dockerfile package.json README.md tsconfig.json
web/255
src/244
app/45
__tests__/1theme-tokens.test.ts
(app)/26
w/25
[workspace]/25
ask/1page.tsx
databases/1
[id]/1page.tsx
favorites/1page.tsx
files/1page.tsx
notifications/1page.tsx
page/4
[id]/4
[block]/1page.tsx
share/1page.tsx
layout.tsx page.tsx
search/1page.tsx
settings/10
appearance/1page.tsx
automations/3
[id]/1page.tsx
new/1page.tsx
page.tsx
billing/1page.tsx
developers/1page.tsx
members/1page.tsx
permissions/1page.tsx
teams/1page.tsx
page.tsx
shared/1page.tsx
templates/1page.tsx
trash/1page.tsx
layout.tsx page.tsx
layout.tsx
(auth)/4
login/1page.tsx
register/1page.tsx
reset-password/1page.tsx
layout.tsx
api/2
metrics/1route.ts
v1/1
health/1route.ts
f/1
[slug]/1page.tsx
health/1
ready/1route.ts
org/2
[orgId]/2
admin/2
integrations/1page.tsx
page.tsx
share/1
[id]/1page.tsx
error.tsx global-error.tsx globals.css layout.tsx not-found.tsx page.tsx providers.tsx
components/118
admin/10
org-admin/8ApiKeysTab.tsx AuditTab.tsx MembersTab.tsx OverviewTab.tsx PoliciesTab.tsx RetentionTab.tsx WebhooksTab.tsx WorkspacePicker.tsx
ScimCard.tsx SsoTabs.tsx
ai/6ai-panel.tsx ask-ai-submenu.tsx db-ai-dialog.tsx generate-page-dialog.tsx writing-actions-menu.tsx writing-diff.tsx
automations/5action-editor.tsx condition-editor.tsx lib.ts run-history.tsx trigger-editor.tsx
collaboration/6CursorLayer.tsx OfflineBanner.tsx PresenceBar.tsx README.md ReviewFailedDialog.tsx StatusChip.tsx
comments/3comment-panel.tsx comment-thread.tsx mention-input.tsx
database/27
__tests__/2cell-editor.test.tsx cell-renderer.test.tsx
AiExtractDialog.tsx AiFormulaDialog.tsx AiGenerateDialog.tsx board-view.tsx buttons-panel.tsx calendar-view.tsx cell-editor.tsx cell-renderer.tsx chart-view.tsx database-page-view.tsx database-toolbar.tsx database-view-renderer.tsx db-embedded.tsx filter-builder.tsx form-builder-view.tsx formula-editor.tsx gallery-view.tsx group-builder.tsx list-view.tsx property-editor.tsx record-header.tsx record-panel.tsx sort-builder.tsx table-view.tsx timeline-view.tsx
dialogs/7
__tests__/1move-to-dialog.test.tsx
create-dialog.tsx dialog.tsx file-permissions-dialog.tsx invite-dialog.tsx move-to-dialog.tsx share-dialog.tsx
editor/19
__tests__/3code-block-text.test.ts code-block.test.tsx expandable.test.tsx
block-card.tsx block-renderer.tsx block-save-queue.ts block-toolbar.tsx context-menu.tsx expandable.tsx history-popover.tsx inline-editor.tsx insert-dialog.tsx page-canvas.tsx page-spinner.tsx page-view.tsx selection-toolbar.tsx slash-menu.tsx template-dialog.tsx type-menu.tsx
files/4FileBlock.tsx FileBrowser.tsx FilePicker.tsx PreviewModal.tsx
form/4form-field.tsx form-renderer.tsx form-responses-view.tsx form-view.tsx
layout/8
__tests__/1page-tree-icon.test.tsx
command-palette.tsx factory-logo.tsx page-tree-node.tsx sidebar.tsx theme-provider.tsx topbar.tsx workspace-shell.tsx
mentions/3
__tests__/1mention-model.test.ts
mention-model.ts MentionPopup.tsx
search/3ask-ai-search.tsx results-list.tsx search-dialog.tsx
settings/10
api-keys/4ApiKeyCreateDialog.tsx ApiKeyRevealDialog.tsx ApiKeyTable.tsx ScopesBadge.tsx
webhooks/5CurlExamples.tsx DeliveryInspector.tsx EventExplorer.tsx OverviewTab.tsx WebhooksTab.tsx
settings-shell.tsx
ui/3confirm-dialog.tsx form.tsx toaster.tsx
hooks/21
__tests__/4realtime-frame-handler.test.ts use-blocks.test.ts use-database-options.test.ts use-workspace.test.tsx
realtime-frame-handler.ts use-ai-stream.ts use-auth.ts use-blocks.ts use-database.ts use-db-ai-stream.ts use-forms.ts use-members.ts use-mention-sources.ts use-notifications.ts use-offline-status.ts use-pages.ts use-presence-announcer.ts use-presence-toasts.ts use-presence.ts use-realtime.ts use-workspace.ts
lib/50
__tests__/21ai-database.test.ts ai-generate.test.ts ai-sse.test.ts ai-writing.test.ts api-keys.test.ts api-members.test.ts auth-refresh.test.ts auth-store.test.ts batch-queue-drop.test.ts block-content.test.ts clipboard-import.test.ts code-highlight.test.ts offline-status.test.ts presence-announcer.test.ts presence-color.test.ts presence-cursor.test.ts presence-emitter.test.ts realtime-url.test.ts search.test.ts utils.test.ts writing-diff.test.ts
files/7
__tests__/2picker.test.ts upload.test.ts
api.ts picker.ts upload-store.ts upload.ts use-file-uploads.ts
ai-database.ts ai.ts api-keys.ts api.ts auth-store.ts batch-queue.ts block-content.ts clipboard-import.ts code-highlight.ts constants.ts offline-status.ts org-admin.ts presence-announcer.ts presence-color.ts presence-cursor.ts presence-editor-views.ts presence-emitter.ts query-client.ts realtime.ts search.ts types.ts utils.ts
stores/8
__tests__/1block-patch-store.test.ts
block-patch-store.ts comment-pill-store.ts database-store.ts editor-store.ts offlineQueue.ts presence-store.ts ui-store.ts
instrumentation.ts middleware.ts
.env.example Dockerfile next-env.d.ts next.config.mjs package.json postcss.config.mjs README.md tailwind.config.ts tsconfig.json tsconfig.tsbuildinfo vitest.config.ts
worker/92
src/87
__tests__/13ai-embedding.test.ts ai-request.test.ts ai-usage-budget.test.ts audit-export.test.ts email-decision.test.ts encrypt.test.ts health-search-index-lag.test.ts mime.test.ts retention.test.ts s3-sign.test.ts search-index.test.ts smtp.test.ts webhook-sign.test.ts
health/1server.ts
lib/12
__tests__/1clamav.test.ts
clamav.ts encrypt.ts job-otel.ts mime.ts notify.ts prisma.ts redis-stream.ts redis.ts s3.ts sharp.ts smtp.ts
processors/56
automations/28
__tests__/3condition-evaluator.spec.ts executors.spec.ts run-pipeline.spec.ts
executors/17assign-user.ts call-integration.ts change-status.ts classify.ts create-notification.ts create-page.ts email-cap.ts helpers.ts integration-registry.ts permission.ts record-write.ts registry.ts run-ai-action.ts send-email.ts send-webhook.ts types.ts update-record.ts
condition-evaluator.ts dead-letter.ts log.ts matcher.ts pipeline.ts run-state.ts schedule.ts watchdog.ts
files/15
__tests__/4errors.test.ts scan-gate.test.ts status.test.ts temp.test.ts
steps/4finalize.ts metadata.ts scan.ts thumbnails.ts
cleanup.ts errors.ts generateThumbs.ts processor.ts status.ts temp.ts types.ts
search-index/4builders.ts grants.ts index.ts processor.ts
ai-embedding.ts ai.processor.ts audit-export.ts automations.processor.ts email-decision.processor.ts email.processor.ts maintenance.processor.ts retention.ts webhooks.processor.ts
config.ts events.ts index.ts logger.ts queues.ts
.env.example Dockerfile package.json README.md tsconfig.json
docs/73
api/17ai.md authentication.md automations.md blocks.md comments.md databases.md files.md forms.md openapi.yaml org-admin.md pages.md scim.md search.md sso.md versioning.md webhooks.md workspaces.md
architecture/10ai-architecture.md automation.md backend-architecture.md database-architecture.md frontend-architecture.md realtime-architecture.md search-architecture.md security-architecture.md storage.md system-architecture.md
database/2database-architecture.md redis-keys.md
development/8admin-guide.md coding-standards.md deployment.md development-guide.md files-pipeline.md perf-report.md performance.md testing.md
product/4functional-requirements.md non-functional-requirements.md product-requirements.md ux-requirements.md
requirements/1requirements-matrix.md
security/5audit-report.md files.md forms.md license-allowlist.md security-architecture.md
tasks/16phase-00-foundation.md phase-01-authentication.md phase-02-workspace.md phase-03-pages.md phase-04-editor.md phase-05-databases.md phase-06-relations.md phase-07-collaboration.md phase-08-search.md phase-09-files.md phase-10-forms.md phase-11-automation.md phase-12-ai.md phase-13-api.md phase-14-enterprise.md phase-15-production.md
ui/9ai.md automations.md design-system.md developers.md files.md forms.md org-admin.md scim.md sso.md
support-matrix.md
infrastructure/71
caddy/1Caddyfile
docker/6
init/101-extensions.sql
opensearch/1init-indices.json
.env.docker docker-compose.yml opensearch-init.sh README.md
kubernetes/40
observability/20
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1sync-sources.mjs
alertmanager.yaml grafana.yaml kustomization.yaml namespace.yaml node-exporter.yaml otel-collector.yaml prometheus.yaml
postgres/7configmap.yaml cronjob-backup.yaml cronjob-opensearch-snapshot.yaml pgbouncer.yaml replica.yaml statefulset.yaml walg-iam-policy.yaml
api.yaml configmap.yaml ingress.yaml kustomization.yaml namespace.yaml networkpolicy.yaml pdb.yaml README.md realtime.yaml redis.yaml secret.example.yaml web.yaml worker.yaml
nginx/2nginx.conf README.md
observability/14
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1dashboard-completeness.mjs
README.md
terraform/8compute.tf data.tf main.tf network.tf outputs.tf README.md security.tf variables.tf
packages/289
ai/24
src/20
providers/7anthropic.ts google.ts http.ts index.ts openai-compatible.ts rag.test.ts writing.test.ts
schemas/2database.ts generate.ts
adapters.test.ts config.test.ts config.ts gateway.test.ts gateway.ts index.ts tools.test.ts tools.ts types.ts usage.test.ts usage.ts
package.json README.md tsconfig.json vitest.config.ts
auth/11
src/7index.ts passwords.test.ts passwords.ts sessions.test.ts sessions.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
collaboration/17
src/13awareness.ts codec.test.ts codec.ts index.ts lww.test.ts lww.ts mapping.ts offline.ts outbox.test.ts outbox.ts provider.test.ts provider.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
config/7
src/4eslint.base.js index.ts prettier.base.json tsconfig.base.json
package.json README.md tsconfig.json
database-client/5
src/2index.test.ts index.ts
package.json README.md tsconfig.json
database-engine/33
src/29
filter/2filter.test.ts index.ts
formula/8evaluator.ts formula.test.ts functions.ts index.ts lexer.ts parser.ts types.ts validate.ts
query/6group.ts index.ts paginate.ts pivot.ts query.test.ts sort.ts
relations/2index.ts relations.test.ts
rollups/2index.ts rollups.test.ts
values/5dates.ts index.ts registry.ts types.ts values.test.ts
views/3buttons.ts index.ts views.test.ts
index.ts
package.json README.md tsconfig.json vitest.config.ts
editor/25
src/21
marks/1highlight.ts
nodes/2equation.ts mention.ts
autosave.test.ts autosave.ts clipboard.test.ts clipboard.ts codec.test.ts codec.ts context-menu.test.ts context-menu.ts diff.test.ts diff.ts factory.ts history.test.ts history.ts index.ts positions.ts slash.test.ts slash.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
observability/20
src/16
__tests__/8db-redis-metrics.test.ts metrics.test.ts otel.test.ts pino-otel.test.ts prometheus.test.ts resource.test.ts scrubber.test.ts traceparent.test.ts
index.ts metrics.ts otel.ts pino-otel.ts prometheus.ts resource.ts scrubber.ts traceparent.ts
package.json README.md tsconfig.json vitest.config.ts
permissions/11
src/7index.ts levels.test.ts levels.ts resolve.test.ts resolve.ts rules.test.ts rules.ts
package.json README.md tsconfig.json vitest.config.ts
search/22
src/18chunking.test.ts chunking.ts client.test.ts client.ts embeddings.test.ts embeddings.ts hybrid.test.ts hybrid.ts index.ts indexer.test.ts indexer.ts mapping.test.ts mapping.ts queries.test.ts queries.ts rrf.test.ts rrf.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
shared/61
src/57
dto/17ai.dto.ts auth.dto.test.ts auth.dto.ts block.dto.ts browser-safety.test.ts comment.dto.ts database.dto.test.ts database.dto.ts file.dto.ts formula-ast.ts index.ts notification.dto.ts page.dto.ts search.dto.ts share.dto.ts webhook.dto.ts workspace.dto.ts
events/8audit-catalog.test.ts audit-catalog.ts audit-csv.test.ts audit-csv.ts audit-scope.test.ts audit-scope.ts catalog.test.ts catalog.ts
realtime/6comment-events.ts frames.test.ts frames.ts notification-policy.ts notification-prefs.ts task-0094.test.ts
automation.test.ts automation.ts constants.ts crypto.test.ts crypto.ts env.ts errors.ts events.test.ts events.ts forms.test.ts forms.ts index.ts markdown.test.ts markdown.ts mentions.test.ts mentions.ts net.test.ts net.ts positions.test.ts positions.ts presence.test.ts presence.ts security-headers.ts webhook-delivery.test.ts webhook-delivery.ts webhook-events.ts
package.json README.md tsconfig.json vitest.config.ts
storage/16
src/12client.test.ts client.ts index.ts keys.test.ts keys.ts mime.test.ts mime.ts presign.test.ts presign.ts thumbnails.test.ts thumbnails.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
ui/37
src/33
components/19avatar.tsx badge.tsx button.tsx checkbox.tsx command.tsx context-menu.tsx dialog.tsx dropdown-menu.tsx empty-state.tsx index.ts popover.tsx separator.tsx skeleton.tsx slider.tsx spinner.tsx switch.tsx tabs.tsx toast.tsx tooltip.tsx
color.ts command-filter.test.ts command-filter.ts focus.test.ts focus.ts formatters.test.ts formatters.ts index.ts theme.test.ts theme.ts toast-store.test.ts toast-store.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
prisma/16
migrations/13
20260927000000_init/1migration.sql
20260928000000_search_documents_content_hash/1migration.sql
20260929000000_workspace_usage_storage_bytes/1migration.sql
20260930000000_form_submitted_notification_type/1migration.sql
20261001000000_ai_phase12/1migration.sql
20261002000000_webhooks_secret_encrypted_dlq/1migration.sql
20261002111436_webhook_delivery_latency/1migration.sql
20261002120000_org_admin_2fa_retention_unique/1migration.sql
20261002215124_audit_export/1migration.sql
20261003210000_perf_indexes_hot_paths/1migration.sql
20261004004500_perf_indexes_hot_paths_2/1migration.sql
migration_lock.toml README.md
schema.prisma seed.ts verify.ts
scripts/7backup-health.ts docs-completeness.mjs generate-openapi.mjs minio-init.ts opensearch-reindex.ts storage-orphans.ts verify-clean-clone.sh
tests/169
e2e/63
.auth/1user.json
fixtures/7
files/6clip.mp4 clip.wav clip.webm hero-1400.png note.docx note.txt
realtime.ts
mocks/2oidc-issuer.ts saml-idp.ts
playwright-report/3
data/2a6557bae9c2714a658968d4eb8534195c08ebda8.png e3092d07d00a16063e46385c02a559a9054fa847.webm
index.html
reports/1files-security.json
specs/34ai-database.spec.ts ai-generate.spec.ts ai-rag.spec.ts ai-security.spec.ts ai-writing.spec.ts ai.spec.ts api-keys.spec.ts appearance-theme.spec.ts auth.spec.ts automation.spec.ts collab-two-tab.spec.ts comments.spec.ts databases.spec.ts default-workspace-alias.spec.ts developers.spec.ts editor.spec.ts files.spec.ts final-flow.spec.ts forms.spec.ts oauth.spec.ts org-admin.spec.ts pages.spec.ts performance.spec.ts presence-cursors.spec.ts qa-paste-toggle.spec.ts realtime.spec.ts scim.spec.ts search-index-lag.spec.ts search.spec.ts sharing.spec.ts sso.spec.ts task-0094-comments-notifications.spec.ts webhooks.spec.ts workspace.spec.ts
test-results-final-flow/3
final-flow-Release-v1-0-fi-6c06b-w-state-carries-sequential--chromium-desktop/2test-failed-1.png trace.zip
.last-run.json
utils/4api.ts dnd.ts search-lag.ts wait.ts
.gitignore fixtures.ts global-setup.ts package.json playwright.config.ts playwright.final-flow.config.ts README.md tsconfig.json
fixtures/1
files/1make-sparse.ts
integration/60
ai-capability/2ai-app.capability.test.ts gateway.capability.test.ts
drill/2seed-drill.mjs verify-restore.mjs
fixtures/5
files/5clip.mp4 clip.wav doc.pdf hero-1400.png note.txt
helpers/3api.ts containers.ts seed.ts
realtime/11control-channel.test.ts convergence.test.ts flush-rebase-http.test.ts handshake.test.ts heartbeat-limits.test.ts lifecycle.test.ts presence.test.ts rebase.test.ts resume.test.ts rt-helpers.ts task-0094-fanout.test.ts
ai-chat.integration.test.ts ai-database.integration.test.ts ai-generate.integration.test.ts ai-rag.integration.test.ts ai-writing.integration.test.ts api-keys.integration.test.ts audit-completeness.integration.test.ts audit-export-s3.integration.test.ts auth.integration.test.ts automation-pipeline.integration.test.ts automations.integration.test.ts blocks.integration.test.ts cache-invalidation.integration.test.ts comments.integration.test.ts databases.integration.test.ts files-lifecycle.integration.test.ts files-pipeline.integration.test.ts files-security.integration.test.ts files.integration.test.ts forms-responses.integration.test.ts forms.integration.test.ts oauth.integration.test.ts openapi.contract.test.ts org-admin.integration.test.ts package.json pages.integration.test.ts permissions.integration.test.ts README.md replica-routing.integration.test.ts retention-engine.integration.test.ts scim.integration.test.ts search.integration.test.ts sso.integration.test.ts tsconfig.json vitest.config.ts webhooks.integration.test.ts workspace.integration.test.ts
performance/28
fixtures/1search-100k.ts
helpers/2seed-100k.mjs seed-1k-load.mjs
reports/7api-load-smoke.json database-load-smoke.json editor-local.json realtime-load.json search-full.json search-last.json search-reduced.json
api-load.js api.js archive.tar database-load.js database.js editor-local.mjs extract-100k-env.mjs package.json README.md realtime-1k.js realtime-load.js realtime.js run-realtime-load.sh search-search.k6.js search-summary.json seed-100k.mjs threshold-breach-demo.js verify-zero-op-loss.mjs
security/16
helpers/1app.ts
audit-report.test.ts auth-paths.test.ts deps.test.ts formula-fuzz.test.ts gitleaks-config.test.ts headers.test.ts images.test.ts oracle.ts package.json permission-fuzz.test.ts rate-limits.test.ts README.md tsconfig.json upload-abuse.test.ts vitest.config.ts
README.md
.dockerignore .env .env.bak-task0125 .env.example .eslintrc.json .gitignore .npmrc .prettierignore .prettierrc.json AGENTS.md ARCHITECTURE.md CHANGELOG.md CONTRIBUTING.md gitleaks.toml LICENSE package.json pnpm-lock.yaml pnpm-workspace.yaml PRODUCT-SPEC.md README.md ROADMAP.md SECURITY.md tsconfig.json turbo.json
page.service.tsapps/api/src/modules/pages/page.service.tsraw← pages71,511 B← Back to the app
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  // T-144: content-type + review-state surface (whiteboard/task pages,
29  // review workflow). Legacy rows read as page/published.
30  kind: true, reviewState: true,
31  // T-149: move guards (a deleted page cannot be moved).
32  deletedAt: true
33} as const;
34type PageRow = Prisma.PageGetPayload<{ select: typeof PAGE_SELECT }>;
35 
36/**
37 * Page service: tree CRUD, trash/restore, versions, favorites, templates.
38 * A "database" page also creates the Database row + default properties/views
39 * (seed kept here to avoid a circular dependency with the databases module —
40 * the databases module owns all database *query* logic).
41 */
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  // ---------- lookups ----------
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  /**
66   * Last-resort re-spacing for a page sibling list that exhausted its
67   * fractional headroom (the "position too close to the top" RangeError the
68   * old midpoint-to-top append rule produced after ~127 appends). Rewrites
69   * every live sibling to an even grid inside `tx`; the caller then retries
70   * the position computation. Mirrors the blocks service'
71   * `rebalanceSiblingList` (pages have no per-tx client here, so it takes
72   * the root client).
73   */
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  /** Append-position for a page sibling list, with in-tx rebalance on exhaustion. */
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  // ---------- list / create ----------
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    // --- favorites: the caller's PageFavorite rows (Phase 3 PR-030, ordered
125    // by the favorite's fractional position — currently all "a0" since the
126    // reorder API is not built yet, so `(position, pageId)` is the composite
127    // order + cursor key), joined to live page rows. `deletedAt: null` keeps
128    // pages trashed AFTER being starred (the page's trashedAt shows the state).
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    // --- trash: pages with trashedAt set (deletedAt null — purge hard-deletes
165    // after the retention window, so those are gone). The docs gate this
166    // Admin/Owner-only (docs/api/pages.md §3), so a GUEST/VIEW-level member
167    // gets 403 even though the trash is their own workspace.
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      // List the TRASHED ROOTS only: a page is in the trash view when its own
183      // trashedAt is set and its parent is not (a trashed subtree shows once,
184      // at its root — restore(root) recursively un-trashes the whole set).
185      // `parentId` is intentionally ignored (a trashed root can sit under any
186      // parent). Ordering is `(position, id)` — position is only unique WITHIN
187      // a sibling set, so across parents the id is a stable tie-breaker and
188      // the cursor predicate is composite (no row skipping on dup positions).
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      // Denormalized (spec TASK-0035: "grandparentId maintained by the service
256      // (parent's parentId) on create/move") — the parent's own parent.
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    // T-167 (A-001): a whiteboard page carries its canvas in the 1:1
265    // `whiteboards` sidecar — set the kind at creation (legacy pages default
266    // to "page" and are unaffected).
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    // Default child blocks: title block + (for databases) a database block.
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        // The sidecar starts as an empty, version-stamped canvas.
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      // TASK-0133 audit catalog: creating a database page ALSO fires
344      // `database.created` (catalog requires `title`) — alongside the
345      // existing `page.created` row (a database is both a page AND a database).
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  /** Map pageId → the seeded Database row's id (the Database row has its OWN
374   * cuid; `create` already returns it for new pages — list endpoints attach
375   * it so clients can tell a database page from a plain page). Pages without
376   * a database row are simply absent from the map. */
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  /** Seed Title + Status(select) + default table view into a fresh database. */
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  // ---------- get / update ----------
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    // Unchecked (scalar) variant: parentId/teamId are scalars on Page (the
439    // relation-variant PageUpdateInput only offers `parent`/`team` writes).
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        // Keep the denormalized grandparent in sync on reparent (TASK-0035).
460        grandparentId = parent.parentId;
461        // reject cycles: new parent must not be a descendant of the page
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          // Explicit annotation: `row` reads `cursorId` (its where) while
469          // `cursorId` is reassigned from `row?.parentId`, a type cycle that
470          // otherwise forces an implicit-`any` self-reference (TS7022).
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    // Keep the title block text in sync.
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    // T-157: watched-page fan-out (canView recheck inside). Awaited so the
500    // watcher's notification is durable before the response returns (deterministic),
501    // but wrapped so it can never fail the triggering request (best-effort).
502    try { await this.notifications.notifyWatchersForPageUpdate(pageId, userId); } catch { /* best-effort */ }
503    return updated;
504  }
505 
506  // ---------- T-167: whiteboard canvas ----------
507 
508  private async assertWhiteboardPage(pageId: string): Promise<PageRow> {
509    const page = await this.getPage(pageId);
510    if (page.kind !== "whiteboard") throw ApiError.notFound(); // no-leak: a non-whiteboard page has no canvas
511    return page;
512  }
513 
514  /** Read the whiteboard canvas (VIEW). Unknown/non-whiteboard page → 404. */
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      // The web renderer shows a read-only banner when the stored document
526      // cannot be validated (unknown version / corrupted) — still return it.
527      valid: parsed.ok,
528      updatedAt: wb.updatedAt,
529      updatedBy: wb.updatedBy
530    };
531  }
532 
533  /** Save the whiteboard canvas (EDIT). Body: the full v1 canvas document. */
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    // Keep the page's own version counter in step (content changed).
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  /**
565   * T-171: convert a whiteboard SELECTION into a real page (or a task page,
566   * A-006). The canvas is re-read server-side (the caller supplies ids only —
567   * never contents); text-bearing elements (stickies, text) become blocks in
568   * selection order, everything else is skipped. Ids the canvas does not
569   * know are dropped (a selection can go stale), so the only hard errors are
570   * authz (403/404) and "nothing to convert" (400 — a page of zero blocks is
571   * meaningless).
572   */
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(); // no-leak
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    // One block per LINE (a sticky note with two lines is two paragraphs of
585    // documentation; empty lines are dropped, selection order preserved).
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      // One block per line (a sticky with wrapped/entered lines keeps its
640      // line breaks; fractional positions extend from "A").
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  // ---------- trash / restore ----------
679 
680  /** Move to trash (recursive: all descendants too). */
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    // TASK-0133 audit catalog: the trash action is `page.deleted` (the
688    // catalog's deletion event) with the CATALOG-required `pageIds` (the
689    // whole recursive set — root + descendants).
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  /** Restore from trash (page + trashed descendants). */
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  /** Permanent delete (trash only; owner/admin) — pages, blocks, records. */
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  // ---------- versions ----------
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  /** Snapshot the current state as a new version. */
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    // T-170: a whiteboard page's content lives in the sidecar, not blocks —
795    // snapshot it too so "restore" can bring the canvas back.
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  /** T-151: read-only version preview — the full snapshot at `versionId`. */
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  /** T-150: compare two versions of the same page (keyed by block id). */
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  /** Restore a page version (page fields + block contents). */
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      // T-170: restore the whiteboard canvas (validated before write — a
865      // snapshot from a newer/older schema must not corrupt the board).
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    // T-157: watched-page fan-out.
886    try { await this.notifications.notifyWatchersForPageUpdate(pageId, userId); } catch { /* best-effort */ }
887    return { restored: true, version: version.version };
888  }
889 
890  // ---------- T-157: watchlist ----------
891 
892  /** Watch a page (idempotent). VIEW is required (you can only watch what
893   *  you can open). */
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  // ---------- favorites ----------
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  // ---------- templates ----------
949 
950  /** Snapshot a page subtree into a template (T-141: +category/description/variables). */
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    // T-145: the first version snapshot is captured at creation.
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  /** T-141: directory listing with category/search (additive filters). */
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  // ---------- T-143/T-145: built-in guard + version history ----------
1010 
1011  /** Built-in templates are seeded, not user-editable (A-005): the only write
1012   *  path that touches them is admin versioning; edits/deletes 403. */
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  /** T-145: snapshot the template's current state as a new version. */
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  /**
1079   * T-141 (read-only preview): return the template's CURRENT snapshot
1080   * (the frozen `{ page, blocks, children }` captured at save time).
1081   *
1082   * ID-scoped (no workspace header) on purpose: `listTemplates` is a
1083   * CROSS-WORKSPACE union and its rows carry no `workspaceId`, so a
1084   * workspace-scoped lookup 404s for any union row. Authorization is
1085   * membership in the template's OWN workspace (any membership ≥ VIEW —
1086   * the lowest level); a non-member gets NOT_FOUND (no cross-tenant
1087   * existence leak — same shape as the bulk-actions external-id rule).
1088   */
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  /** T-145: restore a version — writes it back as the CURRENT state AND
1098   *  records a new version (history is never overwritten). */
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  /** Instantiate a template: deep-copy the subtree under parentId (T-142: optional `values` for variable templates). */
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    // Loose block type: `substituteSnapshot` (T-142) re-shapes the snapshot
1125    // and the block fields are only read loosely below (content/position).
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    // T-142: template variables — resolve + substitute before instantiating.
1134    // (The snapshot is always a plain object, so re-assigning is safe.)
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      // substituteSnapshot re-shapes the same snapshot (tokens → values);
1142      // the cast is safe — it preserves every block field we read below.
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  // ---------- T-180: content export (Phase 8) ----------
1235 
1236  /**
1237   * `GET /pages/:id/export/markdown?includeChildren=1` — render a page (and
1238   * its sub-pages, opt-in) to Markdown for download.
1239   *
1240   * Authorization: **VIEW** on the page (the same level `get` uses) — a
1241   * member of the workspace (or a page-level grant holder) can export exactly
1242   * what they can read; a restricted page (explicit NONE page grant) 403s.
1243   * This is a SENSITIVE operation (content leaves the system), so per the
1244   * audit rules (spec §16) an audit event is written on success —
1245   * `page.exported` with the byte count + whether children were included.
1246   */
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  // ---------- T-138: duplicate ----------
1276 
1277  /**
1278   * Recursive duplicate of a page subtree. New page/block ids, positions
1279   * re-based into the target sibling list. Database pages are duplicated by
1280   * snapshot: the Database row + properties + views are copied (records are
1281   * NOT copied — a copy is a fresh database; live relation links are
1282   * severed by design, matching the documented behavior). Comments,
1283   * shares, favorites, and versions are intentionally NOT copied.
1284   */
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        // Copy the block tree. Fractional positions order SIBLINGS only, so a
1315        // flat `position` sort does NOT guarantee parent-before-child —
1316        // process in passes until every parent is mapped (the tree is acyclic
1317        // by construction, so this terminates).
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        // `remaining` is only non-empty if the source tree was corrupt (cycle);
1349        // the live source cannot be cyclic (move guard), so this is a no-op.
1350        void remaining;
1351        // Database snapshot: copy the Database row + properties + views.
1352        // (pageId is a plain ref, not unique — findFirst, mirroring page.service get())
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  // ---------- T-149: cross-workspace move ----------
1425 
1426  /**
1427   * Move a page (with its subtree) to another workspace. Requires EDIT on
1428   * the SOURCE page AND EDIT in the DESTINATION workspace (create rights) —
1429   * both checked server-side (SECURITY_REVIEW §2). Positions are re-based
1430   * into the destination sibling list; the search index is refreshed via the
1431   * domain event (the indexer re-stamps workspace + grant snapshot).
1432   */
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    // Re-base only the MOVED ROOT into the new sibling list; descendants keep
1460    // their relative positions (they are siblings among themselves, unchanged).
1461    await this.prisma.page.update({ where: { id: pageId }, data: { parentId: toParentId, position } });
1462    // Blocks/records/files carry workspaceId too — re-stamp them so the
1463    // tenant scope stays consistent (the page cascade keeps them attached).
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  // ---------- T-139: bulk actions ----------
1486 
1487  /**
1488   * Bulk page actions with per-item permission checks and partial-failure
1489   * reporting (spec §4: "Bulk actions with confirmation and partial-failure
1490   * handling"). `ids` are all checked in one workspace membership query
1491   * context; each item that fails carries a stable code.
1492   */
1493  async bulk(
1494    workspaceId: string,
1495    userId: string,
1496    input: { action: "trash" | "archive" | "unarchive" | "favorite" | "unfavorite"; ids: string[] },
1497    req: any
1498  ) {
1499    // Deduplicate while preserving input order.
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            // Reuse the single-page path (permission + recursion + audit).
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  // ---------- T-140: backlinks ----------
1554 
1555  /**
1556   * Backlinks for a page: the pages that reference it — via `child_page`
1557   * blocks, `synced_block` (source is the referenced page's root block is
1558   * out of scope; we index pages whose block embeds this page),
1559   * `linked_database` (this page's database), and comment `pageMentionIds`.
1560   * Every source is filtered by the requester's VIEW level (fail-closed:
1561   * a source the requester cannot see is excluded — no existence leak).
1562   */
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    // 1. child_page blocks embedding this page.
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    // 2. linked_database blocks pointing at this page's database.
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    // 3. Comments mentioning this page (pageMentionIds is a plain string[]).
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); // a page is not its own backlink
1595 
1596    // 4. Resolve + permission-filter the sources (fail-closed).
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