/** * Frontend domain types for @factory/web. * * Shapes mirror the API DTOs validated by `@factory/shared` (zod). The shared * package is source-first and its DTO module is still in flight, so we define * structural aliases here. When `@factory/shared/dto` lands, these should be * re-exported from it — keep field names identical to the zod DTOs. */ import type { Position } from "@factory/shared/positions"; // Re-exported so web modules can `import type { Position } from "@/lib/types"` // (the import was declared-but-not-exported, which broke `lib/api.ts` — fixed // 2026-10-12 with the versions panel). export type { Position }; import type { BlockChangedFrame } from "@factory/shared/realtime/frames"; /* ------------------------------------------------------------------ users */ export interface User { id: string; name: string; email: string; image?: string | null; createdAt: string; } /* ------------------------------------------------------------- workspaces */ export type WorkspaceRole = "owner" | "admin" | "member" | "guest"; export interface Workspace { id: string; name: string; slug: string; emoji?: string | null; plan: "free" | "pro" | "enterprise"; role: WorkspaceRole; memberCount: number; createdAt: string; /** T-136: workspace type (team | project | kb | custom). */ type?: string; /** T-136: set when the workspace is archived. */ archivedAt?: string | null; } /** T-158 — an activity-feed row (a permission-scoped `audit_logs` projection; * `actorName` is resolved server-side for USER actors). Shared by the user * feed (GET /users/me/activity) and the space feed (GET /workspaces/:id/activity). */ export interface ActivityRow { id: string; actorType: string; actorId: string; action: string; resourceType: string; resourceId: string; workspaceId: string; pageId: string | null; createdAt: string; actorName?: string | null; } /** T-137: space usage stats (`GET /workspaces/:id/stats`). */ export interface WorkspaceStats { workspaceId: string; type: string; archivedAt: string | null; content: { pages: number; whiteboards: number; tasks: number; databases: number; records: number; comments: number; members: number; storageBytes: number; }; } export interface WorkspaceMember { userId: string; user: Pick; role: WorkspaceRole; joinedAt: string; } /** A teamspace (the mention popup's Teams group — TASK-0094 R3). */ export interface Team { id: string; name: string; slug: string; memberCount: number; } export interface WorkspaceInvite { id: string; workspaceId: string; email: string; role: WorkspaceRole; token: string; expiresAt: string; acceptedAt: string | null; createdAt: string; } /* ------------------------------------------------------------------ pages */ /** Page kinds: regular pages, database container pages, and trashed pages. */ export type PageType = "page" | "database" | "trash"; export interface Page { id: string; workspaceId: string; /** null = top level of the workspace. */ parentId: string | null; title: string; icon?: string | null; cover?: string | null; type: PageType; /** Fractional ordering among siblings (see @factory/shared/positions). */ position: Position; favorite: boolean; createdBy: string; createdByUser?: Pick | null; updatedAt: string; createdAt: string; trashedAt: string | null; /** * The seeded Database row's id, present on list endpoints. Non-null ⇔ this * page is a database (the Page model has no `type` field — a database is a * page that has a Database row; recorded conflict with the core `Page.type` * field, which the API never emits). */ databaseId?: string | null; /** * T-144/T-167 — the page's content kind. `"page"` (default, legacy rows), * `"whiteboard"` (owns a `whiteboards` sidecar — the canvas surface), * `"task"` (A-006). The API's `GET /pages/:id` and list endpoints both emit * it (`PAGE_SELECT`), so the page route branches the renderer on `kind`. */ kind?: "page" | "whiteboard" | "task"; } /** * T-164 — a first-class task (A-006). A task is a `kind="task"` page + a 1:1 * `tasks` sidecar; the sidecar fields (status/dueAt/assigneeIds) are the * queryable ones, the page carries the blocks/comments. `pageId` links the * two (the inbox list endpoint attaches the page + its title). */ export interface Task { id: string; workspaceId: string; pageId: string; title: string; status: "todo" | "in_progress" | "done" | "cancelled"; dueAt: string | null; assigneeIds: string[]; createdBy: string; createdAt: string; updatedAt: string; /** Attached by `taskApi.list` (the task page's live title/state). */ page?: { id: string; title: string; trashedAt: string | null; reviewState: string; parentId: string | null }; pageTitle?: string; } /** * T-163 — the page review workflow (A-003: explicit reviewer lists). `state` * is the page's `reviewState`; `review` is the in-flight Review row (null when * none) — the wire field is `review` (review.service `reviewState` returns * `{ state, review, lastReview }`). The web type previously named it `open`, * which made the in_review banner branch dead code (fixed 2026-10-12 with the * tasks/reviews E2E). `lastReview` lets the editor resubmit to the same * reviewers in one click after `changes_requested`. */ export interface PageReview { state: "draft" | "in_review" | "approved" | "published" | "changes_requested"; review: { id: string; state: string; reviewerIds: string[]; requestedBy: string; comment: string | null; deadline: string | null; openedAt: string; } | null; lastReview: { id: string; reviewerIds: string[]; decision: string | null; comment: string | null } | null; } export interface PageTreeEntry { id: string; parentId: string | null; title: string; icon?: string | null; type: PageType; position: Position; /** Present when the server pre-loads direct children. */ children?: PageTreeEntry[]; /** Server hint: true when more children exist than were pre-loaded. */ hasMore?: boolean; updatedAt: string; } /** * A workspace template (the Template model) — a frozen page/database subtree * the user can duplicate (`templateApi.apply`). NOT a Page: it has `name` * (no `title`), an optional `icon`, and the kind (`"page" | "database" | * "workspace" | "team"`). Builtin templates are seeded read-only rows. */ export interface TemplateInfo { id: string; name: string; icon: string | null; type: string; isBuiltin: boolean; /** `{ type, id }` of the source (null for builtin). */ sourceRef: unknown; createdAt: string; /** T-141: directory category (e.g. "meetings", "engineering"). */ category?: string | null; /** T-141: human description shown in the directory. */ description?: string | null; /** T-142: typed variables the template expects on apply. */ variables?: unknown; /** T-141: default-for page type (nullable). */ defaultFor?: string | null; } /** T-145: a snapshot version of a template (append-only history). */ export interface TemplateVersionInfo { id: string; version: number; note: string | null; createdBy: string; createdAt: string; } /** * `GET /pages/:id/versions` row (page.service `listVersions` select). The * wire shape is `{ id, version, note, createdBy, createdAt }` — there is NO * title/blockCount/source on the row (an earlier draft of this interface * declared those and the history popover rendered "undefined"; fixed * 2026-10-12 when the compare/preview panel was built). */ export interface PageVersion { id: string; /** Monotonic 1-based sequence number — the compare endpoint's `from`/`to`. */ version: number; /** Snapshot note (POST /versions `{ note }`), or null. */ note: string | null; createdBy: string; createdAt: string; } /** `GET /pages/:id/versions/:versionId` — a read-only snapshot (T-151). */ export interface VersionPreview { version: number; createdAt: string; note: string | null; snapshot: { page: Record; blocks: Array<{ id: string; type: string; content: unknown; properties: Record | null; parentId: string | null; position: string; }>; }; } /** * `GET /pages/:id/versions/compare?from&to` (T-150). `from`/`to` are the * version NUMBERS (not ids). Blocks are keyed by id across the snapshots; * `modified` lists WHICH fields changed, not the new values (the diff is a * summary, not a patch). */ export interface VersionDiff { from: number; to: number; page: Record; added: Array<{ id: string; type: string; content: unknown }>; deleted: Array<{ id: string; type: string; content: unknown }>; modified: Array<{ id: string; fields: string[] }>; moved: Array<{ id: string; from: string; to: string }>; summary: { added: number; deleted: number; modified: number; moved: number; unchanged: number; }; } export interface PageShare { id: string; pageId: string; slug: string; url: string; level: "view" | "edit" | "public"; password?: string | null; expiresAt: string | null; createdAt: string; updatedAt: string; } /* ----------------------------------------------------------------- blocks */ /** * The 30 block types the wire protocol uses (docs/api/blocks.md §3, * mirroring the Prisma BlockType enum in lowercase). Text-ish types carry * their content in `content`; structural types in `properties`. */ export const BLOCK_TYPES = [ "paragraph", "heading_1", "heading_2", "heading_3", "quote", "callout", "bulleted_list", "numbered_list", "todo", "toggle", "image", "video", "audio", "file", "bookmark", "embed", "code", "equation", "column", "column_list", "divider", "spacer", "database", "linked_database", "table", "chart", "form", "synced_block", "child_page", "child_database", "toc", ] as const; export type BlockType = (typeof BLOCK_TYPES)[number]; export interface BlockTableColumn { id: string; type: "text" | "number"; width: number; /** Display header; falls back to `id`. */ label?: string; } export interface BlockProperties { /** The page's title block (the server seeds it on page create; `content.text` * mirrors `page.title`). Excluded from the editor's content list. */ isTitle?: boolean; /** paragraph / headings / quote: alignment. */ align?: string; /** code: language id. */ language?: string; /** code: raw source (when not carried in content). */ code?: string; /** code: show line numbers / wrap. */ showLineNumbers?: boolean; wrap?: boolean; /** media (image/video/audio/file): file id (docs/api/blocks.md §3). */ fileId?: string; /** media / embed / bookmark: direct URL. */ src?: string; url?: string; caption?: string; name?: string; size?: number; mimeType?: string; title?: string; description?: string; /** quote: citation line. */ citation?: string; /** callout: emoji + colors. */ emoji?: string; backgroundColor?: string; textColor?: string; icon?: string; color?: string; /** callout: visual variant (info | warning | tip | error, T-148). */ variant?: string; /** todo. */ checked?: boolean; /** toggle. */ collapsed?: boolean; /** numbered_list: list start index. */ start?: number; /** table: rows of cells (rectangular string grid), header row, column widths. */ columns?: BlockTableColumn[]; rows?: string[][]; headerRow?: boolean; columnWidths?: number[]; /** image / video: width percentage (10–100). */ widthPct?: number; /** file block (image kind): resize width, 25–100 (TASK-0103). Distinct from * `widthPct` (the image/video block type's 10–100 field) — the Phase-4 * block-schema extension the Phase-9 spec names `widthPercent`. */ widthPercent?: number; /** image: display mode; embed / spacer: heightPx (120–800 / 8–200). */ display?: "fullPage" | "pageWidth" | "auto"; heightPx?: number; /** equation: LaTeX source. */ latex?: string; /** column: width percentage (20–100); column_list. */ background?: string; padding?: string; /** synced_block. */ sourceSynced?: boolean; targetBlockId?: string; targetPageId?: string; /** child_page. */ pageId?: string; /** database / linked_database / child_database. */ databaseId?: string; viewId?: string; /** chart / form. */ chartId?: string; formId?: string; } export interface Block { id: string; pageId: string; /** null = direct child of the page (top-level block). */ parentId: string | null; type: BlockType; position: Position; /** Canonical block JSON (Tiptap doc / plain object), not raw HTML. */ content: unknown; properties: BlockProperties; updatedAt: string; createdAt: string; } export type BlockPatch = Partial< Pick >; /* --------------------------------------------------------------- database */ export const PROPERTY_TYPES = [ "title", "text", "rich_text", "number", "select", "multi_select", "status", "date", "date_range", "person", "email", "phone", "url", "checkbox", "files", "created_time", "created_by", "last_edited_time", "last_edited_by", "relation", "rollup", "formula", "button", "unique_id", ] as const; export type PropertyType = (typeof PROPERTY_TYPES)[number]; export const VIEW_TYPES = [ "table", "board", "calendar", "timeline", "list", "gallery", "chart", "form", "dashboard", ] as const; export type ViewType = (typeof VIEW_TYPES)[number]; export interface DatabaseProperty { id: string; databaseId: string; name: string; type: PropertyType; position: Position; /** type-specific config: options, formula source, relation target… */ config?: Record; } export interface DatabaseView { id: string; databaseId: string; name: string; type: ViewType; position: Position; /** filter/sort/group/visibility settings (JSONB on the server). */ config?: Record; } export interface Database { id: string; workspaceId: string; pageId: string; title: string; properties: DatabaseProperty[]; views: DatabaseView[]; icon?: string | null; createdAt: string; updatedAt: string; } export type RecordValue = string | number | boolean | string[] | null; export interface DatabaseRecord { id: string; databaseId: string; pageId: string | null; values: Record; createdAt: string; updatedAt: string; } /* -------------------------------------------------------- comments/files */ export interface Comment { id: string; blockId: string | null; pageId: string; parentId: string | null; body: string; authorId: string; author?: Pick | null; resolved: boolean; createdAt: string; updatedAt: string; } export interface FileMeta { id: string; workspaceId: string; name: string; mime: string; size: number; /** * Public URL (signed or persistent, per storage pipeline). Absent when the * file is not viewable — a `VIRUS_DETECTED` (quarantined) file has no * signed URL (docs/api/files.md §8.5); callers must check `status === * "ready"` before using it. */ url?: string; status: "processing" | "ready" | "error"; createdAt: string; } /* ------------------------------------------------------- search & notify */ /** * The 7 searchable doc types (TASK-0098, `factory-content` index). Mirrors * the Phase-8 API contract — one ResultItem shape for every search route. */ export type SearchType = | "page" | "block" | "database" | "record" | "comment" | "file" | "user"; /** * One search hit exactly as the Phase-8 search routes return it: * `POST /search`, `GET /search/query`, `GET /search/suggest` (the last two * return a BARE array of these). `snippet` is plain text (the API strips the * OpenSearch `` highlight before responding — never render it as HTML). */ export interface SearchResult { docId: string; type: SearchType; id: string; title: string; snippet?: string | null; url?: string | null; icon?: string | null; createdByName?: string | null; updatedAt: string; pageId?: string | null; blockId?: string | null; databaseId?: string | null; recordId?: string | null; workspaceId: string; score: number; source: string; /** * Web-only convenience (NOT on the wire): a breadcrumb the command palette * renders as the row hint. Filled client-side from pageId/databaseId by the * palette's adapter — the API never sends `path`. */ path?: string[]; } /** `POST /search` response envelope (the paged full-text search). */ export interface SearchQueryResult { data: SearchResult[]; nextCursor: string | null; tookMs: number; } /** A live suggestion row (`GET /search/suggest` — bare array). */ export interface SearchSuggestion { id: string; type: SearchType; title: string; } /** `POST /search` request body (the web's subset — the API supports more). */ /** T-140: a permission-filtered backlink row (`GET /pages/:id/backlinks`). */ export interface BacklinkRef { pageId: string; title: string; icon: string | null; kind: string; } export interface SearchQueryInput { q: string; types?: SearchType[]; workspaceId?: string; teamId?: string; createdBy?: string; updatedAfter?: string; updatedBefore?: string; /** T-155: result ordering (undefined/"relevance" = the default `_score` path). */ sortBy?: "relevance" | "createdAt" | "updatedAt"; cursor?: string; /** 1..50 (API cap `LIMITS.MAX_SEARCH_LIMIT`). */ limit?: number; } /** * The deep link on a notification row (the API stores `{type, id}` — the * target resource — plus an optional `pageId` ride-along so the web can open * a comment/record link's page without a comment→page lookup endpoint; * TASK-0094 R5, recorded). */ export interface NotificationLink { type: "page" | "database" | "record" | "comment" | (string & {}); id: string; pageId?: string | null; } /** * A notification row exactly as `GET /notifications` returns it (the API's * `NOTIF_SELECT` + the batch-resolved `actor` — TASK-0094 R5). `read` is the * client-side view of `readAt === null`; the wire has no boolean. */ export interface Notification { id: string; workspaceId: string; /** The 8-value `NotificationType` enum (MENTION, COMMENT, …). */ type: | "MENTION" | "COMMENT" | "ASSIGNMENT" | "PAGE_SHARED" | "TASK_ASSIGNED" | "DATABASE_CHANGE" | "AUTOMATION" | "INTEGRATION"; /** The delivery channel (IN_APP always; EMAIL/WEBHOOK are the fan-out * surfaces — see the shared `notification-policy`). */ channel: "IN_APP" | "EMAIL" | "WEBHOOK"; title: string; body?: string | null; link?: NotificationLink | null; /** Unread = `readAt` is null. */ readAt: string | null; /** The acting user, batch-resolved by the API (null = unknown/deleted). */ actor?: { id: string; name: string; avatarUrl: string | null } | null; createdAt: string; } /** Unread rows (the bell count + the "Unread" inbox section). */ export type UnreadNotification = Notification & { readAt: null }; /* ----------------------------------------------------------------- misc */ /** * One peer's presence state, as the SERVER broadcasts it (TASK-0093, the * per-peer `presence` frame — `packages/shared/realtime/frames.ts` * `PresenceBroadcast` is the source of truth for the wire; this is the * web's consumer mirror). The server stamps `user` (the client never * supplies identity); a room over the 50-peer cap sends avatars-only * (`cursor: null`, no `viewing`). */ export interface Presence { userId: string; name: string; color: string; /** The peer's cursor (null = cleared / hidden / avatars-only cap). */ cursor?: { blockId: string; offset: number; selection?: PresenceSelection } | null; /** Whether the peer is actively viewing (false = background tab — the * cursor hides, the avatar stays). Absent in avatars-only mode. */ viewing?: boolean; /** Server epoch ms (the client's prune basis). */ lastSeen: number; } /** A multi-position selection inside the peer's page (doc §5.1). */ export interface PresenceSelection { fromBlockId: string; fromOffset: number; toBlockId: string; toOffset: number; } /** * The client's own presence state (sent in `presence` frames — the wire * `PresenceFrame` carries ONLY these two fields; identity is * server-stamped from the handshake). */ export interface OwnPresence { cursor?: { blockId: string; offset: number; selection?: PresenceSelection } | null; viewing?: boolean; } /** * Server→client realtime frames (see lib/realtime.ts for the full * protocol). The per-peer `presence` frame carries ONE peer's state * (TASK-0093) — the page's roster is the fold of every `presence` frame * seen since subscribing, minus the `presence-leave` frames. */ export type RealtimeServerEvent = | { type: "presence"; pageId: string; user: { id: string; name: string; color: string }; cursor?: { blockId: string; offset: number; selection?: PresenceSelection } | null; viewing?: boolean; lastSeen: number; } | { type: "presence-leave"; pageId: string; userId: string } | { type: "block:upsert"; pageId: string; blocks: Block[] } | { type: "block:delete"; pageId: string; blockIds: string[] } | { type: "block:move"; pageId: string; blockId: string; parentId: string | null; position: Position; } /** Page-block change refetch-hint (TASK-0092 (b)): the page's blocks * changed (a REST write or a flush) → refetch. The shape is the shared * `BlockChangedFrame` (single source of truth for the producer). */ | BlockChangedFrame /** * Comment thread activity over the page's comment room (TASK-0094 R1/R2). * One frame type, `kind` discriminates the op — the shared * `CommentEventFrame` (source of truth) is the producer. The frame carries * a `preview` (first ~200 chars); the full comment is fetched on demand. */ | { type: "comment.event"; kind: "created" | "replied" | "resolved" | "reopened" | "reacted" | "deleted"; commentId: string; pageId: string; actorId: string; preview: string; } /** * A new notification for THIS user, pushed over the `user:{userId}` * control room (TASK-0094 R7). The server fans it to all of the user's * sockets (no in-band subscribe needed). `kind` is the policy kind * (mention, comment, …) — `type` is the frame discriminator. The durable * row is the truth; the push is the < 1 s fast path. */ | { type: "notification.push"; notificationId: string; kind: string; title: string; deepLink: { type: string; id: string }; actorName?: string; } | { type: "ack"; requestId: string };