File
Blob: src/shared/types.ts
| 1 | import { z } from "zod"; |
| 2 | import { ALLOWED_UPLOAD_TYPES, MAX_PAGE_MENTION_BATCH, MAX_UPLOAD_SIZE } from "@/shared/constants"; |
| 3 | import { AiUsage } from "@/shared/ai"; |
| 4 | import { sitesSlug } from "@/shared/site-slug"; |
| 5 | import { pageId } from "@/shared/page-id"; |
| 6 | |
| 7 | export const WorkspaceRole = z.enum(["owner", "admin", "member", "guest"]); |
| 8 | export type WorkspaceRole = z.infer<typeof WorkspaceRole>; |
| 9 | |
| 10 | export const SharePermission = z.enum(["view", "edit"]); |
| 11 | export type SharePermission = z.infer<typeof SharePermission>; |
| 12 | |
| 13 | export const GranteeType = z.enum(["user", "link"]); |
| 14 | export type GranteeType = z.infer<typeof GranteeType>; |
| 15 | |
| 16 | export const InviteRole = z.enum(["admin", "member", "guest"]); |
| 17 | export type InviteRole = z.infer<typeof InviteRole>; |
| 18 | |
| 19 | export const PageKind = z.enum(["doc", "canvas"]); |
| 20 | export type PageKind = z.infer<typeof PageKind>; |
| 21 | |
| 22 | export const User = z.object({ |
| 23 | id: z.string(), |
| 24 | email: z.string(), |
| 25 | name: z.string(), |
| 26 | avatar_url: z.string().nullable(), |
| 27 | created_at: z.string(), |
| 28 | }); |
| 29 | export type User = z.infer<typeof User>; |
| 30 | |
| 31 | export const Workspace = z.object({ |
| 32 | id: z.string(), |
| 33 | name: z.string(), |
| 34 | slug: z.string(), |
| 35 | icon: z.string().nullable(), |
| 36 | owner_id: z.string(), |
| 37 | created_at: z.string(), |
| 38 | }); |
| 39 | export type Workspace = z.infer<typeof Workspace>; |
| 40 | |
| 41 | // Workspace + caller's role. Returned by `GET /workspaces` so both the worker |
| 42 | // and the client can answer "which of my workspaces are writer-eligible?" |
| 43 | // without a second round-trip through /members. |
| 44 | export const WorkspaceMembershipSummary = Workspace.extend({ |
| 45 | role: WorkspaceRole, |
| 46 | }); |
| 47 | export type WorkspaceMembershipSummary = z.infer<typeof WorkspaceMembershipSummary>; |
| 48 | |
| 49 | export const WorkspaceMember = z.object({ |
| 50 | user_id: z.string(), |
| 51 | workspace_id: z.string(), |
| 52 | role: WorkspaceRole, |
| 53 | joined_at: z.string(), |
| 54 | user: User.optional(), |
| 55 | }); |
| 56 | export type WorkspaceMember = z.infer<typeof WorkspaceMember>; |
| 57 | |
| 58 | export const Page = z.object({ |
| 59 | id: z.string(), |
| 60 | workspace_id: z.string(), |
| 61 | parent_id: z.string().nullable(), |
| 62 | kind: PageKind, |
| 63 | title: z.string(), |
| 64 | icon: z.string().nullable(), |
| 65 | cover_url: z.string().nullable(), |
| 66 | position: z.number(), |
| 67 | created_by: z.string(), |
| 68 | created_at: z.string(), |
| 69 | updated_at: z.string(), |
| 70 | archived_at: z.string().nullable(), |
| 71 | archive_root_id: z.string().nullable(), |
| 72 | }); |
| 73 | export type Page = z.infer<typeof Page>; |
| 74 | |
| 75 | export type ArchivedPage = Page & { |
| 76 | archived_descendant_count: number; |
| 77 | }; |
| 78 | |
| 79 | export const GranteeUser = z.object({ |
| 80 | id: z.string(), |
| 81 | name: z.string(), |
| 82 | email: z.string(), |
| 83 | }); |
| 84 | export type GranteeUser = z.infer<typeof GranteeUser>; |
| 85 | |
| 86 | export const PageShare = z.object({ |
| 87 | id: z.string(), |
| 88 | page_id: z.string(), |
| 89 | grantee_type: GranteeType, |
| 90 | grantee_id: z.string().nullable(), |
| 91 | permission: SharePermission, |
| 92 | link_token: z.string().nullable(), |
| 93 | created_by: z.string(), |
| 94 | created_at: z.string(), |
| 95 | grantee_user: GranteeUser.nullable().optional(), |
| 96 | }); |
| 97 | export type PageShare = z.infer<typeof PageShare>; |
| 98 | |
| 99 | export const Invite = z.object({ |
| 100 | id: z.string(), |
| 101 | email: z.string().nullable(), |
| 102 | workspace_id: z.string(), |
| 103 | invited_by: z.string(), |
| 104 | role: InviteRole, |
| 105 | token: z.string(), |
| 106 | accepted_at: z.string().nullable(), |
| 107 | expires_at: z.string(), |
| 108 | created_at: z.string(), |
| 109 | }); |
| 110 | export type Invite = z.infer<typeof Invite>; |
| 111 | |
| 112 | export const ApiError = z.object({ |
| 113 | error: z.string(), |
| 114 | message: z.string(), |
| 115 | }); |
| 116 | export type ApiError = z.infer<typeof ApiError>; |
| 117 | |
| 118 | export const PublicClientConfig = z.object({ |
| 119 | sentry_dsn: z.string().min(1).nullable(), |
| 120 | }); |
| 121 | export type PublicClientConfig = z.infer<typeof PublicClientConfig>; |
| 122 | |
| 123 | export const InvitePreview = z.object({ |
| 124 | id: z.string(), |
| 125 | email: z.string().nullable(), |
| 126 | role: InviteRole, |
| 127 | workspace_name: z.string(), |
| 128 | workspace_icon: z.string().nullable(), |
| 129 | invited_by_name: z.string(), |
| 130 | }); |
| 131 | export type InvitePreview = z.infer<typeof InvitePreview>; |
| 132 | |
| 133 | export const CreateInviteRequest = z.object({ |
| 134 | email: z.email().max(255).optional(), |
| 135 | role: InviteRole.default("member"), |
| 136 | }); |
| 137 | export type CreateInviteRequest = z.infer<typeof CreateInviteRequest>; |
| 138 | |
| 139 | export const AcceptInviteRequest = z.object({}).optional(); |
| 140 | export type AcceptInviteRequest = z.infer<typeof AcceptInviteRequest>; |
| 141 | |
| 142 | // Reserved slugs that conflict with frontend routes |
| 143 | const RESERVED_SLUGS = new Set([ |
| 144 | "s", |
| 145 | "login", |
| 146 | "invite", |
| 147 | "profile", |
| 148 | "shared-with-me", |
| 149 | "api", |
| 150 | "uploads", |
| 151 | "ws", |
| 152 | "parties", |
| 153 | ]); |
| 154 | |
| 155 | const workspaceSlug = z |
| 156 | .string() |
| 157 | .min(1) |
| 158 | .max(60) |
| 159 | .regex( |
| 160 | /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, |
| 161 | "Slug must be lowercase alphanumeric with hyphens, cannot start or end with a hyphen", |
| 162 | ) |
| 163 | .refine((s) => !RESERVED_SLUGS.has(s), "This slug is reserved"); |
| 164 | |
| 165 | export const WorkspaceSite = z.object({ |
| 166 | workspace_id: z.string(), |
| 167 | slug: z.string(), |
| 168 | home_page_id: z.string().nullable(), |
| 169 | published_at: z.string().nullable(), |
| 170 | created_at: z.string(), |
| 171 | updated_at: z.string(), |
| 172 | }); |
| 173 | export type WorkspaceSite = z.infer<typeof WorkspaceSite>; |
| 174 | |
| 175 | export const PublishedPage = z.object({ |
| 176 | workspace_id: z.string(), |
| 177 | page_id: z.string(), |
| 178 | published_by: z.string(), |
| 179 | published_at: z.string(), |
| 180 | }); |
| 181 | export type PublishedPage = z.infer<typeof PublishedPage>; |
| 182 | |
| 183 | export const PublishedPageWithMeta = PublishedPage.extend({ |
| 184 | title: z.string(), |
| 185 | icon: z.string().nullable(), |
| 186 | kind: PageKind, |
| 187 | }); |
| 188 | export type PublishedPageWithMeta = z.infer<typeof PublishedPageWithMeta>; |
| 189 | |
| 190 | export const WorkspaceSiteResponse = z.object({ |
| 191 | site: WorkspaceSite.nullable(), |
| 192 | base_domain: z.string().nullable(), |
| 193 | }); |
| 194 | export type WorkspaceSiteResponse = z.infer<typeof WorkspaceSiteResponse>; |
| 195 | |
| 196 | export const WorkspaceSiteUpdateRequest = z.object({ |
| 197 | slug: sitesSlug.optional(), |
| 198 | home_page_id: pageId.nullable().optional(), |
| 199 | published: z.boolean().optional(), |
| 200 | }); |
| 201 | export type WorkspaceSiteUpdateRequest = z.infer<typeof WorkspaceSiteUpdateRequest>; |
| 202 | |
| 203 | export const SiteSlugAvailability = z.object({ |
| 204 | available: z.boolean(), |
| 205 | reason: z.string().optional(), |
| 206 | }); |
| 207 | export type SiteSlugAvailability = z.infer<typeof SiteSlugAvailability>; |
| 208 | |
| 209 | export const SitePageStatus = z.object({ |
| 210 | published: z.boolean(), |
| 211 | is_explicit_root: z.boolean(), |
| 212 | inherited_from: z |
| 213 | .object({ |
| 214 | id: z.string(), |
| 215 | title: z.string(), |
| 216 | icon: z.string().nullable(), |
| 217 | }) |
| 218 | .nullable(), |
| 219 | public_url: z.string().nullable(), |
| 220 | canvas: z.boolean(), |
| 221 | }); |
| 222 | export type SitePageStatus = z.infer<typeof SitePageStatus>; |
| 223 | |
| 224 | export const CreateWorkspaceRequest = z.object({ |
| 225 | name: z.string().min(1).max(100), |
| 226 | slug: workspaceSlug, |
| 227 | icon: z.string().max(50).optional(), |
| 228 | }); |
| 229 | export type CreateWorkspaceRequest = z.infer<typeof CreateWorkspaceRequest>; |
| 230 | |
| 231 | export const UpdateWorkspaceRequest = z.object({ |
| 232 | name: z.string().min(1).max(100).optional(), |
| 233 | slug: workspaceSlug.optional(), |
| 234 | icon: z.string().max(50).nullable().optional(), |
| 235 | }); |
| 236 | export type UpdateWorkspaceRequest = z.infer<typeof UpdateWorkspaceRequest>; |
| 237 | |
| 238 | export const UpdateMemberRoleRequest = z.object({ |
| 239 | role: InviteRole, |
| 240 | }); |
| 241 | export type UpdateMemberRoleRequest = z.infer<typeof UpdateMemberRoleRequest>; |
| 242 | |
| 243 | export const CreatePageRequest = z.object({ |
| 244 | kind: PageKind.default("doc"), |
| 245 | title: z.string().max(500).optional(), |
| 246 | icon: z.string().max(50).optional(), |
| 247 | parent_id: z.string().max(26).optional().nullable(), |
| 248 | position: z.number().optional(), |
| 249 | }); |
| 250 | export type CreatePageRequest = z.infer<typeof CreatePageRequest>; |
| 251 | |
| 252 | export const UpdatePageRequest = z.object({ |
| 253 | icon: z.string().max(50).nullable().optional(), |
| 254 | cover_url: z.string().max(2048).nullable().optional(), |
| 255 | position: z.number().optional(), |
| 256 | parent_id: z.string().max(26).nullable().optional(), |
| 257 | }); |
| 258 | export type UpdatePageRequest = z.infer<typeof UpdatePageRequest>; |
| 259 | |
| 260 | export const PresignRequest = z.object({ |
| 261 | filename: z.string().min(1).max(255), |
| 262 | content_type: z.enum(ALLOWED_UPLOAD_TYPES), |
| 263 | size_bytes: z.number().int().min(1).max(MAX_UPLOAD_SIZE), |
| 264 | page_id: z.string().max(26).nullable().optional(), |
| 265 | }); |
| 266 | export type PresignRequest = z.infer<typeof PresignRequest>; |
| 267 | |
| 268 | export const Upload = z.object({ |
| 269 | id: z.string(), |
| 270 | workspace_id: z.string(), |
| 271 | page_id: z.string().nullable(), |
| 272 | uploaded_by: z.string(), |
| 273 | filename: z.string(), |
| 274 | content_type: z.string(), |
| 275 | size_bytes: z.number(), |
| 276 | r2_key: z.string(), |
| 277 | created_at: z.string(), |
| 278 | }); |
| 279 | export type Upload = z.infer<typeof Upload>; |
| 280 | |
| 281 | export const SearchResult = z.object({ |
| 282 | page_id: z.string(), |
| 283 | title: z.string(), |
| 284 | snippet: z.string(), |
| 285 | icon: z.string().nullable(), |
| 286 | }); |
| 287 | export type SearchResult = z.infer<typeof SearchResult>; |
| 288 | |
| 289 | export const CreateShareRequest = z.object({ |
| 290 | grantee_type: GranteeType, |
| 291 | grantee_id: z.string().max(26).optional(), |
| 292 | grantee_email: z.email().max(255).optional(), |
| 293 | permission: SharePermission, |
| 294 | }); |
| 295 | export type CreateShareRequest = z.infer<typeof CreateShareRequest>; |
| 296 | |
| 297 | export const ResolvedViewerContext = z.object({ |
| 298 | // Membership axis: `member` iff the caller has a memberships row on the |
| 299 | // canonical workspace surface (including guest). Non-member canonical access |
| 300 | // and every `/s/:token` or `?share=` request emits `shared`. |
| 301 | access_mode: z.enum(["member", "shared"]), |
| 302 | principal_type: z.enum(["user", "link"]), |
| 303 | route_kind: z.enum(["canonical", "shared"]), |
| 304 | workspace_slug: z.string().nullable(), |
| 305 | // Role axis: the caller's workspace role when they have a membership row, |
| 306 | // else null. Always null on the shared surface so `/s/:token` stays |
| 307 | // link-scoped end to end regardless of a caller's membership status. |
| 308 | workspace_role: WorkspaceRole.nullable(), |
| 309 | }); |
| 310 | export type ResolvedViewerContext = z.infer<typeof ResolvedViewerContext>; |
| 311 | |
| 312 | export const SharedPageInfo = z.object({ |
| 313 | page_id: z.string(), |
| 314 | workspace_id: z.string(), |
| 315 | kind: PageKind, |
| 316 | title: z.string(), |
| 317 | icon: z.string().nullable(), |
| 318 | cover_url: z.string().nullable(), |
| 319 | permission: SharePermission, |
| 320 | token: z.string(), |
| 321 | viewer: ResolvedViewerContext, |
| 322 | }); |
| 323 | export type SharedPageInfo = z.infer<typeof SharedPageInfo>; |
| 324 | |
| 325 | const avatarUrl = z |
| 326 | .string() |
| 327 | .max(2048) |
| 328 | .refine((s) => s.startsWith("/uploads/") || s.startsWith("https://"), "Avatar must be an upload or HTTPS URL") |
| 329 | .nullable() |
| 330 | .optional(); |
| 331 | |
| 332 | export const UpdateProfileRequest = z.object({ |
| 333 | name: z.string().min(1).max(100).optional(), |
| 334 | avatar_url: avatarUrl, |
| 335 | }); |
| 336 | export type UpdateProfileRequest = z.infer<typeof UpdateProfileRequest>; |
| 337 | |
| 338 | export const SharedWithMeItem = z.object({ |
| 339 | page_id: z.string(), |
| 340 | title: z.string(), |
| 341 | icon: z.string().nullable(), |
| 342 | cover_url: z.string().nullable(), |
| 343 | workspace: z.object({ |
| 344 | id: z.string(), |
| 345 | name: z.string(), |
| 346 | slug: z.string(), |
| 347 | icon: z.string().nullable(), |
| 348 | role: WorkspaceRole.nullable(), |
| 349 | }), |
| 350 | permission: SharePermission, |
| 351 | shared_by: z.string(), |
| 352 | shared_by_name: z.string(), |
| 353 | shared_at: z.string(), |
| 354 | }); |
| 355 | export type SharedWithMeItem = z.infer<typeof SharedWithMeItem>; |
| 356 | |
| 357 | /** |
| 358 | * Grouped summary for pages shared with the current user *inside* a workspace |
| 359 | * they are already a member of. Those pages are reachable in the workspace's |
| 360 | * normal page tree, so we do not duplicate them as standalone items — we |
| 361 | * surface a lightweight pointer so the user can jump to the workspace. |
| 362 | */ |
| 363 | export const SharedInboxWorkspaceSummary = z.object({ |
| 364 | workspace: z.object({ |
| 365 | id: z.string(), |
| 366 | name: z.string(), |
| 367 | slug: z.string(), |
| 368 | icon: z.string().nullable(), |
| 369 | }), |
| 370 | count: z.number().int().nonnegative(), |
| 371 | }); |
| 372 | export type SharedInboxWorkspaceSummary = z.infer<typeof SharedInboxWorkspaceSummary>; |
| 373 | |
| 374 | export const SharedPagesResponse = z.object({ |
| 375 | items: z.array(SharedWithMeItem), |
| 376 | workspace_summaries: z.array(SharedInboxWorkspaceSummary), |
| 377 | }); |
| 378 | export type SharedPagesResponse = z.infer<typeof SharedPagesResponse>; |
| 379 | |
| 380 | export const GetPageResponse = z.object({ |
| 381 | page: Page, |
| 382 | can_edit: z.boolean(), |
| 383 | }); |
| 384 | export type GetPageResponse = z.infer<typeof GetPageResponse>; |
| 385 | |
| 386 | export const PageAncestor = z.object({ |
| 387 | id: z.string(), |
| 388 | title: z.string().nullable(), |
| 389 | icon: z.string().nullable(), |
| 390 | accessible: z.boolean(), |
| 391 | }); |
| 392 | export type PageAncestor = z.infer<typeof PageAncestor>; |
| 393 | |
| 394 | export const GetPageAncestorsResponse = z.object({ |
| 395 | ancestors: z.array(PageAncestor), |
| 396 | }); |
| 397 | export type GetPageAncestorsResponse = z.infer<typeof GetPageAncestorsResponse>; |
| 398 | |
| 399 | export const PageRouteBootstrapResponse = z.object({ |
| 400 | workspace: Workspace, |
| 401 | viewer: ResolvedViewerContext, |
| 402 | }); |
| 403 | export type PageRouteBootstrapResponse = z.infer<typeof PageRouteBootstrapResponse>; |
| 404 | |
| 405 | export const ResolvePageMentionsRequest = z.object({ |
| 406 | page_ids: z.array(z.string().min(1)).min(1).max(MAX_PAGE_MENTION_BATCH), |
| 407 | }); |
| 408 | export type ResolvePageMentionsRequest = z.infer<typeof ResolvePageMentionsRequest>; |
| 409 | |
| 410 | export const ResolvedPageMentionItem = z.object({ |
| 411 | page_id: z.string(), |
| 412 | accessible: z.boolean(), |
| 413 | title: z.string().nullable(), |
| 414 | icon: z.string().nullable(), |
| 415 | }); |
| 416 | export type ResolvedPageMentionItem = z.infer<typeof ResolvedPageMentionItem>; |
| 417 | |
| 418 | export const ResolvePageMentionsResponse = z.object({ |
| 419 | mentions: z.array(ResolvedPageMentionItem), |
| 420 | }); |
| 421 | export type ResolvePageMentionsResponse = z.infer<typeof ResolvePageMentionsResponse>; |
| 422 | |
| 423 | export type PageSnapshotResponse = { kind: "found"; snapshot: ArrayBuffer } | { kind: "missing" }; |
| 424 | |
| 425 | export const AiRewriteAction = z.enum(["proofread", "formal", "casual", "simplify", "expand"]); |
| 426 | export type AiRewriteAction = z.infer<typeof AiRewriteAction>; |
| 427 | |
| 428 | const AI_BLOCK_MAX = 2000; |
| 429 | const AI_SELECTION_MAX = 4000; |
| 430 | |
| 431 | export const AiRewriteRequest = z.object({ |
| 432 | action: AiRewriteAction, |
| 433 | selectedText: z.string().min(1).max(AI_SELECTION_MAX), |
| 434 | parentBlock: z.string().max(AI_BLOCK_MAX), |
| 435 | beforeBlock: z.string().max(AI_BLOCK_MAX), |
| 436 | afterBlock: z.string().max(AI_BLOCK_MAX), |
| 437 | pageTitle: z.string().max(500), |
| 438 | }); |
| 439 | export type AiRewriteRequest = z.infer<typeof AiRewriteRequest>; |
| 440 | |
| 441 | export const AiGenerateIntent = z.enum(["continue", "explain", "brainstorm"]); |
| 442 | export type AiGenerateIntent = z.infer<typeof AiGenerateIntent>; |
| 443 | |
| 444 | export const AiGenerateRequest = z.object({ |
| 445 | intent: AiGenerateIntent, |
| 446 | beforeBlock: z.string().max(AI_BLOCK_MAX), |
| 447 | afterBlock: z.string().max(AI_BLOCK_MAX), |
| 448 | pageTitle: z.string().max(500), |
| 449 | }); |
| 450 | export type AiGenerateRequest = z.infer<typeof AiGenerateRequest>; |
| 451 | |
| 452 | export const AiAskHistoryMessage = z.object({ |
| 453 | role: z.enum(["user", "assistant"]), |
| 454 | content: z.string().min(1).max(AI_BLOCK_MAX), |
| 455 | }); |
| 456 | export type AiAskHistoryMessage = z.infer<typeof AiAskHistoryMessage>; |
| 457 | |
| 458 | export const AiAskRequest = z.object({ |
| 459 | question: z.string().min(1).max(AI_BLOCK_MAX), |
| 460 | history: z.array(AiAskHistoryMessage).max(6).optional(), |
| 461 | }); |
| 462 | export type AiAskRequest = z.infer<typeof AiAskRequest>; |
| 463 | |
| 464 | export const AiSummarizeResponse = z.object({ |
| 465 | summary: z.string(), |
| 466 | usage: AiUsage.optional(), |
| 467 | }); |
| 468 | export type AiSummarizeResponse = z.infer<typeof AiSummarizeResponse>; |