From f302bc44117096e85a2d7fcf5770013e4d107946 Mon Sep 17 00:00:00 2001
From: Jack D
Date: Thu, 5 Mar 2026 13:25:22 +0000
Subject: [PATCH 01/91] Refactor plan
---
...refactor-assessment-evidence-2026-03-05.md | 85 ++++++++++++
.../refactor-assessment-roadmap-2026-03-05.md | 130 ++++++++++++++++++
...assessment-web-widget-convex-2026-03-05.md | 82 +++++++++++
3 files changed, 297 insertions(+)
create mode 100644 docs/refactor-assessment-evidence-2026-03-05.md
create mode 100644 docs/refactor-assessment-roadmap-2026-03-05.md
create mode 100644 docs/refactor-assessment-web-widget-convex-2026-03-05.md
diff --git a/docs/refactor-assessment-evidence-2026-03-05.md b/docs/refactor-assessment-evidence-2026-03-05.md
new file mode 100644
index 0000000..e7c4e19
--- /dev/null
+++ b/docs/refactor-assessment-evidence-2026-03-05.md
@@ -0,0 +1,85 @@
+# Refactor Assessment Evidence (2026-03-05)
+
+## 1) Shared Business Logic Is Duplicated Across Surfaces
+
+| Theme | Evidence | Refactor Opportunity |
+|---|---|---|
+| Markdown rendering + sanitization | `apps/web/src/lib/parseMarkdown.ts` and `apps/widget/src/utils/parseMarkdown.ts` are near-duplicates (same allowed tags/attrs, same frontmatter strip, same link/media hardening). | Extract to a DOM-scoped shared module (`packages/web-shared`), keep one parser/sanitizer test suite, re-export helpers per frontend if needed. |
+| Unread cue engine | `apps/web/src/lib/inboxNotificationCues.ts` and `apps/widget/src/lib/widgetNotificationCues.ts` share the same snapshot/increase/suppress algorithm shape with naming/count-field differences. | Create a generic unread-cue core (`buildSnapshot`, `getIncreases`, `shouldSuppress`) with thin adapters for agent vs visitor fields. |
+| Visitor human-readable ID generation | `apps/web/src/lib/visitorIdentity.ts` and `packages/convex/convex/visitorReadableId.ts` contain the same adjective/noun dictionaries and hash/salt strategy (wordlists are byte-identical for adjective+noun sections). | Make one source of truth for deterministic label generation (shared utility + explicit stability contract tests). |
+| Messenger settings defaults | Defaults duplicated across `packages/convex/convex/messengerSettings.ts`, `apps/widget/src/hooks/useWidgetSettings.ts`, and `packages/react-native-sdk/src/hooks/useMessengerSettings.ts`. | Move contract + defaults into shared package; Convex returns authoritative shape, clients consume shared default merger. |
+| Home config model + defaults | Repeated in `packages/convex/convex/messengerSettings.ts`, `apps/web/src/app/settings/HomeSettingsSection.tsx`, `apps/widget/src/components/Home.tsx`, and `packages/react-native-sdk/src/components/OpencomHome.tsx`. | Centralize `HomeCard`, `HomeTab`, `HomeConfig`, and default/normalization logic; avoid per-surface drift. |
+| Audience rule contract | Frontend type system in `apps/web/src/components/AudienceRuleBuilder.tsx` duplicates backend model in `packages/convex/convex/audienceRules.ts` and validators in `packages/convex/convex/validators.ts`. | Publish shared audience-rule types + schema adapters; use on both frontend and backend validators. |
+| Backend URL normalization | `normalizeBackendUrl` exists in both `packages/types/src/backendValidation.ts` and `apps/web/src/contexts/BackendContext.tsx`; mobile uses ad-hoc normalization too. | Use shared normalization/validation utilities from `@opencom/types` everywhere. |
+
+## 2) Business Logic Is Still Heavily Coupled To UI Containers
+
+### Web
+
+- `apps/web/src/app/inbox/page.tsx`
+ - 1732 lines, 15 `useQuery/useMutation/useAction` hooks, 13 `useEffect` hooks.
+ - Handles routing sync, optimistic patching, suggestions loading, title mutation, notification side effects, and rendering in one page.
+- `apps/web/src/app/settings/page.tsx`
+ - 1360 lines, many independent settings concerns mixed: workspace membership, signup policy, help-center policy, email channel config, transfer ownership.
+- `apps/web/src/app/campaigns/series/[id]/page.tsx`
+ - 1204 lines with rule parsing, activation-error parsing, block graph editing, and persistence orchestration co-located with render logic.
+
+### Widget
+
+- `apps/widget/src/Widget.tsx`
+ - 1413 lines, 21 data hooks (`useQuery/useMutation`), 9 `useState` + high orchestration density.
+ - Owns session boot/refresh integration, view routing, ticket/help/tour/survey/outbound orchestration.
+- `apps/widget/src/components/ConversationView.tsx`
+ - 791 lines with messaging, AI actions, CSAT gating, email-capture logic, and markdown rendering in one component.
+
+### External signal
+
+`react-doctor` confirms this trend:
+
+- Web: large-component and effect/state orchestration warnings across key pages including inbox/settings/series.
+- Widget: flags `Widget` for high `useState` count and `OutboundOverlay`/`ConversationView`/`TourOverlay` as oversized components.
+
+## 3) Convex Authorization + Domain Patterns Are Inconsistent
+
+### Mixed auth patterns
+
+- Auth wrappers (`authQuery/authMutation/authAction`) are used in some modules, but many modules still perform manual `getAuthenticatedUserFromSession` + `requirePermission` + `hasPermission` logic.
+- High-repetition examples:
+ - `packages/convex/convex/workspaces.ts` (18 auth+perm calls)
+ - `packages/convex/convex/workspaceMembers.ts` (18)
+ - `packages/convex/convex/tags.ts` (16)
+ - `packages/convex/convex/identityVerification.ts` (14)
+ - `packages/convex/convex/segments.ts` (14)
+
+### Same workflow repeated in many CRUD modules
+
+- `assignmentRules.ts`, `commonIssueButtons.ts`, `segments.ts`, `ticketForms.ts`, `checklists.ts` each repeat similar list/get/create/update/remove/reorder with similar permission gates and error styles.
+
+### Public/private API semantics are inconsistent in naming
+
+- Example: `automationSettings.get` is auth-gated, while `automationSettings.getOrCreate` is effectively public-facing for widget consumption, but naming does not signal this (unlike `messengerSettings.getPublicSettings`).
+
+### Utility fragmentation
+
+- `packages/convex/convex/utils/validation.ts` exposes many validators/sanitizers but appears unused by domain modules; `messengerSettings.ts` defines local `isValidUrl` instead.
+
+## 4) Cross-App References Highlight Better Patterns Already Present
+
+- `apps/mobile/src/utils/workspaceSelection.ts` cleanly extracts workspace-resolution rules, while web keeps similar logic embedded in `apps/web/src/contexts/AuthContext.tsx`.
+- `packages/react-native-sdk/src/OpencomSDK.ts` uses shared `@opencom/sdk-core` state/client/session utilities extensively.
+- `apps/widget` and `apps/web` mostly bypass these shared `sdk-core` abstractions and directly implement orchestration in app code.
+
+## 5) Dead/Partial Abstractions Increase Drift
+
+- `apps/widget/src/components/WidgetContext.tsx` is currently unused.
+- `apps/web/src/components/TriggerConfigEditor.tsx` and `apps/web/src/components/CollapsibleSection.tsx` are currently unused.
+- This indicates refactors were started but not consistently adopted, leaving multiple architectural paths in parallel.
+
+## 6) Implications For apps/mobile + sdk-core + React Native SDK
+
+| Surface | Evidence | Plan Implication |
+|---|---|---|
+| Mobile app (`apps/mobile`) | Mobile depends on `@opencom/types` + `@opencom/convex`, not `@opencom/sdk-core` (`apps/mobile/package.json`). Backend selection already uses shared validation (`apps/mobile/src/contexts/BackendContext.tsx`), and workspace selection is cleanly extracted/tested (`apps/mobile/src/utils/workspaceSelection.ts`, `apps/mobile/src/utils/__tests__/workspaceSelection.test.ts`). | No plan shape change. Keep “workspace/backend consolidation” as-is; promote mobile’s workspace resolver pattern to web and standardize backend normalization usage (avoid ad-hoc `url.replace(/\/$/, "")` branches). |
+| sdk-core (`packages/sdk-core`) | `validateConvexUrl` allows `http://` for localhost only (`packages/sdk-core/src/api/client.ts`) and normalizes URL for client init. Contract tests assert specific Convex function references/args (`packages/sdk-core/tests/contracts.test.ts`). | Treat sdk-core as a compatibility boundary: do not silently rename/remove Convex functions used by sdk-core paths, and keep surface-specific URL-security semantics while centralizing common normalization primitives. |
+| React Native SDK (`packages/react-native-sdk`) | RN SDK re-exports many public hooks/types (`packages/react-native-sdk/src/index.ts`). It duplicates messenger/home defaults and local interfaces (`packages/react-native-sdk/src/hooks/useMessengerSettings.ts`, `packages/react-native-sdk/src/components/OpencomHome.tsx`). `useAutomationSettings` calls `api.automationSettings.get` (auth-gated) and falls back to defaults (`packages/react-native-sdk/src/hooks/useAutomationSettings.ts`), while widget uses `api.automationSettings.getOrCreate` (`apps/widget/src/Widget.tsx`). RN components also build a second `ConvexReactClient` directly from raw config URL (`packages/react-native-sdk/src/components/Opencom.tsx`, `packages/react-native-sdk/src/components/OpencomProvider.tsx`). | No plan shape change. Add guardrails: preserve exported SDK contracts while extracting shared defaults/types, enforce explicit public endpoint naming with compatibility aliases, and align RN client URL handling with sdk-core normalization semantics. |
+| Existing OpenSpec tracks | Active changes already cover RN modularization/type safety and mobile parity (`openspec/changes/split-react-native-sdk-orchestrator`, `decompose-react-native-sdk-messenger-containers`, `tighten-react-native-sdk-messenger-types`, `parity-mobile-inbox-ai-review-and-visitors`). | Keep current roadmap; map each refactor task to these existing tracks so workstreams converge instead of duplicating refactors. |
diff --git a/docs/refactor-assessment-roadmap-2026-03-05.md b/docs/refactor-assessment-roadmap-2026-03-05.md
new file mode 100644
index 0000000..f5df2da
--- /dev/null
+++ b/docs/refactor-assessment-roadmap-2026-03-05.md
@@ -0,0 +1,130 @@
+# Refactor Roadmap: Web + Widget + Convex (2026-03-05)
+
+## Goals
+
+1. Separate business logic from UI render layers.
+2. Eliminate duplicate domain logic across frontends.
+3. Standardize Convex auth/permission and public/private API patterns.
+
+## Architecture Direction
+
+- `@opencom/types`: canonical domain contracts and default configuration objects (no runtime/browser coupling).
+- `@opencom/sdk-core`: pure shared runtime logic used across web/widget/mobile/RN.
+- `packages/web-shared` (new): DOM-dependent shared logic for web + widget only (markdown/sanitization, browser notification cue helpers).
+
+## Cross-App Compatibility Constraints
+
+The roadmap itself does not change after reviewing `apps/mobile`, `packages/sdk-core`, and `packages/react-native-sdk`. Execution needs these hard constraints:
+
+1. Preserve public SDK contracts.
+ - Keep `@opencom/react-native-sdk` and `@opencom/sdk-core` exported types/hooks/API signatures stable while internals are refactored.
+2. Preserve Convex visitor-path compatibility.
+ - Do not break function references used by sdk-core/RN visitor paths without compatibility aliases and migration sequencing.
+3. Keep platform security semantics explicit.
+ - Backend discovery URL handling (`@opencom/types`) and sdk-core Convex URL validation are related but not identical; centralize shared pieces without flattening behavior.
+4. Prefer extraction by surface responsibility.
+ - Pure contracts in `@opencom/types`, runtime-only logic in `@opencom/sdk-core`, DOM utilities in `packages/web-shared`.
+
+## Phase Plan
+
+### Phase 0: Guardrails (1-2 days)
+
+- Add a lightweight architecture note documenting source-of-truth ownership for:
+ - messenger settings/home config contracts
+ - audience rule contracts
+ - visitor readable ID generation
+- Add compatibility guardrails for external/mobile surfaces:
+ - lock critical sdk-core + RN SDK contract tests (Convex function refs and argument shapes)
+ - define “no API break” checklist for exported RN SDK hooks/types
+ - map work items to active OpenSpec changes for RN/mobile to avoid duplicate tracks
+- Add focused regression tests before moving logic:
+ - markdown/sanitization behavior parity tests
+ - home config normalization behavior
+ - visitor readable ID deterministic output snapshots
+
+### Phase 1: Shared Contract Extraction (3-5 days)
+
+- Move duplicated contracts/defaults into shared packages:
+ - Messenger settings + home config types/defaults/normalizers
+ - Audience rule types shared between builder/backend
+ - Visitor readable ID generator
+- During extraction:
+ - keep RN SDK return shapes and default behavior unchanged
+ - align mobile/web backend normalization on shared utilities (remove ad-hoc trailing-slash normalization paths)
+- Refactor consumers:
+ - `packages/convex/convex/messengerSettings.ts`
+ - `apps/web/src/app/settings/HomeSettingsSection.tsx`
+ - `apps/widget/src/components/Home.tsx`
+ - `packages/react-native-sdk/src/components/OpencomHome.tsx`
+ - `apps/web/src/lib/visitorIdentity.ts`
+ - `packages/convex/convex/visitorReadableId.ts`
+
+### Phase 2: UI Decomposition (5-8 days)
+
+#### 2A. Web Inbox
+
+- Introduce controller hooks:
+ - `useInboxData` (queries/mutations/actions)
+ - `useInboxRouteSync` (URL <-> selected conversation)
+ - `useInboxAttentionCues` (snapshot + sound + browser notifications)
+- Move large panes into focused components:
+ - conversation list pane
+ - thread pane
+ - side panels (AI review/suggestions/knowledge)
+
+#### 2B. Widget Shell
+
+- Introduce `useWidgetShellController` that composes existing hooks and data fetches.
+- Move feature slices out of `Widget.tsx`:
+ - tickets controller
+ - home/help controller
+ - survey/outbound/tour orchestration controller
+- Keep `Widget.tsx` mostly as layout + routing glue.
+
+#### 2C. Settings Surface
+
+- Extract per-domain hooks from `apps/web/src/app/settings/page.tsx`:
+ - workspace membership/invitations
+ - signup/auth-mode policy
+ - email channel settings
+
+### Phase 3: Convex Consistency Pass (4-6 days)
+
+- Standardize on auth wrappers where possible; for exceptions, document why.
+- Extract repeated workspace-permission/resource-fetch logic into reusable helpers.
+- Normalize endpoint naming:
+ - use `getPublic*` for unauthenticated/public queries
+ - reserve plain `get/list/update/...` for auth-gated admin paths
+- Add compatibility aliases before removals/renames for currently consumed paths:
+ - `automationSettings.get` / `automationSettings.getOrCreate`
+ - existing visitor-facing queries used by widget/RN hooks
+- Start with highest repetition modules:
+ - `workspaces.ts`, `workspaceMembers.ts`, `identityVerification.ts`, `segments.ts`, `assignmentRules.ts`, `commonIssueButtons.ts`
+
+### Phase 4: Cleanup + Adoption (2-3 days)
+
+- Remove or adopt dead/partial abstractions:
+ - `apps/widget/src/components/WidgetContext.tsx`
+ - `apps/web/src/components/TriggerConfigEditor.tsx`
+ - `apps/web/src/components/CollapsibleSection.tsx`
+ - unused Convex validation helpers not in active use
+- Ensure docs reflect final source-of-truth package boundaries.
+
+## Suggested Sequencing (Lowest Risk First)
+
+1. Shared pure contracts/defaults (low runtime risk, high drift reduction).
+2. Web/widget DOM-shared utility extraction (markdown + cues).
+3. UI decomposition in `web/inbox` and `widget/Widget`.
+4. Convex wrapper and API naming normalization.
+5. Dead code cleanup.
+
+## Exit Criteria
+
+- No duplicated messenger/home/audience/visitor-ID contract definitions across web/widget/convex/RN.
+- `web/inbox` and `widget/Widget` reduced to orchestrators with domain hooks and smaller render components.
+- Convex modules in target set follow one documented auth/public pattern.
+- Shared behavior covered by tests in source-of-truth packages.
+- Mobile + SDK safeguards in place:
+ - `apps/mobile` typecheck passes for backend/workspace flows after shared utility moves.
+ - `@opencom/sdk-core` contract tests pass without Convex visitor-path regressions.
+ - `@opencom/react-native-sdk` tests/typecheck pass with unchanged public API behavior.
diff --git a/docs/refactor-assessment-web-widget-convex-2026-03-05.md b/docs/refactor-assessment-web-widget-convex-2026-03-05.md
new file mode 100644
index 0000000..8b2d571
--- /dev/null
+++ b/docs/refactor-assessment-web-widget-convex-2026-03-05.md
@@ -0,0 +1,82 @@
+# Refactor Assessment: Web + Widget + Convex (2026-03-05)
+
+## Scope
+
+- Primary: `apps/web`, `apps/widget`, `packages/convex`
+- Reference-only for convergence: `apps/mobile`, `packages/react-native-sdk`, `packages/sdk-core`, `packages/types`
+
+## Executive Summary
+
+The codebase already has strong building blocks (`@opencom/sdk-core`, `@opencom/types`, Convex auth wrappers), but core business logic is still duplicated across frontends and mixed into large UI containers.
+
+The highest-ROI path is:
+
+1. Consolidate shared business contracts and defaults (settings, home config, audience rules, identity labels, markdown safety).
+2. Extract orchestration logic out of large pages/components into reusable domain hooks.
+3. Standardize Convex auth/permission patterns and public/private endpoint contracts.
+
+## Cross-App Compatibility Verdict (Mobile + sdk-core + RN SDK)
+
+Plan shape does **not** need to change. The same three tracks remain correct.
+
+What does need to change is execution guardrails:
+
+1. Keep SDK/public contracts stable while extracting shared logic.
+ - `@opencom/react-native-sdk` re-exports many `@opencom/sdk-core` types and hook/component contracts.
+2. Keep platform boundaries explicit.
+ - DOM-dependent logic (markdown/sanitization) should stay out of `@opencom/sdk-core`.
+3. Preserve Convex public endpoint compatibility during naming cleanup.
+ - Some visitor-facing consumers use `getOrCreate`/`get` inconsistently today.
+4. Preserve URL-validation semantics by surface.
+ - Mobile backend discovery (`@opencom/types`) and sdk-core client init (`validateConvexUrl`) have different security rules and should not be merged blindly.
+
+## Key Signals
+
+- Large files (>= 500 lines):
+ - `apps/web`: 17 files
+ - `apps/widget`: 5 files
+ - `packages/convex/convex`: 33 files
+- Largest hotspots:
+ - `apps/web/src/app/inbox/page.tsx` (1732 lines)
+ - `apps/widget/src/Widget.tsx` (1413 lines)
+ - `packages/convex/convex/series.ts` (2429 lines)
+- React health scan (`react-doctor`):
+ - Web: **78/100**, 2 errors, 379 warnings, 66/129 files
+ - Widget: **87/100**, 2 errors, 53 warnings, 17/61 files
+
+## Ranked Opportunities
+
+| Rank | Opportunity | Why It Matters | Impact | Effort |
+|---|---|---|---|---|
+| 1 | Centralize messenger/home settings contracts and defaults | Same domain model/defaults are implemented in Convex + web + widget + RN, causing drift risk | High | Medium |
+| 2 | Split `apps/web/src/app/inbox/page.tsx` into domain hooks + presentational components | Heavy state/effects/query orchestration and notification side effects in one page | High | Medium |
+| 3 | Split `apps/widget/src/Widget.tsx` shell into feature controllers | Shell owns sessions, navigation, data loading, overlay arbitration, and rendering | High | Medium |
+| 4 | Unify markdown/sanitization pipeline across web + widget | Security-sensitive parser/sanitizer logic is near-duplicate and test coverage is fragmented | High | Low |
+| 5 | Standardize Convex auth/permission handling via wrappers/services | Mixed manual checks and wrapper usage increases inconsistency and audit surface | High | Medium |
+| 6 | Unify audience-rule type contract (frontend/backed) | Same concept is typed in multiple places with local variants and casts | High | Medium |
+| 7 | Unify unread cue engine (web inbox + widget) | Same unread-snapshot/increase/suppression model reimplemented with small variations | Medium | Low |
+| 8 | Consolidate workspace/backend selection logic across web + mobile | Duplicate backend/auth/workspace-resolution flows diverge by platform | Medium | Medium |
+| 9 | Extract visitor identity label generator into shared package | Deterministic wordlists are duplicated byte-for-byte in web + Convex | Medium | Low |
+| 10 | Clean up abandoned/partial abstractions | Unused `WidgetContext`, unused web components, unused Convex validation utilities add confusion | Medium | Low |
+| 11 | Add cross-surface compatibility gates (mobile + sdk-core + RN SDK) | Prevents refactors in web/widget/convex from silently breaking SDK consumers and mobile routing/auth flows | High | Low |
+
+## Suggested First 3 Execution Tracks
+
+1. **Shared contracts track**
+ - Move settings/home/audience/identity contracts into shared packages.
+ - Keep Convex as runtime authority; clients consume shared schema/types/defaults.
+
+2. **UI decomposition track**
+ - Start with `web/inbox` and `widget/Widget`.
+ - Introduce domain hooks (`useInboxController`, `useWidgetShellController`) and thin render layers.
+
+3. **Convex consistency track**
+ - Expand auth wrappers into remaining manual modules.
+ - Enforce explicit naming for public endpoints (`getPublic*`) vs auth-required endpoints.
+
+## Detailed Evidence
+
+See:
+
+- `docs/refactor-assessment-evidence-2026-03-05.md`
+- `docs/refactor-assessment-roadmap-2026-03-05.md`
From 691e831acfc0498caba4307e0f2e3d56f240192b Mon Sep 17 00:00:00 2001
From: Jack D
Date: Thu, 5 Mar 2026 13:48:53 +0000
Subject: [PATCH 02/91] unify-markdown-rendering-utility &
unify-inbox-widget-notification-cues
---
apps/web/package.json | 1 +
apps/web/src/lib/inboxNotificationCues.ts | 73 ++++----
apps/web/src/lib/parseMarkdown.ts | 103 +----------
apps/widget/package.json | 1 +
apps/widget/src/lib/widgetNotificationCues.ts | 70 ++++----
apps/widget/src/utils/parseMarkdown.ts | 133 ++------------
.../tasks.md | 18 +-
.../unify-markdown-rendering-utility/tasks.md | 18 +-
packages/web-shared/README.md | 42 +++++
packages/web-shared/package.json | 26 +++
packages/web-shared/src/index.ts | 15 ++
packages/web-shared/src/markdown.test.ts | 62 +++++++
packages/web-shared/src/markdown.ts | 165 ++++++++++++++++++
.../web-shared/src/notificationCues.test.ts | 149 ++++++++++++++++
packages/web-shared/src/notificationCues.ts | 110 ++++++++++++
packages/web-shared/tsconfig.json | 8 +
packages/web-shared/vitest.config.ts | 10 ++
pnpm-lock.yaml | 28 +++
18 files changed, 723 insertions(+), 309 deletions(-)
create mode 100644 packages/web-shared/README.md
create mode 100644 packages/web-shared/package.json
create mode 100644 packages/web-shared/src/index.ts
create mode 100644 packages/web-shared/src/markdown.test.ts
create mode 100644 packages/web-shared/src/markdown.ts
create mode 100644 packages/web-shared/src/notificationCues.test.ts
create mode 100644 packages/web-shared/src/notificationCues.ts
create mode 100644 packages/web-shared/tsconfig.json
create mode 100644 packages/web-shared/vitest.config.ts
diff --git a/apps/web/package.json b/apps/web/package.json
index db5d0bb..9b495f7 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -17,6 +17,7 @@
"@opencom/sdk-core": "workspace:*",
"@opencom/types": "workspace:*",
"@opencom/ui": "workspace:*",
+ "@opencom/web-shared": "workspace:*",
"convex": "^1.31.7",
"dompurify": "^3.3.1",
"fflate": "^0.8.2",
diff --git a/apps/web/src/lib/inboxNotificationCues.ts b/apps/web/src/lib/inboxNotificationCues.ts
index abcc60d..85e8ddf 100644
--- a/apps/web/src/lib/inboxNotificationCues.ts
+++ b/apps/web/src/lib/inboxNotificationCues.ts
@@ -1,3 +1,12 @@
+import {
+ buildUnreadSnapshot as buildSharedUnreadSnapshot,
+ getUnreadIncreases as getSharedUnreadIncreases,
+ loadCuePreferences,
+ saveCuePreferences,
+ shouldSuppressUnreadAttentionCue,
+ type CuePreferenceAdapter,
+} from "@opencom/web-shared";
+
export interface InboxCuePreferences {
browserNotifications: boolean;
sound: boolean;
@@ -10,38 +19,23 @@ const DEFAULT_PREFERENCES: InboxCuePreferences = {
sound: true,
};
+const INBOX_CUE_PREFERENCES_ADAPTER: CuePreferenceAdapter = {
+ storageKey: STORAGE_KEY,
+ defaults: DEFAULT_PREFERENCES,
+ missingFieldBehavior: "strictTrue",
+};
+
type StorageLike = Pick;
export function loadInboxCuePreferences(storage?: StorageLike): InboxCuePreferences {
- if (!storage) {
- return { ...DEFAULT_PREFERENCES };
- }
-
- const raw = storage.getItem(STORAGE_KEY);
- if (!raw) {
- return { ...DEFAULT_PREFERENCES };
- }
-
- try {
- const parsed = JSON.parse(raw) as Partial;
- return {
- browserNotifications: parsed.browserNotifications === true,
- sound: parsed.sound === true,
- };
- } catch {
- return { ...DEFAULT_PREFERENCES };
- }
+ return loadCuePreferences(INBOX_CUE_PREFERENCES_ADAPTER, storage);
}
export function saveInboxCuePreferences(
preferences: InboxCuePreferences,
storage?: StorageLike
): void {
- if (!storage) {
- return;
- }
-
- storage.setItem(STORAGE_KEY, JSON.stringify(preferences));
+ saveCuePreferences(INBOX_CUE_PREFERENCES_ADAPTER, preferences, storage);
}
type EventDispatcher = Pick;
@@ -56,24 +50,21 @@ export function broadcastInboxCuePreferencesUpdated(eventDispatcher?: EventDispa
export function buildUnreadSnapshot(
conversations: Array<{ _id: string; unreadByAgent?: number }>
): Record {
- return Object.fromEntries(
- conversations.map((conversation) => [conversation._id, conversation.unreadByAgent ?? 0])
- );
+ return buildSharedUnreadSnapshot({
+ conversations,
+ getUnreadCount: (conversation) => conversation.unreadByAgent,
+ });
}
export function getUnreadIncreases(args: {
previous: Record;
conversations: Array<{ _id: string; unreadByAgent?: number }>;
}): string[] {
- const increasedConversationIds: string[] = [];
- for (const conversation of args.conversations) {
- const previousUnread = args.previous[conversation._id] ?? 0;
- const nextUnread = conversation.unreadByAgent ?? 0;
- if (nextUnread > previousUnread) {
- increasedConversationIds.push(conversation._id);
- }
- }
- return increasedConversationIds;
+ return getSharedUnreadIncreases({
+ previous: args.previous,
+ conversations: args.conversations,
+ getUnreadCount: (conversation) => conversation.unreadByAgent,
+ });
}
export function shouldSuppressAttentionCue(args: {
@@ -82,9 +73,11 @@ export function shouldSuppressAttentionCue(args: {
isDocumentVisible: boolean;
hasWindowFocus: boolean;
}): boolean {
- return (
- args.selectedConversationId === args.conversationId &&
- args.isDocumentVisible &&
- args.hasWindowFocus
- );
+ return shouldSuppressUnreadAttentionCue({
+ conversationId: args.conversationId,
+ activeConversationId: args.selectedConversationId,
+ isActiveConversationView: true,
+ isDocumentVisible: args.isDocumentVisible,
+ hasWindowFocus: args.hasWindowFocus,
+ });
}
diff --git a/apps/web/src/lib/parseMarkdown.ts b/apps/web/src/lib/parseMarkdown.ts
index 6894478..5c90aae 100644
--- a/apps/web/src/lib/parseMarkdown.ts
+++ b/apps/web/src/lib/parseMarkdown.ts
@@ -1,98 +1,13 @@
-import DOMPurify from "dompurify";
-import markdownIt from "markdown-it";
+import {
+ parseMarkdown as parseSharedMarkdown,
+ type ParseMarkdownOptions,
+} from "@opencom/web-shared";
-const markdown = markdownIt({
- html: true,
- linkify: true,
- breaks: true,
-});
-
-const ALLOWED_TAGS = [
- "h1",
- "h2",
- "h3",
- "h4",
- "h5",
- "h6",
- "p",
- "br",
- "hr",
- "strong",
- "b",
- "em",
- "i",
- "code",
- "pre",
- "blockquote",
- "ul",
- "ol",
- "li",
- "a",
- "img",
-];
-
-const ALLOWED_ATTR = ["href", "target", "rel", "src", "alt", "title", "class"];
-const FRONTMATTER_BLOCK_REGEX = /^\uFEFF?---\s*\r?\n[\s\S]*?\r?\n---(?:\s*\r?\n)?/;
-
-function hasBlockedProtocol(rawUrl: string): boolean {
- const normalized = rawUrl.trim().toLowerCase();
- return (
- normalized.startsWith("javascript:") ||
- normalized.startsWith("data:") ||
- normalized.startsWith("vbscript:")
- );
-}
-
-function hasDisallowedAbsoluteProtocol(rawUrl: string): boolean {
- const match = rawUrl.trim().match(/^([a-z0-9+.-]+):/i);
- if (!match) {
- return false;
- }
- const protocol = match[1].toLowerCase();
- return protocol !== "http" && protocol !== "https";
-}
-
-function enforceSafeLinksAndMedia(html: string): string {
- const container = document.createElement("div");
- container.innerHTML = html;
-
- container.querySelectorAll("a").forEach((anchor) => {
- const href = anchor.getAttribute("href");
- if (!href || hasBlockedProtocol(href) || hasDisallowedAbsoluteProtocol(href)) {
- anchor.removeAttribute("href");
- anchor.removeAttribute("target");
- anchor.removeAttribute("rel");
- return;
- }
- anchor.setAttribute("target", "_blank");
- anchor.setAttribute("rel", "noopener noreferrer");
- });
-
- container.querySelectorAll("img").forEach((image) => {
- const src = image.getAttribute("src");
- if (!src || hasBlockedProtocol(src) || hasDisallowedAbsoluteProtocol(src)) {
- image.removeAttribute("src");
- }
- });
-
- return container.innerHTML;
-}
-
-function stripMarkdownFrontmatter(markdownInput: string): string {
- if (!markdownInput) {
- return "";
- }
- return markdownInput.replace(FRONTMATTER_BLOCK_REGEX, "");
-}
+const WEB_MARKDOWN_OPTIONS: ParseMarkdownOptions = {
+ linkTarget: "_blank",
+ linkRel: "noopener noreferrer",
+};
export function parseMarkdown(markdownInput: string): string {
- const contentWithoutFrontmatter = stripMarkdownFrontmatter(markdownInput);
- const rendered = markdown.render(contentWithoutFrontmatter);
- const sanitized = DOMPurify.sanitize(rendered, {
- ALLOWED_TAGS,
- ALLOWED_ATTR,
- FORBID_ATTR: ["style"],
- });
-
- return enforceSafeLinksAndMedia(sanitized);
+ return parseSharedMarkdown(markdownInput, WEB_MARKDOWN_OPTIONS);
}
diff --git a/apps/widget/package.json b/apps/widget/package.json
index 320ba28..b69674b 100644
--- a/apps/widget/package.json
+++ b/apps/widget/package.json
@@ -17,6 +17,7 @@
"@opencom/convex": "workspace:*",
"@opencom/sdk-core": "workspace:*",
"@opencom/types": "workspace:*",
+ "@opencom/web-shared": "workspace:*",
"convex": "^1.31.7",
"dompurify": "^3.3.1",
"markdown-it": "^14.1.1",
diff --git a/apps/widget/src/lib/widgetNotificationCues.ts b/apps/widget/src/lib/widgetNotificationCues.ts
index 67e3abc..f6160dd 100644
--- a/apps/widget/src/lib/widgetNotificationCues.ts
+++ b/apps/widget/src/lib/widgetNotificationCues.ts
@@ -1,3 +1,11 @@
+import {
+ buildUnreadSnapshot,
+ getUnreadIncreases,
+ loadCuePreferences,
+ shouldSuppressUnreadAttentionCue,
+ type CuePreferenceAdapter,
+} from "@opencom/web-shared";
+
export interface WidgetCuePreferences {
browserNotifications: boolean;
sound: boolean;
@@ -9,53 +17,36 @@ const DEFAULT_PREFERENCES: WidgetCuePreferences = {
sound: false,
};
+const WIDGET_CUE_PREFERENCES_ADAPTER: CuePreferenceAdapter = {
+ storageKey: STORAGE_KEY,
+ defaults: DEFAULT_PREFERENCES,
+ missingFieldBehavior: "defaultValue",
+};
+
type StorageLike = Pick;
export function loadWidgetCuePreferences(storage?: StorageLike): WidgetCuePreferences {
- if (!storage) {
- return { ...DEFAULT_PREFERENCES };
- }
-
- const raw = storage.getItem(STORAGE_KEY);
- if (!raw) {
- return { ...DEFAULT_PREFERENCES };
- }
-
- try {
- const parsed = JSON.parse(raw) as Partial;
- return {
- browserNotifications:
- parsed.browserNotifications !== undefined
- ? parsed.browserNotifications === true
- : DEFAULT_PREFERENCES.browserNotifications,
- sound: parsed.sound !== undefined ? parsed.sound === true : DEFAULT_PREFERENCES.sound,
- };
- } catch {
- return { ...DEFAULT_PREFERENCES };
- }
+ return loadCuePreferences(WIDGET_CUE_PREFERENCES_ADAPTER, storage);
}
export function buildWidgetUnreadSnapshot(
conversations: Array<{ _id: string; unreadByVisitor?: number }>
): Record {
- return Object.fromEntries(
- conversations.map((conversation) => [conversation._id, conversation.unreadByVisitor ?? 0])
- );
+ return buildUnreadSnapshot({
+ conversations,
+ getUnreadCount: (conversation) => conversation.unreadByVisitor,
+ });
}
export function getWidgetUnreadIncreases(args: {
previous: Record;
conversations: Array<{ _id: string; unreadByVisitor?: number }>;
}): string[] {
- const increasedConversationIds: string[] = [];
- for (const conversation of args.conversations) {
- const previousUnread = args.previous[conversation._id] ?? 0;
- const nextUnread = conversation.unreadByVisitor ?? 0;
- if (nextUnread > previousUnread) {
- increasedConversationIds.push(conversation._id);
- }
- }
- return increasedConversationIds;
+ return getUnreadIncreases({
+ previous: args.previous,
+ conversations: args.conversations,
+ getUnreadCount: (conversation) => conversation.unreadByVisitor,
+ });
}
export function shouldSuppressWidgetCue(args: {
@@ -65,10 +56,11 @@ export function shouldSuppressWidgetCue(args: {
isDocumentVisible: boolean;
hasWindowFocus: boolean;
}): boolean {
- return (
- args.widgetView === "conversation" &&
- args.activeConversationId === args.conversationId &&
- args.isDocumentVisible &&
- args.hasWindowFocus
- );
+ return shouldSuppressUnreadAttentionCue({
+ conversationId: args.conversationId,
+ activeConversationId: args.activeConversationId,
+ isActiveConversationView: args.widgetView === "conversation",
+ isDocumentVisible: args.isDocumentVisible,
+ hasWindowFocus: args.hasWindowFocus,
+ });
}
diff --git a/apps/widget/src/utils/parseMarkdown.ts b/apps/widget/src/utils/parseMarkdown.ts
index 9065b8f..0565023 100644
--- a/apps/widget/src/utils/parseMarkdown.ts
+++ b/apps/widget/src/utils/parseMarkdown.ts
@@ -1,123 +1,20 @@
-import DOMPurify from "dompurify";
-import MarkdownIt from "markdown-it";
-
-const markdown = new MarkdownIt({
- html: true,
- linkify: true,
- breaks: true,
-});
-
-const ALLOWED_TAGS = [
- "h1",
- "h2",
- "h3",
- "h4",
- "h5",
- "h6",
- "p",
- "br",
- "hr",
- "strong",
- "b",
- "em",
- "i",
- "code",
- "pre",
- "blockquote",
- "ul",
- "ol",
- "li",
- "a",
- "img",
-];
-
-const ALLOWED_ATTR = ["href", "target", "rel", "src", "alt", "title", "class"];
-const FRONTMATTER_BLOCK_REGEX = /^\uFEFF?---\s*\r?\n[\s\S]*?\r?\n---(?:\s*\r?\n)?/;
-
-function hasBlockedProtocol(rawUrl: string): boolean {
- const normalized = rawUrl.trim().toLowerCase();
- return (
- normalized.startsWith("javascript:") ||
- normalized.startsWith("data:") ||
- normalized.startsWith("vbscript:")
- );
-}
-
-function hasDisallowedAbsoluteProtocol(rawUrl: string): boolean {
- const match = rawUrl.trim().match(/^([a-z0-9+.-]+):/i);
- if (!match) {
- return false;
- }
- const protocol = match[1].toLowerCase();
- return protocol !== "http" && protocol !== "https";
-}
-
-function enforceSafeLinksAndMedia(html: string): string {
- const container = document.createElement("div");
- container.innerHTML = html;
-
- container.querySelectorAll("a").forEach((anchor) => {
- const href = anchor.getAttribute("href");
- if (!href || hasBlockedProtocol(href) || hasDisallowedAbsoluteProtocol(href)) {
- anchor.removeAttribute("href");
- anchor.removeAttribute("target");
- anchor.removeAttribute("rel");
- return;
- }
-
- anchor.setAttribute("target", "_blank");
- anchor.setAttribute("rel", "noopener noreferrer");
- });
-
- container.querySelectorAll("img").forEach((image) => {
- const src = image.getAttribute("src");
- if (!src || hasBlockedProtocol(src) || hasDisallowedAbsoluteProtocol(src)) {
- image.removeAttribute("src");
- }
- });
-
- return container.innerHTML;
-}
-
-export function stripMarkdownFrontmatter(markdownInput: string): string {
- if (!markdownInput) {
- return "";
- }
-
- return markdownInput.replace(FRONTMATTER_BLOCK_REGEX, "");
-}
-
-function htmlToPlainText(html: string): string {
- if (typeof document === "undefined") {
- return html.replace(/<[^>]*>/g, " ");
- }
-
- const container = document.createElement("div");
- container.innerHTML = html;
- return container.textContent ?? "";
-}
-
+import {
+ parseMarkdown as parseSharedMarkdown,
+ stripMarkdownFrontmatter as stripSharedMarkdownFrontmatter,
+ toPlainTextExcerpt as buildSharedPlainTextExcerpt,
+ type ParseMarkdownOptions,
+} from "@opencom/web-shared";
+
+const WIDGET_MARKDOWN_OPTIONS: ParseMarkdownOptions = {
+ linkTarget: "_blank",
+ linkRel: "noopener noreferrer",
+};
+
+export const stripMarkdownFrontmatter = stripSharedMarkdownFrontmatter;
export function toPlainTextExcerpt(markdownInput: string, maxLength = 100): string {
- const safeMaxLength = Math.max(1, maxLength);
- const contentWithoutFrontmatter = stripMarkdownFrontmatter(markdownInput);
- const rendered = markdown.render(contentWithoutFrontmatter);
- const normalizedText = htmlToPlainText(rendered).replace(/\s+/g, " ").trim();
-
- if (normalizedText.length <= safeMaxLength) {
- return normalizedText;
- }
-
- return `${normalizedText.slice(0, safeMaxLength).trimEnd()}...`;
+ return buildSharedPlainTextExcerpt(markdownInput, maxLength);
}
export function parseMarkdown(markdownInput: string): string {
- const contentWithoutFrontmatter = stripMarkdownFrontmatter(markdownInput);
- const rendered = markdown.render(contentWithoutFrontmatter);
- const sanitized = DOMPurify.sanitize(rendered, {
- ALLOWED_TAGS: ALLOWED_TAGS,
- ALLOWED_ATTR: ALLOWED_ATTR,
- FORBID_ATTR: ["style"],
- });
-
- return enforceSafeLinksAndMedia(sanitized);
+ return parseSharedMarkdown(markdownInput, WIDGET_MARKDOWN_OPTIONS);
}
diff --git a/openspec/changes/unify-inbox-widget-notification-cues/tasks.md b/openspec/changes/unify-inbox-widget-notification-cues/tasks.md
index 970224e..b530f7b 100644
--- a/openspec/changes/unify-inbox-widget-notification-cues/tasks.md
+++ b/openspec/changes/unify-inbox-widget-notification-cues/tasks.md
@@ -1,20 +1,20 @@
## 1. Shared Core Extraction
-- [ ] 1.1 Create shared pure cue utilities for unread snapshots, increases, and suppression predicates.
-- [ ] 1.2 Define adapter contracts for surface-specific preference persistence and defaults.
+- [x] 1.1 Create shared pure cue utilities for unread snapshots, increases, and suppression predicates.
+- [x] 1.2 Define adapter contracts for surface-specific preference persistence and defaults.
## 2. Surface Integration
-- [ ] 2.1 Refactor web inbox cue utility to consume shared core functions.
-- [ ] 2.2 Refactor widget cue utility to consume shared core functions.
-- [ ] 2.3 Preserve current surface storage keys and default preference behavior.
+- [x] 2.1 Refactor web inbox cue utility to consume shared core functions.
+- [x] 2.2 Refactor widget cue utility to consume shared core functions.
+- [x] 2.3 Preserve current surface storage keys and default preference behavior.
## 3. Verification
-- [ ] 3.1 Add shared test vectors for unread increase and suppression invariants.
-- [ ] 3.2 Run targeted web/widget tests that cover cue-triggered behavior.
+- [x] 3.1 Add shared test vectors for unread increase and suppression invariants.
+- [x] 3.2 Run targeted web/widget tests that cover cue-triggered behavior.
## 4. Cleanup
-- [ ] 4.1 Remove duplicated core cue logic from surface-local files.
-- [ ] 4.2 Document shared cue-core ownership and extension guidance.
+- [x] 4.1 Remove duplicated core cue logic from surface-local files.
+- [x] 4.2 Document shared cue-core ownership and extension guidance.
diff --git a/openspec/changes/unify-markdown-rendering-utility/tasks.md b/openspec/changes/unify-markdown-rendering-utility/tasks.md
index 37cd4ab..ddc7029 100644
--- a/openspec/changes/unify-markdown-rendering-utility/tasks.md
+++ b/openspec/changes/unify-markdown-rendering-utility/tasks.md
@@ -1,20 +1,20 @@
## 1. Shared Utility Introduction
-- [ ] 1.1 Create a shared markdown utility module with parser, sanitization, and helper exports.
-- [ ] 1.2 Define explicit options for any intentional per-surface rendering differences.
+- [x] 1.1 Create a shared markdown utility module with parser, sanitization, and helper exports.
+- [x] 1.2 Define explicit options for any intentional per-surface rendering differences.
## 2. Surface Migration
-- [ ] 2.1 Migrate web markdown parser imports to the shared utility.
-- [ ] 2.2 Migrate widget markdown parser imports to the shared utility.
-- [ ] 2.3 Remove duplicated parser/sanitizer code from surface-local files.
+- [x] 2.1 Migrate web markdown parser imports to the shared utility.
+- [x] 2.2 Migrate widget markdown parser imports to the shared utility.
+- [x] 2.3 Remove duplicated parser/sanitizer code from surface-local files.
## 3. Verification
-- [ ] 3.1 Add shared parity tests for representative markdown and sanitization vectors.
-- [ ] 3.2 Run targeted web/widget typecheck and tests for markdown-consuming paths.
+- [x] 3.1 Add shared parity tests for representative markdown and sanitization vectors.
+- [x] 3.2 Run targeted web/widget typecheck and tests for markdown-consuming paths.
## 4. Cleanup
-- [ ] 4.1 Document ownership of the shared markdown utility and extension rules.
-- [ ] 4.2 Confirm no remaining duplicate markdown sanitizer implementations in app code.
+- [x] 4.1 Document ownership of the shared markdown utility and extension rules.
+- [x] 4.2 Confirm no remaining duplicate markdown sanitizer implementations in app code.
diff --git a/packages/web-shared/README.md b/packages/web-shared/README.md
new file mode 100644
index 0000000..1ce72e2
--- /dev/null
+++ b/packages/web-shared/README.md
@@ -0,0 +1,42 @@
+# @opencom/web-shared
+
+Shared browser-focused utilities for `apps/web` and `apps/widget`.
+
+## Markdown Utility Ownership
+
+- Source of truth for markdown rendering and sanitization lives in `src/markdown.ts`.
+- Web and widget must use this package instead of maintaining local parser/sanitizer copies.
+- Shared behavior covered here:
+ - markdown-it parser configuration
+ - frontmatter stripping
+ - DOMPurify allowlist and style-forbid policy
+ - protocol hardening for links and images
+ - plain-text excerpt helper
+
+## Extension Rules
+
+- Keep one canonical sanitization policy in this package.
+- Surface-specific behavior must be explicit options passed to `parseMarkdown`:
+ - `linkTarget`
+ - `linkRel`
+- Do not fork parser/sanitizer logic in `apps/web` or `apps/widget`.
+- If a surface needs new behavior, add an option + shared tests in this package first.
+
+## Notification Cue Core Ownership
+
+- Source of truth for unread cue algorithms lives in `src/notificationCues.ts`.
+- Shared behavior covered here:
+ - unread snapshot construction
+ - unread increase detection
+ - focus/visibility suppression predicate
+ - preference adapter contract for storage/default behavior
+
+## Notification Cue Extension Rules
+
+- Keep unread math and suppression predicates in `@opencom/web-shared`.
+- Surface differences must stay in adapters only:
+ - storage keys
+ - default preference values
+ - missing-field behavior for legacy payloads
+- Do not re-implement snapshot/increase/suppression loops in app-level cue files.
+- Add shared invariant tests in this package before changing cue logic.
diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json
new file mode 100644
index 0000000..e10e4ff
--- /dev/null
+++ b/packages/web-shared/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@opencom/web-shared",
+ "version": "0.1.0",
+ "private": true,
+ "main": "./src/index.ts",
+ "module": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run --config ./vitest.config.ts",
+ "test:watch": "vitest --config ./vitest.config.ts"
+ },
+ "dependencies": {
+ "dompurify": "^3.3.1",
+ "markdown-it": "^14.1.1"
+ },
+ "devDependencies": {
+ "@types/markdown-it": "^14.1.2",
+ "jsdom": "^26.1.0",
+ "typescript": "^5.3.0",
+ "vitest": "^4.0.17"
+ }
+}
diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts
new file mode 100644
index 0000000..592cc1f
--- /dev/null
+++ b/packages/web-shared/src/index.ts
@@ -0,0 +1,15 @@
+export {
+ parseMarkdown,
+ stripMarkdownFrontmatter,
+ toPlainTextExcerpt,
+ type ParseMarkdownOptions,
+} from "./markdown";
+export {
+ buildUnreadSnapshot,
+ getUnreadIncreases,
+ loadCuePreferences,
+ saveCuePreferences,
+ shouldSuppressUnreadAttentionCue,
+ type CuePreferenceAdapter,
+ type CuePreferences,
+} from "./notificationCues";
diff --git a/packages/web-shared/src/markdown.test.ts b/packages/web-shared/src/markdown.test.ts
new file mode 100644
index 0000000..8c52c43
--- /dev/null
+++ b/packages/web-shared/src/markdown.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { parseMarkdown, stripMarkdownFrontmatter, toPlainTextExcerpt } from "./markdown";
+
+describe("parseMarkdown", () => {
+ it("renders standard markdown content", () => {
+ const html = parseMarkdown("# Title\n\n- one\n- two\n\n`const x = 1`");
+ expect(html).toContain("Title
");
+ expect(html).toContain("");
+ expect(html).toContain("const x = 1");
+ });
+
+ it("strips dangerous protocols from links and media", () => {
+ const html = parseMarkdown(
+ '[bad](javascript:alert(1)) [worse](data:text/html;base64,abc)
'
+ );
+ expect(html).not.toMatch(/href="(?:javascript|data|vbscript):/i);
+ expect(html).not.toMatch(/src="(?:javascript|data|vbscript):/i);
+ });
+
+ it("keeps safe links and enforces hardened link attributes", () => {
+ const html = parseMarkdown("[Docs](https://example.com/docs)");
+ expect(html).toContain('href="https://example.com/docs"');
+ expect(html).toContain('target="_blank"');
+ expect(html).toContain('rel="noopener noreferrer"');
+ });
+
+ it("supports explicit surface-level link options", () => {
+ const html = parseMarkdown("[Docs](https://example.com/docs)", {
+ linkTarget: null,
+ linkRel: null,
+ });
+ expect(html).toContain('href="https://example.com/docs"');
+ expect(html).not.toContain("target=");
+ expect(html).not.toContain("rel=");
+ });
+});
+
+describe("frontmatter and excerpt helpers", () => {
+ const contentWithFrontmatter = [
+ "---",
+ 'title: "Hosted Quick Start"',
+ "---",
+ "",
+ "# Hosted Quick Start",
+ "",
+ "Install the widget and configure your workspace.",
+ ].join("\n");
+
+ it("strips frontmatter before rendering", () => {
+ const stripped = stripMarkdownFrontmatter(contentWithFrontmatter);
+ expect(stripped).toContain("# Hosted Quick Start");
+ expect(stripped).not.toContain("title:");
+ });
+
+ it("creates plain-text excerpts from markdown", () => {
+ const excerpt = toPlainTextExcerpt(contentWithFrontmatter, 80);
+ expect(excerpt).toContain("Hosted Quick Start");
+ expect(excerpt).toContain("Install the widget");
+ expect(excerpt).not.toContain("---");
+ expect(excerpt).not.toContain("title:");
+ });
+});
diff --git a/packages/web-shared/src/markdown.ts b/packages/web-shared/src/markdown.ts
new file mode 100644
index 0000000..9335951
--- /dev/null
+++ b/packages/web-shared/src/markdown.ts
@@ -0,0 +1,165 @@
+import DOMPurify from "dompurify";
+import MarkdownIt from "markdown-it";
+
+const markdown = new MarkdownIt({
+ html: true,
+ linkify: true,
+ breaks: true,
+});
+
+const ALLOWED_TAGS = [
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "p",
+ "br",
+ "hr",
+ "strong",
+ "b",
+ "em",
+ "i",
+ "code",
+ "pre",
+ "blockquote",
+ "ul",
+ "ol",
+ "li",
+ "a",
+ "img",
+];
+
+const ALLOWED_ATTR = ["href", "target", "rel", "src", "alt", "title", "class"];
+const FRONTMATTER_BLOCK_REGEX = /^\uFEFF?---\s*\r?\n[\s\S]*?\r?\n---(?:\s*\r?\n)?/;
+
+type ResolvedParseMarkdownOptions = {
+ linkTarget: string | null;
+ linkRel: string | null;
+};
+
+const DEFAULT_PARSE_MARKDOWN_OPTIONS: ResolvedParseMarkdownOptions = {
+ linkTarget: "_blank",
+ linkRel: "noopener noreferrer",
+};
+
+export interface ParseMarkdownOptions {
+ linkTarget?: string | null;
+ linkRel?: string | null;
+}
+
+function resolveParseMarkdownOptions(
+ options: ParseMarkdownOptions | undefined
+): ResolvedParseMarkdownOptions {
+ return {
+ linkTarget:
+ options?.linkTarget === undefined
+ ? DEFAULT_PARSE_MARKDOWN_OPTIONS.linkTarget
+ : options.linkTarget,
+ linkRel:
+ options?.linkRel === undefined ? DEFAULT_PARSE_MARKDOWN_OPTIONS.linkRel : options.linkRel,
+ };
+}
+
+function hasBlockedProtocol(rawUrl: string): boolean {
+ const normalized = rawUrl.trim().toLowerCase();
+ return (
+ normalized.startsWith("javascript:") ||
+ normalized.startsWith("data:") ||
+ normalized.startsWith("vbscript:")
+ );
+}
+
+function hasDisallowedAbsoluteProtocol(rawUrl: string): boolean {
+ const match = rawUrl.trim().match(/^([a-z0-9+.-]+):/i);
+ if (!match) {
+ return false;
+ }
+ const protocol = match[1].toLowerCase();
+ return protocol !== "http" && protocol !== "https";
+}
+
+function enforceSafeLinksAndMedia(
+ html: string,
+ options: ResolvedParseMarkdownOptions
+): string {
+ const container = document.createElement("div");
+ container.innerHTML = html;
+
+ container.querySelectorAll("a").forEach((anchor) => {
+ const href = anchor.getAttribute("href");
+ if (!href || hasBlockedProtocol(href) || hasDisallowedAbsoluteProtocol(href)) {
+ anchor.removeAttribute("href");
+ anchor.removeAttribute("target");
+ anchor.removeAttribute("rel");
+ return;
+ }
+
+ if (options.linkTarget === null) {
+ anchor.removeAttribute("target");
+ } else {
+ anchor.setAttribute("target", options.linkTarget);
+ }
+
+ if (options.linkRel === null) {
+ anchor.removeAttribute("rel");
+ } else {
+ anchor.setAttribute("rel", options.linkRel);
+ }
+ });
+
+ container.querySelectorAll("img").forEach((image) => {
+ const src = image.getAttribute("src");
+ if (!src || hasBlockedProtocol(src) || hasDisallowedAbsoluteProtocol(src)) {
+ image.removeAttribute("src");
+ }
+ });
+
+ return container.innerHTML;
+}
+
+export function stripMarkdownFrontmatter(markdownInput: string): string {
+ if (!markdownInput) {
+ return "";
+ }
+ return markdownInput.replace(FRONTMATTER_BLOCK_REGEX, "");
+}
+
+function htmlToPlainText(html: string): string {
+ if (typeof document === "undefined") {
+ return html.replace(/<[^>]*>/g, " ");
+ }
+
+ const container = document.createElement("div");
+ container.innerHTML = html;
+ return container.textContent ?? "";
+}
+
+export function toPlainTextExcerpt(markdownInput: string, maxLength = 100): string {
+ const safeMaxLength = Math.max(1, maxLength);
+ const contentWithoutFrontmatter = stripMarkdownFrontmatter(markdownInput);
+ const rendered = markdown.render(contentWithoutFrontmatter);
+ const normalizedText = htmlToPlainText(rendered).replace(/\s+/g, " ").trim();
+
+ if (normalizedText.length <= safeMaxLength) {
+ return normalizedText;
+ }
+
+ return `${normalizedText.slice(0, safeMaxLength).trimEnd()}...`;
+}
+
+export function parseMarkdown(
+ markdownInput: string,
+ options?: ParseMarkdownOptions
+): string {
+ const contentWithoutFrontmatter = stripMarkdownFrontmatter(markdownInput);
+ const rendered = markdown.render(contentWithoutFrontmatter);
+ const sanitized = DOMPurify.sanitize(rendered, {
+ ALLOWED_TAGS,
+ ALLOWED_ATTR,
+ FORBID_ATTR: ["style"],
+ });
+
+ return enforceSafeLinksAndMedia(sanitized, resolveParseMarkdownOptions(options));
+}
diff --git a/packages/web-shared/src/notificationCues.test.ts b/packages/web-shared/src/notificationCues.test.ts
new file mode 100644
index 0000000..ee06548
--- /dev/null
+++ b/packages/web-shared/src/notificationCues.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildUnreadSnapshot,
+ getUnreadIncreases,
+ loadCuePreferences,
+ saveCuePreferences,
+ shouldSuppressUnreadAttentionCue,
+ type CuePreferenceAdapter,
+ type CuePreferences,
+} from "./notificationCues";
+
+function createStorage(initial: Record = {}) {
+ const values = new Map(Object.entries(initial));
+ return {
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ values.set(key, value);
+ },
+ };
+}
+
+describe("preference adapter behavior", () => {
+ const webAdapter: CuePreferenceAdapter = {
+ storageKey: "web",
+ defaults: {
+ browserNotifications: false,
+ sound: true,
+ },
+ missingFieldBehavior: "strictTrue",
+ };
+
+ const widgetAdapter: CuePreferenceAdapter = {
+ storageKey: "widget",
+ defaults: {
+ browserNotifications: true,
+ sound: false,
+ },
+ missingFieldBehavior: "defaultValue",
+ };
+
+ it("loads defaults when storage is missing or malformed", () => {
+ expect(loadCuePreferences(webAdapter)).toEqual(webAdapter.defaults);
+ expect(loadCuePreferences(webAdapter, createStorage({ web: "invalid" } as Record))).toEqual(
+ webAdapter.defaults
+ );
+ });
+
+ it("supports strictTrue and defaultValue missing-field strategies", () => {
+ const storage = createStorage({
+ web: JSON.stringify({ browserNotifications: true }),
+ widget: JSON.stringify({ browserNotifications: true }),
+ });
+
+ expect(loadCuePreferences(webAdapter, storage)).toEqual({
+ browserNotifications: true,
+ sound: false,
+ });
+ expect(loadCuePreferences(widgetAdapter, storage)).toEqual({
+ browserNotifications: true,
+ sound: false,
+ });
+ });
+
+ it("saves preferences using adapter storage key", () => {
+ const storage = createStorage();
+ const nextPreferences: CuePreferences = {
+ browserNotifications: true,
+ sound: true,
+ };
+
+ saveCuePreferences(webAdapter, nextPreferences, storage);
+
+ expect(loadCuePreferences(webAdapter, storage)).toEqual(nextPreferences);
+ });
+});
+
+describe("unread snapshot and increase invariants", () => {
+ it("builds snapshots from accessor-selected unread fields", () => {
+ const conversations = [
+ { _id: "a", unreadByAgent: 2, unreadByVisitor: 0 },
+ { _id: "b", unreadByAgent: 1, unreadByVisitor: 4 },
+ ];
+
+ expect(
+ buildUnreadSnapshot({
+ conversations,
+ getUnreadCount: (conversation) => conversation.unreadByAgent,
+ })
+ ).toEqual({ a: 2, b: 1 });
+
+ expect(
+ buildUnreadSnapshot({
+ conversations,
+ getUnreadCount: (conversation) => conversation.unreadByVisitor,
+ })
+ ).toEqual({ a: 0, b: 4 });
+ });
+
+ it("detects increases while ignoring unchanged or decreased counts", () => {
+ const previous = { a: 1, b: 3 };
+ const conversations = [
+ { _id: "a", unread: 2 },
+ { _id: "b", unread: 1 },
+ { _id: "c", unread: 1 },
+ ];
+
+ expect(
+ getUnreadIncreases({
+ previous,
+ conversations,
+ getUnreadCount: (conversation) => conversation.unread,
+ })
+ ).toEqual(["a", "c"]);
+ });
+});
+
+describe("suppression predicate invariants", () => {
+ it("suppresses only for active focused visible conversation views", () => {
+ expect(
+ shouldSuppressUnreadAttentionCue({
+ conversationId: "c1",
+ activeConversationId: "c1",
+ isActiveConversationView: true,
+ isDocumentVisible: true,
+ hasWindowFocus: true,
+ })
+ ).toBe(true);
+
+ expect(
+ shouldSuppressUnreadAttentionCue({
+ conversationId: "c1",
+ activeConversationId: "c2",
+ isActiveConversationView: true,
+ isDocumentVisible: true,
+ hasWindowFocus: true,
+ })
+ ).toBe(false);
+
+ expect(
+ shouldSuppressUnreadAttentionCue({
+ conversationId: "c1",
+ activeConversationId: "c1",
+ isActiveConversationView: false,
+ isDocumentVisible: true,
+ hasWindowFocus: true,
+ })
+ ).toBe(false);
+ });
+});
diff --git a/packages/web-shared/src/notificationCues.ts b/packages/web-shared/src/notificationCues.ts
new file mode 100644
index 0000000..bec5b5c
--- /dev/null
+++ b/packages/web-shared/src/notificationCues.ts
@@ -0,0 +1,110 @@
+export interface CuePreferences {
+ browserNotifications: boolean;
+ sound: boolean;
+}
+
+export interface CuePreferenceAdapter {
+ storageKey: string;
+ defaults: CuePreferences;
+ missingFieldBehavior: "strictTrue" | "defaultValue";
+}
+
+type StorageReader = Pick;
+type StorageWriter = Pick;
+
+type ConversationWithId = { _id: string };
+
+function readBooleanValue(args: {
+ parsedValue: unknown;
+ defaultValue: boolean;
+ behavior: CuePreferenceAdapter["missingFieldBehavior"];
+}): boolean {
+ if (args.parsedValue === undefined) {
+ return args.behavior === "defaultValue" ? args.defaultValue : false;
+ }
+ return args.parsedValue === true;
+}
+
+export function loadCuePreferences(
+ adapter: CuePreferenceAdapter,
+ storage?: StorageReader
+): CuePreferences {
+ if (!storage) {
+ return { ...adapter.defaults };
+ }
+
+ const raw = storage.getItem(adapter.storageKey);
+ if (!raw) {
+ return { ...adapter.defaults };
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ return {
+ browserNotifications: readBooleanValue({
+ parsedValue: parsed.browserNotifications,
+ defaultValue: adapter.defaults.browserNotifications,
+ behavior: adapter.missingFieldBehavior,
+ }),
+ sound: readBooleanValue({
+ parsedValue: parsed.sound,
+ defaultValue: adapter.defaults.sound,
+ behavior: adapter.missingFieldBehavior,
+ }),
+ };
+ } catch {
+ return { ...adapter.defaults };
+ }
+}
+
+export function saveCuePreferences(
+ adapter: CuePreferenceAdapter,
+ preferences: CuePreferences,
+ storage?: StorageWriter
+): void {
+ if (!storage) {
+ return;
+ }
+
+ storage.setItem(adapter.storageKey, JSON.stringify(preferences));
+}
+
+export function buildUnreadSnapshot(args: {
+ conversations: TConversation[];
+ getUnreadCount: (conversation: TConversation) => number | undefined;
+}): Record {
+ return Object.fromEntries(
+ args.conversations.map((conversation) => [conversation._id, args.getUnreadCount(conversation) ?? 0])
+ );
+}
+
+export function getUnreadIncreases(args: {
+ previous: Record;
+ conversations: TConversation[];
+ getUnreadCount: (conversation: TConversation) => number | undefined;
+}): string[] {
+ const increasedConversationIds: string[] = [];
+ for (const conversation of args.conversations) {
+ const previousUnread = args.previous[conversation._id] ?? 0;
+ const nextUnread = args.getUnreadCount(conversation) ?? 0;
+ if (nextUnread > previousUnread) {
+ increasedConversationIds.push(conversation._id);
+ }
+ }
+ return increasedConversationIds;
+}
+
+export function shouldSuppressUnreadAttentionCue(args: {
+ conversationId: string;
+ activeConversationId: string | null;
+ isActiveConversationView: boolean;
+ isDocumentVisible: boolean;
+ hasWindowFocus: boolean;
+}): boolean {
+ return (
+ args.isActiveConversationView &&
+ args.activeConversationId === args.conversationId &&
+ args.isDocumentVisible &&
+ args.hasWindowFocus
+ );
+}
diff --git a/packages/web-shared/tsconfig.json b/packages/web-shared/tsconfig.json
new file mode 100644
index 0000000..90d76d7
--- /dev/null
+++ b/packages/web-shared/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/web-shared/vitest.config.ts b/packages/web-shared/vitest.config.ts
new file mode 100644
index 0000000..ef15c61
--- /dev/null
+++ b/packages/web-shared/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: "jsdom",
+ include: ["src/**/*.test.ts"],
+ exclude: ["**/node_modules/**", "**/dist/**"],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 73a0190..3c3afc3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -220,6 +220,9 @@ importers:
'@opencom/ui':
specifier: workspace:*
version: link:../../packages/ui
+ '@opencom/web-shared':
+ specifier: workspace:*
+ version: link:../../packages/web-shared
convex:
specifier: ^1.31.7
version: 1.31.7(react@19.2.3)
@@ -296,6 +299,9 @@ importers:
'@opencom/types':
specifier: workspace:*
version: link:../../packages/types
+ '@opencom/web-shared':
+ specifier: workspace:*
+ version: link:../../packages/web-shared
convex:
specifier: ^1.31.7
version: 1.31.7(react@19.2.3)
@@ -558,6 +564,28 @@ importers:
specifier: ^5.3.0
version: 5.9.3
+ packages/web-shared:
+ dependencies:
+ dompurify:
+ specifier: ^3.3.1
+ version: 3.3.1
+ markdown-it:
+ specifier: ^14.1.1
+ version: 14.1.1
+ devDependencies:
+ '@types/markdown-it':
+ specifier: ^14.1.2
+ version: 14.1.2
+ jsdom:
+ specifier: ^26.1.0
+ version: 26.1.0
+ typescript:
+ specifier: ^5.3.0
+ version: 5.9.3
+ vitest:
+ specifier: ^4.0.17
+ version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.30)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+
packages:
'@0no-co/graphql.web@1.2.0':
From f6662a5026139768b103b5e9969aed1534c98119 Mon Sep 17 00:00:00 2001
From: Jack D
Date: Thu, 5 Mar 2026 14:13:52 +0000
Subject: [PATCH 03/91] decompose web inbox page
---
apps/web/src/app/inbox/README.md | 43 ++
.../hooks/useInboxAttentionCues.test.tsx | 124 +++++
.../app/inbox/hooks/useInboxAttentionCues.ts | 172 +++++++
.../hooks/useInboxCompactPanels.test.tsx | 123 +++++
.../app/inbox/hooks/useInboxCompactPanels.ts | 90 ++++
.../app/inbox/hooks/useInboxMessageActions.ts | 215 ++++++++
.../hooks/useInboxSelectionSync.test.tsx | 143 ++++++
.../app/inbox/hooks/useInboxSelectionSync.ts | 147 ++++++
.../inbox/hooks/useInboxSuggestionsCount.ts | 73 +++
apps/web/src/app/inbox/page.tsx | 483 +++---------------
.../changes/decompose-web-inbox-page/tasks.md | 26 +-
11 files changed, 1227 insertions(+), 412 deletions(-)
create mode 100644 apps/web/src/app/inbox/README.md
create mode 100644 apps/web/src/app/inbox/hooks/useInboxAttentionCues.test.tsx
create mode 100644 apps/web/src/app/inbox/hooks/useInboxAttentionCues.ts
create mode 100644 apps/web/src/app/inbox/hooks/useInboxCompactPanels.test.tsx
create mode 100644 apps/web/src/app/inbox/hooks/useInboxCompactPanels.ts
create mode 100644 apps/web/src/app/inbox/hooks/useInboxMessageActions.ts
create mode 100644 apps/web/src/app/inbox/hooks/useInboxSelectionSync.test.tsx
create mode 100644 apps/web/src/app/inbox/hooks/useInboxSelectionSync.ts
create mode 100644 apps/web/src/app/inbox/hooks/useInboxSuggestionsCount.ts
diff --git a/apps/web/src/app/inbox/README.md b/apps/web/src/app/inbox/README.md
new file mode 100644
index 0000000..06c4fc6
--- /dev/null
+++ b/apps/web/src/app/inbox/README.md
@@ -0,0 +1,43 @@
+# Inbox Orchestration Modules
+
+This route keeps UI composition in [`page.tsx`](./page.tsx) and pushes behavior into focused domain hooks under `hooks/`.
+
+## Ownership Map
+
+- Selection and URL sync:
+ [`useInboxSelectionSync.ts`](./hooks/useInboxSelectionSync.ts)
+ owns `conversationId` / legacy `conversation` query handling, selected conversation reconciliation, and route updates.
+- Compact panel behavior:
+ [`useInboxCompactPanels.ts`](./hooks/useInboxCompactPanels.ts)
+ owns open/close/reset rules for `ai-review` and `suggestions` panels.
+- Suggestions count loading:
+ [`useInboxSuggestionsCount.ts`](./hooks/useInboxSuggestionsCount.ts)
+ owns async count fetching and loading/error fallback behavior.
+- Attention cues and title updates:
+ [`useInboxAttentionCues.ts`](./hooks/useInboxAttentionCues.ts)
+ owns unread snapshot comparisons, suppression checks, sound/browser cues, and document title updates.
+- Message actions:
+ [`useInboxMessageActions.ts`](./hooks/useInboxMessageActions.ts)
+ owns optimistic action flows for select/read, send, resolve, and convert-to-ticket.
+
+## Extension Points
+
+- Add new query-driven selection semantics in `useInboxSelectionSync` without adding URL logic back into `page.tsx`.
+- Add new compact-panel variants by extending `InboxCompactPanel` and reset helpers in `useInboxCompactPanels`.
+- Add additional cue channels (for example, toast or haptics) in `useInboxAttentionCues`, reusing suppression gates.
+- Add new message-level mutations in `useInboxMessageActions`, keeping optimistic patching and rollback local to that module.
+
+## Cross-Surface Constraints
+
+- Web inbox refactor must not change Convex API signatures or shared domain contracts.
+- `apps/mobile`, `packages/sdk-core`, and `packages/sdk-react-native` remain unaffected by this decomposition because no shared payload/schema or SDK interface was changed.
+- Any future cross-surface behavior change should land first in shared packages/specs, then be consumed by web/mobile/widget.
+
+## Tests
+
+- Selection-sync invariants:
+ [`useInboxSelectionSync.test.tsx`](./hooks/useInboxSelectionSync.test.tsx)
+- Compact-panel reset behavior:
+ [`useInboxCompactPanels.test.tsx`](./hooks/useInboxCompactPanels.test.tsx)
+- Attention-cue suppression and title behavior:
+ [`useInboxAttentionCues.test.tsx`](./hooks/useInboxAttentionCues.test.tsx)
diff --git a/apps/web/src/app/inbox/hooks/useInboxAttentionCues.test.tsx b/apps/web/src/app/inbox/hooks/useInboxAttentionCues.test.tsx
new file mode 100644
index 0000000..5956d41
--- /dev/null
+++ b/apps/web/src/app/inbox/hooks/useInboxAttentionCues.test.tsx
@@ -0,0 +1,124 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Id } from "@opencom/convex/dataModel";
+
+const playInboxBingSoundMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@/lib/playInboxBingSound", () => ({
+ playInboxBingSound: playInboxBingSoundMock,
+}));
+
+import {
+ computeInboxTitle,
+ getUnsuppressedUnreadIncreases,
+ useInboxAttentionCues,
+} from "./useInboxAttentionCues";
+
+function conversationId(value: string): Id<"conversations"> {
+ return value as Id<"conversations">;
+}
+
+function createConversation(args: { id: string; unreadByAgent: number; content?: string | null }) {
+ return {
+ _id: conversationId(args.id),
+ unreadByAgent: args.unreadByAgent,
+ lastMessage: args.content ? { content: args.content } : null,
+ visitor: { name: "Visitor" },
+ visitorId: "visitor-id" as Id<"visitors">,
+ };
+}
+
+describe("useInboxAttentionCues", () => {
+ beforeEach(() => {
+ playInboxBingSoundMock.mockReset();
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ value: "visible",
+ });
+ vi.spyOn(document, "hasFocus").mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("computes title with unread badge prefix", () => {
+ expect(computeInboxTitle({ totalUnread: 0, baseTitle: "Inbox" })).toBe("Inbox");
+ expect(computeInboxTitle({ totalUnread: 4, baseTitle: "Inbox" })).toBe("(4) Inbox");
+ });
+
+ it("filters out suppressed increases for selected visible conversation", () => {
+ expect(
+ getUnsuppressedUnreadIncreases({
+ increasedConversationIds: ["conv-1", "conv-2"],
+ selectedConversationId: "conv-1",
+ isDocumentVisible: true,
+ hasWindowFocus: true,
+ })
+ ).toEqual(["conv-2"]);
+ });
+
+ it("updates document title from unread total and restores base title on unmount", () => {
+ document.title = "Inbox Dashboard";
+
+ const { rerender, unmount } = renderHook(
+ (props: { unreadA: number; unreadB: number }) =>
+ useInboxAttentionCues({
+ conversations: [
+ createConversation({ id: "conv-a", unreadByAgent: props.unreadA }),
+ createConversation({ id: "conv-b", unreadByAgent: props.unreadB }),
+ ],
+ selectedConversationId: null,
+ getConversationIdentityLabel: () => "Visitor",
+ onOpenConversation: vi.fn(),
+ }),
+ {
+ initialProps: {
+ unreadA: 1,
+ unreadB: 2,
+ },
+ }
+ );
+
+ expect(document.title).toBe("(3) Inbox Dashboard");
+
+ rerender({
+ unreadA: 0,
+ unreadB: 0,
+ });
+ expect(document.title).toBe("Inbox Dashboard");
+
+ unmount();
+ expect(document.title).toBe("Inbox Dashboard");
+ });
+
+ it("suppresses cue for selected focused thread but cues other increased threads", async () => {
+ const { rerender } = renderHook(
+ (props: { unreadA: number; unreadB: number }) =>
+ useInboxAttentionCues({
+ conversations: [
+ createConversation({ id: "conv-a", unreadByAgent: props.unreadA, content: "A" }),
+ createConversation({ id: "conv-b", unreadByAgent: props.unreadB, content: "B" }),
+ ],
+ selectedConversationId: conversationId("conv-a"),
+ getConversationIdentityLabel: () => "Visitor",
+ onOpenConversation: vi.fn(),
+ }),
+ {
+ initialProps: {
+ unreadA: 0,
+ unreadB: 0,
+ },
+ }
+ );
+
+ rerender({
+ unreadA: 1,
+ unreadB: 1,
+ });
+
+ await waitFor(() => {
+ expect(playInboxBingSoundMock).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/apps/web/src/app/inbox/hooks/useInboxAttentionCues.ts b/apps/web/src/app/inbox/hooks/useInboxAttentionCues.ts
new file mode 100644
index 0000000..51651c4
--- /dev/null
+++ b/apps/web/src/app/inbox/hooks/useInboxAttentionCues.ts
@@ -0,0 +1,172 @@
+import { useEffect, useRef } from "react";
+import type { Id } from "@opencom/convex/dataModel";
+import {
+ INBOX_CUE_PREFERENCES_UPDATED_EVENT,
+ buildUnreadSnapshot,
+ getUnreadIncreases,
+ loadInboxCuePreferences,
+ shouldSuppressAttentionCue,
+} from "@/lib/inboxNotificationCues";
+import { playInboxBingSound } from "@/lib/playInboxBingSound";
+
+type InboxCueConversation = {
+ _id: Id<"conversations">;
+ unreadByAgent?: number;
+ lastMessage?: {
+ content?: string | null;
+ } | null;
+ visitor?: { name?: string; email?: string; readableId?: string } | null;
+ visitorId?: Id<"visitors">;
+};
+
+export interface UseInboxAttentionCuesArgs {
+ conversations: InboxCueConversation[] | undefined;
+ selectedConversationId: Id<"conversations"> | null;
+ getConversationIdentityLabel: (conversation: InboxCueConversation) => string;
+ onOpenConversation: (conversationId: Id<"conversations">) => void;
+}
+
+export function computeInboxTitle(args: {
+ totalUnread: number;
+ baseTitle: string;
+}): string {
+ return args.totalUnread > 0 ? `(${args.totalUnread}) ${args.baseTitle}` : args.baseTitle;
+}
+
+export function getUnsuppressedUnreadIncreases(args: {
+ increasedConversationIds: string[];
+ selectedConversationId: string | null;
+ isDocumentVisible: boolean;
+ hasWindowFocus: boolean;
+}): string[] {
+ return args.increasedConversationIds.filter(
+ (conversationId) =>
+ !shouldSuppressAttentionCue({
+ conversationId,
+ selectedConversationId: args.selectedConversationId,
+ isDocumentVisible: args.isDocumentVisible,
+ hasWindowFocus: args.hasWindowFocus,
+ })
+ );
+}
+
+export function useInboxAttentionCues({
+ conversations,
+ selectedConversationId,
+ getConversationIdentityLabel,
+ onOpenConversation,
+}: UseInboxAttentionCuesArgs): void {
+ const inboxCuePreferencesRef = useRef<{
+ browserNotifications: boolean;
+ sound: boolean;
+ }>({
+ browserNotifications: false,
+ sound: true,
+ });
+ const unreadSnapshotRef = useRef | null>(null);
+ const defaultTitleRef = useRef(null);
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const refreshCuePreferences = () => {
+ inboxCuePreferencesRef.current = loadInboxCuePreferences(window.localStorage);
+ };
+ refreshCuePreferences();
+ window.addEventListener("storage", refreshCuePreferences);
+ window.addEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences);
+ return () => {
+ window.removeEventListener("storage", refreshCuePreferences);
+ window.removeEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (typeof document === "undefined") {
+ return;
+ }
+
+ if (!defaultTitleRef.current) {
+ defaultTitleRef.current = document.title;
+ }
+
+ const totalUnread =
+ conversations?.reduce((sum, conversation) => sum + (conversation.unreadByAgent ?? 0), 0) ?? 0;
+ const baseTitle = defaultTitleRef.current || "Inbox";
+ document.title = computeInboxTitle({ totalUnread, baseTitle });
+ }, [conversations]);
+
+ useEffect(() => {
+ return () => {
+ if (typeof document !== "undefined" && defaultTitleRef.current) {
+ document.title = defaultTitleRef.current;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!conversations || typeof window === "undefined" || typeof document === "undefined") {
+ return;
+ }
+
+ const previousSnapshot = unreadSnapshotRef.current;
+ const currentSnapshot = buildUnreadSnapshot(
+ conversations.map((conversation) => ({
+ _id: conversation._id,
+ unreadByAgent: conversation.unreadByAgent,
+ }))
+ );
+ unreadSnapshotRef.current = currentSnapshot;
+
+ if (!previousSnapshot) {
+ return;
+ }
+
+ const increasedConversationIds = getUnreadIncreases({
+ previous: previousSnapshot,
+ conversations: conversations.map((conversation) => ({
+ _id: conversation._id,
+ unreadByAgent: conversation.unreadByAgent,
+ })),
+ });
+ if (increasedConversationIds.length === 0) {
+ return;
+ }
+
+ const unsuppressedConversationIds = getUnsuppressedUnreadIncreases({
+ increasedConversationIds,
+ selectedConversationId,
+ isDocumentVisible: document.visibilityState === "visible",
+ hasWindowFocus: document.hasFocus(),
+ });
+
+ for (const conversationId of unsuppressedConversationIds) {
+ const conversation = conversations.find((item) => item._id === conversationId);
+ if (!conversation) {
+ continue;
+ }
+
+ const preferences = inboxCuePreferencesRef.current;
+ if (preferences.sound) {
+ playInboxBingSound();
+ }
+
+ if (
+ preferences.browserNotifications &&
+ "Notification" in window &&
+ Notification.permission === "granted"
+ ) {
+ const notification = new Notification("New inbox message", {
+ body: `${getConversationIdentityLabel(conversation)}: ${conversation.lastMessage?.content ?? "Open inbox to view details."}`,
+ tag: `opencom-inbox-${conversation._id}`,
+ });
+ notification.onclick = () => {
+ window.focus();
+ onOpenConversation(conversation._id);
+ };
+ }
+ }
+ }, [conversations, getConversationIdentityLabel, onOpenConversation, selectedConversationId]);
+}
diff --git a/apps/web/src/app/inbox/hooks/useInboxCompactPanels.test.tsx b/apps/web/src/app/inbox/hooks/useInboxCompactPanels.test.tsx
new file mode 100644
index 0000000..3729b36
--- /dev/null
+++ b/apps/web/src/app/inbox/hooks/useInboxCompactPanels.test.tsx
@@ -0,0 +1,123 @@
+import { renderHook, act } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { Id } from "@opencom/convex/dataModel";
+import {
+ shouldResetCompactPanelForViewport,
+ shouldResetSuggestionsPanelForSidecar,
+ useInboxCompactPanels,
+} from "./useInboxCompactPanels";
+
+function conversationId(value: string): Id<"conversations"> {
+ return value as Id<"conversations">;
+}
+
+describe("useInboxCompactPanels", () => {
+ it("exposes compact reset helper semantics", () => {
+ expect(
+ shouldResetCompactPanelForViewport({
+ isCompactViewport: false,
+ selectedConversationId: conversationId("conv-1"),
+ })
+ ).toBe(true);
+ expect(
+ shouldResetCompactPanelForViewport({
+ isCompactViewport: true,
+ selectedConversationId: null,
+ })
+ ).toBe(true);
+ expect(
+ shouldResetCompactPanelForViewport({
+ isCompactViewport: true,
+ selectedConversationId: conversationId("conv-1"),
+ })
+ ).toBe(false);
+ });
+
+ it("resets suggestions panel when sidecar is disabled", () => {
+ expect(
+ shouldResetSuggestionsPanelForSidecar({
+ activeCompactPanel: "suggestions",
+ isSidecarEnabled: false,
+ })
+ ).toBe(true);
+ expect(
+ shouldResetSuggestionsPanelForSidecar({
+ activeCompactPanel: "ai-review",
+ isSidecarEnabled: false,
+ })
+ ).toBe(false);
+ });
+
+ it("closes active compact panel when viewport or sidecar constraints change", () => {
+ const focusReplyInput = vi.fn();
+ const { result, rerender } = renderHook(
+ (props: {
+ isCompactViewport: boolean;
+ selectedConversationId: Id<"conversations"> | null;
+ isSidecarEnabled: boolean;
+ }) =>
+ useInboxCompactPanels({
+ ...props,
+ focusReplyInput,
+ }),
+ {
+ initialProps: {
+ isCompactViewport: true,
+ selectedConversationId: conversationId("conv-1"),
+ isSidecarEnabled: true,
+ },
+ }
+ );
+
+ act(() => {
+ result.current.toggleAuxiliaryPanel("suggestions");
+ });
+ expect(result.current.activeCompactPanel).toBe("suggestions");
+ expect(result.current.suggestionsPanelOpen).toBe(true);
+
+ rerender({
+ isCompactViewport: true,
+ selectedConversationId: conversationId("conv-1"),
+ isSidecarEnabled: false,
+ });
+ expect(result.current.activeCompactPanel).toBeNull();
+ expect(result.current.suggestionsPanelOpen).toBe(false);
+
+ act(() => {
+ result.current.toggleAuxiliaryPanel("ai-review");
+ });
+ expect(result.current.activeCompactPanel).toBe("ai-review");
+ expect(result.current.aiReviewPanelOpen).toBe(true);
+
+ rerender({
+ isCompactViewport: false,
+ selectedConversationId: conversationId("conv-1"),
+ isSidecarEnabled: false,
+ });
+ expect(result.current.activeCompactPanel).toBeNull();
+ expect(result.current.aiReviewPanelOpen).toBe(false);
+ });
+
+ it("closeCompactPanel resets panel and focuses reply input", () => {
+ const focusReplyInput = vi.fn();
+ const { result } = renderHook(() =>
+ useInboxCompactPanels({
+ isCompactViewport: true,
+ selectedConversationId: conversationId("conv-1"),
+ isSidecarEnabled: true,
+ focusReplyInput,
+ })
+ );
+
+ act(() => {
+ result.current.toggleAuxiliaryPanel("ai-review");
+ });
+ expect(result.current.activeCompactPanel).toBe("ai-review");
+
+ act(() => {
+ result.current.closeCompactPanel();
+ });
+ expect(result.current.activeCompactPanel).toBeNull();
+ expect(focusReplyInput).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/src/app/inbox/hooks/useInboxCompactPanels.ts b/apps/web/src/app/inbox/hooks/useInboxCompactPanels.ts
new file mode 100644
index 0000000..1758184
--- /dev/null
+++ b/apps/web/src/app/inbox/hooks/useInboxCompactPanels.ts
@@ -0,0 +1,90 @@
+import { useCallback, useEffect, useState } from "react";
+import type { Id } from "@opencom/convex/dataModel";
+
+export type InboxCompactPanel = "ai-review" | "suggestions";
+
+export interface UseInboxCompactPanelsArgs {
+ isCompactViewport: boolean;
+ selectedConversationId: Id<"conversations"> | null;
+ isSidecarEnabled: boolean;
+ focusReplyInput: () => void;
+}
+
+export interface UseInboxCompactPanelsResult {
+ activeCompactPanel: InboxCompactPanel | null;
+ aiReviewPanelOpen: boolean;
+ suggestionsPanelOpen: boolean;
+ closeCompactPanel: () => void;
+ toggleAuxiliaryPanel: (panel: InboxCompactPanel) => void;
+ resetCompactPanel: () => void;
+}
+
+export function shouldResetCompactPanelForViewport(args: {
+ isCompactViewport: boolean;
+ selectedConversationId: Id<"conversations"> | null;
+}): boolean {
+ return !args.isCompactViewport || !args.selectedConversationId;
+}
+
+export function shouldResetSuggestionsPanelForSidecar(args: {
+ activeCompactPanel: InboxCompactPanel | null;
+ isSidecarEnabled: boolean;
+}): boolean {
+ return !args.isSidecarEnabled && args.activeCompactPanel === "suggestions";
+}
+
+export function useInboxCompactPanels({
+ isCompactViewport,
+ selectedConversationId,
+ isSidecarEnabled,
+ focusReplyInput,
+}: UseInboxCompactPanelsArgs): UseInboxCompactPanelsResult {
+ const [activeCompactPanel, setActiveCompactPanel] = useState(null);
+
+ useEffect(() => {
+ if (
+ shouldResetCompactPanelForViewport({
+ isCompactViewport,
+ selectedConversationId,
+ })
+ ) {
+ setActiveCompactPanel(null);
+ }
+ }, [isCompactViewport, selectedConversationId]);
+
+ useEffect(() => {
+ if (
+ shouldResetSuggestionsPanelForSidecar({
+ activeCompactPanel,
+ isSidecarEnabled,
+ })
+ ) {
+ setActiveCompactPanel(null);
+ }
+ }, [activeCompactPanel, isSidecarEnabled]);
+
+ const closeCompactPanel = useCallback(() => {
+ setActiveCompactPanel(null);
+ focusReplyInput();
+ }, [focusReplyInput]);
+
+ const toggleAuxiliaryPanel = useCallback((panel: InboxCompactPanel) => {
+ setActiveCompactPanel((current) => (current === panel ? null : panel));
+ }, []);
+
+ const resetCompactPanel = useCallback(() => {
+ setActiveCompactPanel(null);
+ }, []);
+
+ return {
+ activeCompactPanel,
+ aiReviewPanelOpen: Boolean(selectedConversationId) && activeCompactPanel === "ai-review",
+ suggestionsPanelOpen:
+ Boolean(selectedConversationId) &&
+ isSidecarEnabled &&
+ activeCompactPanel === "suggestions",
+ closeCompactPanel,
+ toggleAuxiliaryPanel,
+ resetCompactPanel,
+ };
+}
diff --git a/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts b/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts
new file mode 100644
index 0000000..e36ce9a
--- /dev/null
+++ b/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts
@@ -0,0 +1,215 @@
+import { useCallback } from "react";
+import type { Dispatch, SetStateAction } from "react";
+import type { Id } from "@opencom/convex/dataModel";
+
+type ConversationStatus = "open" | "closed" | "snoozed";
+
+export type ConversationUiPatch = {
+ unreadByAgent?: number;
+ status?: ConversationStatus;
+ lastMessageAt?: number;
+ optimisticLastMessage?: string;
+};
+
+interface ConversationSummaryForActions {
+ _id: Id<"conversations">;
+ unreadByAgent?: number;
+ status?: ConversationStatus;
+}
+
+interface MutationApi {
+ markAsRead: (args: { id: Id<"conversations">; readerType: "agent" }) => Promise;
+ sendMessage: (args: {
+ conversationId: Id<"conversations">;
+ senderId: Id<"users">;
+ senderType: "agent";
+ content: string;
+ }) => Promise;
+ updateStatus: (args: { id: Id<"conversations">; status: "closed" }) => Promise;
+ convertToTicket: (args: { conversationId: Id<"conversations"> }) => Promise>;
+}
+
+interface MutationState {
+ inputValue: string;
+ setInputValue: Dispatch>;
+ setIsSending: Dispatch>;
+ setIsResolving: Dispatch>;
+ setIsConvertingTicket: Dispatch>;
+ conversationPatches: Record;
+ setConversationPatches: Dispatch>>;
+ setReadSyncConversationId: Dispatch | null>>;
+ setSelectedConversationId: Dispatch | null>>;
+ setWorkflowError: Dispatch>;
+}
+
+interface MutationContext {
+ userId: Id<"users"> | null;
+ selectedConversationId: Id<"conversations"> | null;
+ conversations: ConversationSummaryForActions[] | undefined;
+ onTicketCreated: (ticketId: Id<"tickets">) => void;
+}
+
+export interface UseInboxMessageActionsArgs {
+ api: MutationApi;
+ state: MutationState;
+ context: MutationContext;
+}
+
+export interface UseInboxMessageActionsResult {
+ patchConversationState: (conversationId: Id<"conversations">, patch: ConversationUiPatch) => void;
+ handleSelectConversation: (conversationId: Id<"conversations">) => Promise;
+ handleSendMessage: () => Promise;
+ handleResolveConversation: () => Promise;
+ handleConvertToTicket: () => Promise;
+}
+
+export function useInboxMessageActions({
+ api,
+ state,
+ context,
+}: UseInboxMessageActionsArgs): UseInboxMessageActionsResult {
+ const patchConversationState = useCallback(
+ (conversationId: Id<"conversations">, patch: ConversationUiPatch) => {
+ state.setConversationPatches((previousState) => ({
+ ...previousState,
+ [conversationId]: {
+ ...(previousState[conversationId] ?? {}),
+ ...patch,
+ },
+ }));
+ },
+ [state]
+ );
+
+ const handleSelectConversation = useCallback(
+ async (conversationId: Id<"conversations">) => {
+ state.setWorkflowError(null);
+ state.setSelectedConversationId(conversationId);
+ const previousUnreadCount =
+ context.conversations?.find((conversation) => conversation._id === conversationId)
+ ?.unreadByAgent ?? 0;
+ patchConversationState(conversationId, { unreadByAgent: 0 });
+ state.setReadSyncConversationId(conversationId);
+
+ try {
+ await api.markAsRead({ id: conversationId, readerType: "agent" });
+ } catch (error) {
+ console.error("Failed to mark conversation as read:", error);
+ patchConversationState(conversationId, { unreadByAgent: previousUnreadCount });
+ state.setWorkflowError("Failed to sync read state. Please retry.");
+ } finally {
+ state.setReadSyncConversationId((current) => (current === conversationId ? null : current));
+ }
+ },
+ [api, context.conversations, patchConversationState, state]
+ );
+
+ const handleSendMessage = useCallback(async () => {
+ if (!state.inputValue.trim() || !context.selectedConversationId || !context.userId) {
+ return;
+ }
+
+ const content = state.inputValue.trim();
+ const conversationId = context.selectedConversationId;
+ const previousPatch = state.conversationPatches[conversationId];
+ const now = Date.now();
+
+ state.setWorkflowError(null);
+ state.setIsSending(true);
+ state.setInputValue("");
+ patchConversationState(conversationId, {
+ unreadByAgent: 0,
+ lastMessageAt: now,
+ optimisticLastMessage: content,
+ });
+
+ try {
+ await api.sendMessage({
+ conversationId,
+ senderId: context.userId,
+ senderType: "agent",
+ content,
+ });
+ } catch (error) {
+ console.error("Failed to send message:", error);
+ state.setInputValue(content);
+ state.setWorkflowError("Failed to send reply. Please try again.");
+ state.setConversationPatches((previousState) => {
+ const nextState = { ...previousState };
+ if (previousPatch) {
+ nextState[conversationId] = previousPatch;
+ } else {
+ delete nextState[conversationId];
+ }
+ return nextState;
+ });
+ } finally {
+ state.setIsSending(false);
+ }
+ }, [api, context.selectedConversationId, context.userId, patchConversationState, state]);
+
+ const handleResolveConversation = useCallback(async () => {
+ if (!context.selectedConversationId) {
+ return;
+ }
+ const conversationId = context.selectedConversationId;
+ const previousPatch = state.conversationPatches[conversationId];
+
+ state.setWorkflowError(null);
+ state.setIsResolving(true);
+ patchConversationState(conversationId, {
+ status: "closed",
+ unreadByAgent: 0,
+ });
+
+ try {
+ await api.updateStatus({
+ id: conversationId,
+ status: "closed",
+ });
+ } catch (error) {
+ console.error("Failed to resolve conversation:", error);
+ state.setWorkflowError("Failed to resolve conversation. Please retry.");
+ state.setConversationPatches((previousState) => {
+ const nextState = { ...previousState };
+ if (previousPatch) {
+ nextState[conversationId] = previousPatch;
+ } else {
+ delete nextState[conversationId];
+ }
+ return nextState;
+ });
+ } finally {
+ state.setIsResolving(false);
+ }
+ }, [api, context.selectedConversationId, patchConversationState, state]);
+
+ const handleConvertToTicket = useCallback(async () => {
+ if (!context.selectedConversationId) {
+ return;
+ }
+ state.setWorkflowError(null);
+ state.setIsConvertingTicket(true);
+ try {
+ const ticketId = await api.convertToTicket({
+ conversationId: context.selectedConversationId,
+ });
+ context.onTicketCreated(ticketId);
+ } catch (error) {
+ console.error("Failed to convert to ticket:", error);
+ state.setWorkflowError(
+ "Failed to convert to ticket. A ticket may already exist for this conversation."
+ );
+ } finally {
+ state.setIsConvertingTicket(false);
+ }
+ }, [api, context, state]);
+
+ return {
+ patchConversationState,
+ handleSelectConversation,
+ handleSendMessage,
+ handleResolveConversation,
+ handleConvertToTicket,
+ };
+}
diff --git a/apps/web/src/app/inbox/hooks/useInboxSelectionSync.test.tsx b/apps/web/src/app/inbox/hooks/useInboxSelectionSync.test.tsx
new file mode 100644
index 0000000..6131c91
--- /dev/null
+++ b/apps/web/src/app/inbox/hooks/useInboxSelectionSync.test.tsx
@@ -0,0 +1,143 @@
+import { useState } from "react";
+import { renderHook, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { Id } from "@opencom/convex/dataModel";
+import {
+ buildInboxRouteWithConversationId,
+ getQueryConversationId,
+ useInboxSelectionSync,
+} from "./useInboxSelectionSync";
+
+function conversationId(value: string): Id<"conversations"> {
+ return value as Id<"conversations">;
+}
+
+function createSearchParams(params: Record) {
+ return {
+ get: (name: string) => params[name] ?? null,
+ toString: () => new URLSearchParams(params).toString(),
+ };
+}
+
+describe("useInboxSelectionSync", () => {
+ it("parses both conversationId and legacy conversation query keys", () => {
+ expect(getQueryConversationId(createSearchParams({ conversationId: "conv-1" }))).toBe("conv-1");
+ expect(getQueryConversationId(createSearchParams({ conversation: "conv-2" }))).toBe("conv-2");
+ expect(getQueryConversationId(createSearchParams({}))).toBeNull();
+ });
+
+ it("builds inbox routes with selected id and strips legacy query keys", () => {
+ expect(
+ buildInboxRouteWithConversationId({
+ searchParams: createSearchParams({ filter: "open", conversation: "legacy-id" }),
+ selectedConversationId: conversationId("conv-9"),
+ })
+ ).toBe("/inbox?filter=open&conversationId=conv-9");
+
+ expect(
+ buildInboxRouteWithConversationId({
+ searchParams: createSearchParams({ filter: "open", conversation: "legacy-id" }),
+ selectedConversationId: null,
+ })
+ ).toBe("/inbox?filter=open");
+ });
+
+ it("adopts a valid query conversation id into selected state", async () => {
+ const setSelectionSpy = vi.fn<(id: Id<"conversations"> | null) => void>();
+ const clearWorkflowError = vi.fn();
+ const resetCompactPanel = vi.fn();
+ const routerReplace = vi.fn();
+ const conversations = [{ _id: conversationId("conv-1") }];
+ const searchParams = createSearchParams({ conversationId: "conv-1" });
+
+ const { result } = renderHook(() => {
+ const [selectedConversationId, setSelectedConversationId] = useState | null>(
+ null
+ );
+
+ useInboxSelectionSync({
+ conversations,
+ isConversationsLoading: false,
+ selectedConversationId,
+ setSelectedConversationId: (id) => {
+ setSelectionSpy(id);
+ setSelectedConversationId(id);
+ },
+ resetCompactPanel,
+ clearWorkflowError,
+ searchParams,
+ router: {
+ replace: routerReplace,
+ },
+ });
+
+ return { selectedConversationId };
+ });
+
+ await waitFor(() => {
+ expect(result.current.selectedConversationId).toBe(conversationId("conv-1"));
+ });
+ expect(setSelectionSpy).toHaveBeenCalledWith(conversationId("conv-1"));
+ expect(clearWorkflowError).toHaveBeenCalledTimes(1);
+ expect(routerReplace).not.toHaveBeenCalled();
+ expect(resetCompactPanel).not.toHaveBeenCalled();
+ });
+
+ it("clears invalid selected conversation and resets compact state", async () => {
+ const resetCompactPanel = vi.fn();
+
+ const { result } = renderHook(() => {
+ const [selectedConversationId, setSelectedConversationId] = useState | null>(
+ conversationId("conv-missing")
+ );
+
+ useInboxSelectionSync({
+ conversations: [{ _id: conversationId("conv-1") }],
+ isConversationsLoading: false,
+ selectedConversationId,
+ setSelectedConversationId,
+ resetCompactPanel,
+ clearWorkflowError: vi.fn(),
+ searchParams: createSearchParams({}),
+ router: {
+ replace: vi.fn(),
+ },
+ });
+
+ return { selectedConversationId };
+ });
+
+ await waitFor(() => {
+ expect(result.current.selectedConversationId).toBeNull();
+ });
+ expect(resetCompactPanel).toHaveBeenCalledTimes(1);
+ });
+
+ it("updates url when selection differs from query and removes legacy key", async () => {
+ const routerReplace = vi.fn();
+
+ renderHook(() =>
+ useInboxSelectionSync({
+ conversations: [{ _id: conversationId("conv-1") }, { _id: conversationId("conv-2") }],
+ isConversationsLoading: false,
+ selectedConversationId: conversationId("conv-2"),
+ setSelectedConversationId: vi.fn(),
+ resetCompactPanel: vi.fn(),
+ clearWorkflowError: vi.fn(),
+ searchParams: createSearchParams({
+ filter: "open",
+ conversation: "legacy-id",
+ }),
+ router: {
+ replace: routerReplace,
+ },
+ })
+ );
+
+ await waitFor(() => {
+ expect(routerReplace).toHaveBeenCalledWith("/inbox?filter=open&conversationId=conv-2", {
+ scroll: false,
+ });
+ });
+ });
+});
diff --git a/apps/web/src/app/inbox/hooks/useInboxSelectionSync.ts b/apps/web/src/app/inbox/hooks/useInboxSelectionSync.ts
new file mode 100644
index 0000000..62b546c
--- /dev/null
+++ b/apps/web/src/app/inbox/hooks/useInboxSelectionSync.ts
@@ -0,0 +1,147 @@
+import { useEffect, useMemo, useRef } from "react";
+import type { Id } from "@opencom/convex/dataModel";
+
+export interface InboxSelectionConversationRef {
+ _id: Id<"conversations">;
+}
+
+export interface InboxSelectionRouter {
+ replace: (href: string, options?: { scroll?: boolean }) => void;
+}
+
+export interface InboxSelectionSearchParams {
+ get: (name: string) => string | null;
+ toString: () => string;
+}
+
+export interface UseInboxSelectionSyncArgs {
+ conversations: InboxSelectionConversationRef[] | undefined;
+ isConversationsLoading: boolean;
+ selectedConversationId: Id<"conversations"> | null;
+ setSelectedConversationId: (id: Id<"conversations"> | null) => void;
+ resetCompactPanel: () => void;
+ clearWorkflowError: () => void;
+ searchParams: InboxSelectionSearchParams;
+ router: InboxSelectionRouter;
+}
+
+export interface UseInboxSelectionSyncResult {
+ queryConversationId: Id<"conversations"> | null;
+}
+
+export function getQueryConversationId(
+ searchParams: InboxSelectionSearchParams
+): Id<"conversations"> | null {
+ const requestedConversationId =
+ searchParams.get("conversationId") ?? searchParams.get("conversation");
+ return requestedConversationId ? (requestedConversationId as Id<"conversations">) : null;
+}
+
+export function buildInboxRouteWithConversationId(args: {
+ searchParams: InboxSelectionSearchParams;
+ selectedConversationId: Id<"conversations"> | null;
+}): string {
+ const nextSearchParams = new URLSearchParams(args.searchParams.toString());
+ if (args.selectedConversationId) {
+ nextSearchParams.set("conversationId", args.selectedConversationId);
+ } else {
+ nextSearchParams.delete("conversationId");
+ }
+ nextSearchParams.delete("conversation");
+ const nextQuery = nextSearchParams.toString();
+ return nextQuery ? `/inbox?${nextQuery}` : "/inbox";
+}
+
+export function useInboxSelectionSync({
+ conversations,
+ isConversationsLoading,
+ selectedConversationId,
+ setSelectedConversationId,
+ resetCompactPanel,
+ clearWorkflowError,
+ searchParams,
+ router,
+}: UseInboxSelectionSyncArgs): UseInboxSelectionSyncResult {
+ const queryConversationId = useMemo(
+ () => getQueryConversationId(searchParams),
+ [searchParams]
+ );
+ const lastAppliedQueryConversationIdRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (!selectedConversationId || !conversations) {
+ return;
+ }
+ if (!conversations.some((conversation) => conversation._id === selectedConversationId)) {
+ setSelectedConversationId(null);
+ resetCompactPanel();
+ }
+ }, [conversations, resetCompactPanel, selectedConversationId, setSelectedConversationId]);
+
+ useEffect(() => {
+ if (!conversations) {
+ return;
+ }
+ const queryHasChanged = lastAppliedQueryConversationIdRef.current !== queryConversationId;
+ if (!queryHasChanged) {
+ return;
+ }
+ lastAppliedQueryConversationIdRef.current = queryConversationId;
+ if (!queryConversationId) {
+ if (selectedConversationId !== null) {
+ setSelectedConversationId(null);
+ resetCompactPanel();
+ }
+ return;
+ }
+ if (!conversations.some((conversation) => conversation._id === queryConversationId)) {
+ return;
+ }
+ if (selectedConversationId !== queryConversationId) {
+ setSelectedConversationId(queryConversationId);
+ clearWorkflowError();
+ }
+ }, [
+ clearWorkflowError,
+ conversations,
+ queryConversationId,
+ resetCompactPanel,
+ selectedConversationId,
+ setSelectedConversationId,
+ ]);
+
+ useEffect(() => {
+ if (isConversationsLoading) {
+ return;
+ }
+ if (
+ selectedConversationId === null &&
+ queryConversationId &&
+ conversations?.some((conversation) => conversation._id === queryConversationId)
+ ) {
+ return;
+ }
+ if (selectedConversationId === queryConversationId) {
+ return;
+ }
+
+ router.replace(
+ buildInboxRouteWithConversationId({
+ searchParams,
+ selectedConversationId,
+ }),
+ { scroll: false }
+ );
+ }, [
+ conversations,
+ isConversationsLoading,
+ queryConversationId,
+ router,
+ searchParams,
+ selectedConversationId,
+ ]);
+
+ return {
+ queryConversationId,
+ };
+}
diff --git a/apps/web/src/app/inbox/hooks/useInboxSuggestionsCount.ts b/apps/web/src/app/inbox/hooks/useInboxSuggestionsCount.ts
new file mode 100644
index 0000000..bd893bd
--- /dev/null
+++ b/apps/web/src/app/inbox/hooks/useInboxSuggestionsCount.ts
@@ -0,0 +1,73 @@
+import { useEffect, useState } from "react";
+import type { Dispatch, SetStateAction } from "react";
+import type { Id } from "@opencom/convex/dataModel";
+
+export interface UseInboxSuggestionsCountArgs {
+ selectedConversationId: Id<"conversations"> | null;
+ isSidecarEnabled: boolean;
+ messageCountSignal: number;
+ getSuggestionsForConversation: (args: {
+ conversationId: Id<"conversations">;
+ limit: number;
+ }) => Promise;
+}
+
+export interface UseInboxSuggestionsCountResult {
+ suggestionsCount: number;
+ isSuggestionsCountLoading: boolean;
+ setSuggestionsCount: Dispatch>;
+}
+
+export function useInboxSuggestionsCount({
+ selectedConversationId,
+ isSidecarEnabled,
+ messageCountSignal,
+ getSuggestionsForConversation,
+}: UseInboxSuggestionsCountArgs): UseInboxSuggestionsCountResult {
+ const [suggestionsCount, setSuggestionsCount] = useState(0);
+ const [isSuggestionsCountLoading, setIsSuggestionsCountLoading] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const fetchSuggestionsCount = async () => {
+ if (!selectedConversationId || !isSidecarEnabled) {
+ setSuggestionsCount(0);
+ setIsSuggestionsCountLoading(false);
+ return;
+ }
+
+ setIsSuggestionsCountLoading(true);
+ try {
+ const results = await getSuggestionsForConversation({
+ conversationId: selectedConversationId,
+ limit: 5,
+ });
+ if (!cancelled) {
+ setSuggestionsCount(results.length);
+ }
+ } catch (error) {
+ console.error("Failed to fetch suggestions count:", error);
+ if (!cancelled) {
+ setSuggestionsCount(0);
+ }
+ } finally {
+ if (!cancelled) {
+ setIsSuggestionsCountLoading(false);
+ }
+ }
+ };
+
+ void fetchSuggestionsCount();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [getSuggestionsForConversation, isSidecarEnabled, messageCountSignal, selectedConversationId]);
+
+ return {
+ suggestionsCount,
+ isSuggestionsCountLoading,
+ setSuggestionsCount,
+ };
+}
diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx
index 41df5ed..6694032 100644
--- a/apps/web/src/app/inbox/page.tsx
+++ b/apps/web/src/app/inbox/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useMemo, useRef } from "react";
+import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { useQuery, useMutation, useAction } from "convex/react";
import { api } from "@opencom/convex";
import { Button, Card, Input } from "@opencom/ui";
@@ -35,14 +35,14 @@ import {
ResponsiveSecondaryRegion,
useIsCompactViewport,
} from "@/components/ResponsiveLayout";
+import { useInboxSelectionSync } from "./hooks/useInboxSelectionSync";
+import { useInboxCompactPanels } from "./hooks/useInboxCompactPanels";
+import { useInboxSuggestionsCount } from "./hooks/useInboxSuggestionsCount";
+import { useInboxAttentionCues } from "./hooks/useInboxAttentionCues";
import {
- INBOX_CUE_PREFERENCES_UPDATED_EVENT,
- buildUnreadSnapshot,
- getUnreadIncreases,
- loadInboxCuePreferences,
- shouldSuppressAttentionCue,
-} from "@/lib/inboxNotificationCues";
-import { playInboxBingSound } from "@/lib/playInboxBingSound";
+ useInboxMessageActions,
+ type ConversationUiPatch,
+} from "./hooks/useInboxMessageActions";
function PresenceIndicator({ visitorId }: { visitorId: Id<"visitors"> }) {
const isOnline = useQuery(api.visitors.isOnline, { visitorId });
@@ -53,15 +53,6 @@ function PresenceIndicator({ visitorId }: { visitorId: Id<"visitors"> }) {
);
}
-type ConversationStatus = "open" | "closed" | "snoozed";
-
-type ConversationUiPatch = {
- unreadByAgent?: number;
- status?: ConversationStatus;
- lastMessageAt?: number;
- optimisticLastMessage?: string;
-};
-
const HANDOFF_REASON_FALLBACK = "Reason not provided by handoff trigger";
function sortInboxConversations(
@@ -118,27 +109,12 @@ function InboxContent(): React.JSX.Element | null {
const [isSending, setIsSending] = useState(false);
const [isResolving, setIsResolving] = useState(false);
const [isConvertingTicket, setIsConvertingTicket] = useState(false);
- const [suggestionsCount, setSuggestionsCount] = useState(0);
- const [isSuggestionsCountLoading, setIsSuggestionsCountLoading] = useState(false);
const [readSyncConversationId, setReadSyncConversationId] = useState | null>(
null
);
const [highlightedMessageId, setHighlightedMessageId] = useState | null>(null);
- const [activeCompactPanel, setActiveCompactPanel] = useState<"ai-review" | "suggestions" | null>(
- null
- );
const messageHighlightTimerRef = useRef | null>(null);
const replyInputRef = useRef(null);
- const inboxCuePreferencesRef = useRef<{
- browserNotifications: boolean;
- sound: boolean;
- }>({
- browserNotifications: false,
- sound: true,
- });
- const unreadSnapshotRef = useRef | null>(null);
- const defaultTitleRef = useRef(null);
- const lastAppliedQueryConversationIdRef = useRef | null>(null);
const inboxQueryArgs = activeWorkspace?._id
? {
@@ -234,11 +210,6 @@ function InboxContent(): React.JSX.Element | null {
const selectedConversation =
conversations?.find((conversation) => conversation._id === selectedConversationId) ?? null;
const isConversationsLoading = conversationsData === undefined;
- const queryConversationId = useMemo | null>(() => {
- const requestedConversationId =
- searchParams.get("conversationId") ?? searchParams.get("conversation");
- return requestedConversationId ? (requestedConversationId as Id<"conversations">) : null;
- }, [searchParams]);
const isSidecarEnabled = aiSettings?.suggestionsEnabled === true;
const orderedAiResponses = useMemo(() => {
if (!aiResponses) {
@@ -248,9 +219,6 @@ function InboxContent(): React.JSX.Element | null {
}, [aiResponses]);
const showConversationListPane = !isCompactViewport || !selectedConversationId;
const showThreadPane = !isCompactViewport || Boolean(selectedConversationId);
- const aiReviewPanelOpen = Boolean(selectedConversationId) && activeCompactPanel === "ai-review";
- const suggestionsPanelOpen =
- Boolean(selectedConversationId) && isSidecarEnabled && activeCompactPanel === "suggestions";
const focusReplyInput = () => {
if (typeof window === "undefined") {
@@ -260,15 +228,39 @@ function InboxContent(): React.JSX.Element | null {
replyInputRef.current?.focus();
});
};
-
- const closeCompactPanel = () => {
- setActiveCompactPanel(null);
- focusReplyInput();
- };
-
- const toggleAuxiliaryPanel = (panel: "ai-review" | "suggestions") => {
- setActiveCompactPanel((current) => (current === panel ? null : panel));
- };
+ const clearWorkflowError = useCallback(() => {
+ setWorkflowError(null);
+ }, []);
+ const {
+ activeCompactPanel,
+ aiReviewPanelOpen,
+ suggestionsPanelOpen,
+ closeCompactPanel,
+ toggleAuxiliaryPanel,
+ resetCompactPanel,
+ } = useInboxCompactPanels({
+ isCompactViewport,
+ selectedConversationId,
+ isSidecarEnabled,
+ focusReplyInput,
+ });
+ useInboxSelectionSync({
+ conversations,
+ isConversationsLoading,
+ selectedConversationId,
+ setSelectedConversationId,
+ resetCompactPanel,
+ clearWorkflowError,
+ searchParams,
+ router,
+ });
+ const { suggestionsCount, isSuggestionsCountLoading, setSuggestionsCount } =
+ useInboxSuggestionsCount({
+ selectedConversationId,
+ isSidecarEnabled,
+ messageCountSignal: messages?.length ?? 0,
+ getSuggestionsForConversation,
+ });
// Keyboard shortcut for knowledge search (Ctrl+K / Cmd+K)
useEffect(() => {
@@ -285,111 +277,6 @@ function InboxContent(): React.JSX.Element | null {
return () => document.removeEventListener("keydown", handleGlobalKeyDown);
}, []);
- useEffect(() => {
- if (!selectedConversationId || !conversations) {
- return;
- }
- if (!conversations.some((conversation) => conversation._id === selectedConversationId)) {
- setSelectedConversationId(null);
- setActiveCompactPanel(null);
- }
- }, [conversations, selectedConversationId]);
-
- useEffect(() => {
- if (!conversations) {
- return;
- }
- const queryHasChanged = lastAppliedQueryConversationIdRef.current !== queryConversationId;
- if (!queryHasChanged) {
- return;
- }
- lastAppliedQueryConversationIdRef.current = queryConversationId;
- if (!queryConversationId) {
- if (selectedConversationId !== null) {
- setSelectedConversationId(null);
- setActiveCompactPanel(null);
- }
- return;
- }
- if (!conversations.some((conversation) => conversation._id === queryConversationId)) {
- return;
- }
- if (selectedConversationId !== queryConversationId) {
- setSelectedConversationId(queryConversationId);
- setWorkflowError(null);
- }
- }, [conversations, queryConversationId, selectedConversationId]);
-
- useEffect(() => {
- if (isConversationsLoading) {
- return;
- }
- if (
- selectedConversationId === null &&
- queryConversationId &&
- conversations?.some((conversation) => conversation._id === queryConversationId)
- ) {
- return;
- }
- if (selectedConversationId === queryConversationId) {
- return;
- }
- const nextSearchParams = new URLSearchParams(searchParams.toString());
- if (selectedConversationId) {
- nextSearchParams.set("conversationId", selectedConversationId);
- } else {
- nextSearchParams.delete("conversationId");
- }
- nextSearchParams.delete("conversation");
- const nextQuery = nextSearchParams.toString();
- router.replace(nextQuery ? `/inbox?${nextQuery}` : "/inbox", { scroll: false });
- }, [
- conversations,
- isConversationsLoading,
- queryConversationId,
- router,
- searchParams,
- selectedConversationId,
- ]);
-
- useEffect(() => {
- let cancelled = false;
-
- const fetchSuggestionsCount = async () => {
- if (!selectedConversationId || !isSidecarEnabled) {
- setSuggestionsCount(0);
- setIsSuggestionsCountLoading(false);
- return;
- }
-
- setIsSuggestionsCountLoading(true);
- try {
- const results = await getSuggestionsForConversation({
- conversationId: selectedConversationId,
- limit: 5,
- });
- if (!cancelled) {
- setSuggestionsCount(results.length);
- }
- } catch (error) {
- console.error("Failed to fetch suggestions count:", error);
- if (!cancelled) {
- setSuggestionsCount(0);
- }
- } finally {
- if (!cancelled) {
- setIsSuggestionsCountLoading(false);
- }
- }
- };
-
- void fetchSuggestionsCount();
-
- return () => {
- cancelled = true;
- };
- }, [getSuggestionsForConversation, isSidecarEnabled, selectedConversationId, messages?.length]);
-
useEffect(() => {
if (!selectedConversationId || !messages || messages.length === 0) {
return;
@@ -432,202 +319,52 @@ function InboxContent(): React.JSX.Element | null {
}
};
}, []);
-
- useEffect(() => {
- if (!isCompactViewport || !selectedConversationId) {
- setActiveCompactPanel(null);
- }
- }, [isCompactViewport, selectedConversationId]);
-
- useEffect(() => {
- if (!isSidecarEnabled && activeCompactPanel === "suggestions") {
- setActiveCompactPanel(null);
- }
- }, [activeCompactPanel, isSidecarEnabled]);
-
- useEffect(() => {
+ const handleOpenConversationFromNotification = useCallback((conversationId: Id<"conversations">) => {
if (typeof window === "undefined") {
return;
}
-
- const refreshCuePreferences = () => {
- inboxCuePreferencesRef.current = loadInboxCuePreferences(window.localStorage);
- };
- refreshCuePreferences();
- window.addEventListener("storage", refreshCuePreferences);
- window.addEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences);
- return () => {
- window.removeEventListener("storage", refreshCuePreferences);
- window.removeEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences);
- };
- }, []);
-
- useEffect(() => {
- if (typeof document === "undefined") {
- return;
- }
-
- if (!defaultTitleRef.current) {
- defaultTitleRef.current = document.title;
- }
-
- const totalUnread =
- conversations?.reduce((sum, conversation) => sum + (conversation.unreadByAgent ?? 0), 0) ?? 0;
- const baseTitle = defaultTitleRef.current || "Inbox";
- document.title = totalUnread > 0 ? `(${totalUnread}) ${baseTitle}` : baseTitle;
- }, [conversations]);
-
- useEffect(() => {
- return () => {
- if (typeof document !== "undefined" && defaultTitleRef.current) {
- document.title = defaultTitleRef.current;
- }
- };
+ const url = new URL(window.location.href);
+ url.pathname = "/inbox";
+ url.searchParams.set("conversationId", conversationId);
+ window.location.assign(url.toString());
}, []);
-
- useEffect(() => {
- if (!conversations || typeof window === "undefined" || typeof document === "undefined") {
- return;
- }
-
- const previousSnapshot = unreadSnapshotRef.current;
- const currentSnapshot = buildUnreadSnapshot(
- conversations.map((conversation) => ({
- _id: conversation._id,
- unreadByAgent: conversation.unreadByAgent,
- }))
- );
- unreadSnapshotRef.current = currentSnapshot;
-
- if (!previousSnapshot) {
- return;
- }
-
- const increasedConversationIds = getUnreadIncreases({
- previous: previousSnapshot,
- conversations: conversations.map((conversation) => ({
- _id: conversation._id,
- unreadByAgent: conversation.unreadByAgent,
- })),
- });
- if (increasedConversationIds.length === 0) {
- return;
- }
-
- for (const conversationId of increasedConversationIds) {
- const conversation = conversations.find((item) => item._id === conversationId);
- if (!conversation) {
- continue;
- }
-
- const suppressCue = shouldSuppressAttentionCue({
- conversationId,
- selectedConversationId,
- isDocumentVisible: document.visibilityState === "visible",
- hasWindowFocus: document.hasFocus(),
- });
- if (suppressCue) {
- continue;
- }
-
- const preferences = inboxCuePreferencesRef.current;
- if (preferences.sound) {
- playInboxBingSound();
- }
-
- if (
- preferences.browserNotifications &&
- "Notification" in window &&
- Notification.permission === "granted"
- ) {
- const notification = new Notification("New inbox message", {
- body: `${getConversationIdentityLabel(conversation)}: ${conversation.lastMessage?.content ?? "Open inbox to view details."}`,
- tag: `opencom-inbox-${conversation._id}`,
- });
- notification.onclick = () => {
- window.focus();
- const url = new URL(window.location.href);
- url.pathname = "/inbox";
- url.searchParams.set("conversationId", conversation._id);
- window.location.assign(url.toString());
- };
- }
- }
- }, [conversations, selectedConversationId]);
-
- const patchConversationState = (
- conversationId: Id<"conversations">,
- patch: ConversationUiPatch
- ) => {
- setConversationPatches((previousState) => ({
- ...previousState,
- [conversationId]: {
- ...(previousState[conversationId] ?? {}),
- ...patch,
- },
- }));
- };
-
- const handleSelectConversation = async (convId: Id<"conversations">) => {
- setWorkflowError(null);
- setSelectedConversationId(convId);
- const previousUnreadCount =
- conversations?.find((conversation) => conversation._id === convId)?.unreadByAgent ?? 0;
- patchConversationState(convId, { unreadByAgent: 0 });
- setReadSyncConversationId(convId);
-
- try {
- await markAsRead({ id: convId, readerType: "agent" });
- } catch (error) {
- console.error("Failed to mark conversation as read:", error);
- patchConversationState(convId, { unreadByAgent: previousUnreadCount });
- setWorkflowError("Failed to sync read state. Please retry.");
- } finally {
- setReadSyncConversationId((current) => (current === convId ? null : current));
- }
- };
-
- const handleSendMessage = async () => {
- if (!inputValue.trim() || !selectedConversationId || !user) return;
-
- const content = inputValue.trim();
- const conversationId = selectedConversationId;
- const previousPatch = conversationPatches[conversationId];
- const now = Date.now();
-
- setWorkflowError(null);
- setIsSending(true);
- setInputValue("");
- patchConversationState(conversationId, {
- unreadByAgent: 0,
- lastMessageAt: now,
- optimisticLastMessage: content,
- });
-
- try {
- await sendMessage({
- conversationId,
- senderId: user._id,
- senderType: "agent",
- content,
- });
- } catch (error) {
- console.error("Failed to send message:", error);
- setInputValue(content);
- setWorkflowError("Failed to send reply. Please try again.");
- setConversationPatches((previousState) => {
- const nextState = { ...previousState };
- if (previousPatch) {
- nextState[conversationId] = previousPatch;
- } else {
- delete nextState[conversationId];
- }
- return nextState;
- });
- } finally {
- setIsSending(false);
- }
- };
+ useInboxAttentionCues({
+ conversations,
+ selectedConversationId,
+ getConversationIdentityLabel,
+ onOpenConversation: handleOpenConversationFromNotification,
+ });
+ const {
+ handleSelectConversation,
+ handleSendMessage,
+ handleResolveConversation,
+ handleConvertToTicket,
+ } = useInboxMessageActions({
+ api: {
+ markAsRead,
+ sendMessage,
+ updateStatus,
+ convertToTicket,
+ },
+ state: {
+ inputValue,
+ setInputValue,
+ setIsSending,
+ setIsResolving,
+ setIsConvertingTicket,
+ conversationPatches,
+ setConversationPatches,
+ setReadSyncConversationId,
+ setSelectedConversationId,
+ setWorkflowError,
+ },
+ context: {
+ userId: user?._id ?? null,
+ selectedConversationId,
+ conversations,
+ onTicketCreated: (ticketId) => router.push(`/tickets/${ticketId}`),
+ },
+ });
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -653,58 +390,6 @@ function InboxContent(): React.JSX.Element | null {
setSnippetSearch("");
};
- const handleConvertToTicket = async () => {
- if (!selectedConversationId) return;
- setWorkflowError(null);
- setIsConvertingTicket(true);
- try {
- const ticketId = await convertToTicket({
- conversationId: selectedConversationId,
- });
- router.push(`/tickets/${ticketId}`);
- } catch (error) {
- console.error("Failed to convert to ticket:", error);
- setWorkflowError(
- "Failed to convert to ticket. A ticket may already exist for this conversation."
- );
- } finally {
- setIsConvertingTicket(false);
- }
- };
-
- const handleResolveConversation = async () => {
- if (!selectedConversationId) return;
- const previousPatch = conversationPatches[selectedConversationId];
-
- setWorkflowError(null);
- setIsResolving(true);
- patchConversationState(selectedConversationId, {
- status: "closed",
- unreadByAgent: 0,
- });
-
- try {
- await updateStatus({
- id: selectedConversationId,
- status: "closed",
- });
- } catch (error) {
- console.error("Failed to resolve conversation:", error);
- setWorkflowError("Failed to resolve conversation. Please retry.");
- setConversationPatches((previousState) => {
- const nextState = { ...previousState };
- if (previousPatch) {
- nextState[selectedConversationId] = previousPatch;
- } else {
- delete nextState[selectedConversationId];
- }
- return nextState;
- });
- } finally {
- setIsResolving(false);
- }
- };
-
const jumpToMessage = (messageId: Id<"messages">) => {
const target = document.getElementById(`message-${messageId}`);
if (!target) {
@@ -955,7 +640,7 @@ function InboxContent(): React.JSX.Element | null {
size="sm"
onClick={() => {
setSelectedConversationId(null);
- setActiveCompactPanel(null);
+ resetCompactPanel();
}}
data-testid="inbox-back-to-list"
title="Back to conversations"
diff --git a/openspec/changes/decompose-web-inbox-page/tasks.md b/openspec/changes/decompose-web-inbox-page/tasks.md
index 2fb146d..91997b9 100644
--- a/openspec/changes/decompose-web-inbox-page/tasks.md
+++ b/openspec/changes/decompose-web-inbox-page/tasks.md
@@ -1,24 +1,24 @@
## 1. Refactor Baseline And Contracts
-- [ ] 1.1 Define domain boundaries for inbox orchestration (selection sync, compact panels, suggestions, attention cues, message actions).
-- [ ] 1.2 Add typed contracts for each domain hook/module and establish page-level composition interfaces.
+- [x] 1.1 Define domain boundaries for inbox orchestration (selection sync, compact panels, suggestions, attention cues, message actions).
+- [x] 1.2 Add typed contracts for each domain hook/module and establish page-level composition interfaces.
## 2. Domain Extraction
-- [ ] 2.1 Extract query-param and selected-conversation synchronization into a dedicated hook/module.
-- [ ] 2.2 Extract compact panel open/close/reset behavior into a dedicated hook/module.
-- [ ] 2.3 Extract suggestions-count loading/error behavior into a dedicated hook/module.
-- [ ] 2.4 Extract attention-cue snapshot, suppression, sound/browser notification, and title-update behavior into a dedicated hook/module.
-- [ ] 2.5 Extract message-action helpers (send/resolve/convert/mark-read side effects) into focused helpers/hooks.
+- [x] 2.1 Extract query-param and selected-conversation synchronization into a dedicated hook/module.
+- [x] 2.2 Extract compact panel open/close/reset behavior into a dedicated hook/module.
+- [x] 2.3 Extract suggestions-count loading/error behavior into a dedicated hook/module.
+- [x] 2.4 Extract attention-cue snapshot, suppression, sound/browser notification, and title-update behavior into a dedicated hook/module.
+- [x] 2.5 Extract message-action helpers (send/resolve/convert/mark-read side effects) into focused helpers/hooks.
## 3. Behavioral Parity Tests
-- [ ] 3.1 Add tests for URL-selection synchronization invariants.
-- [ ] 3.2 Add tests for compact panel reset rules across viewport and sidecar toggles.
-- [ ] 3.3 Add tests for unread cue suppression and title-update behavior.
+- [x] 3.1 Add tests for URL-selection synchronization invariants.
+- [x] 3.2 Add tests for compact panel reset rules across viewport and sidecar toggles.
+- [x] 3.3 Add tests for unread cue suppression and title-update behavior.
## 4. Verification And Cleanup
-- [ ] 4.1 Remove obsolete page-local state/effect logic after extraction.
-- [ ] 4.2 Run targeted web lint/typecheck/tests for inbox paths and resolve regressions.
-- [ ] 4.3 Document inbox module ownership and extension points for future contributors.
+- [x] 4.1 Remove obsolete page-local state/effect logic after extraction.
+- [x] 4.2 Run targeted web lint/typecheck/tests for inbox paths and resolve regressions.
+- [x] 4.3 Document inbox module ownership and extension points for future contributors.
From 4b9c0286c181a3f911784f3fe933274d31c9cdd5 Mon Sep 17 00:00:00 2001
From: Jack D
Date: Thu, 5 Mar 2026 15:01:47 +0000
Subject: [PATCH 04/91] Archive proposals
---
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../specs/web-inbox-modularity/spec.md | 0
.../tasks.md | 0
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../cross-surface-notification-cues/spec.md | 0
.../tasks.md | 0
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../spec.md | 0
.../tasks.md | 0
.../cross-surface-notification-cues/spec.md | 34 ++++++++++++
.../spec.md | 39 ++++++++++++++
openspec/specs/web-inbox-modularity/spec.md | 52 +++++++++++++++++++
18 files changed, 125 insertions(+)
rename openspec/changes/{decompose-web-inbox-page => archive/2026-03-05-decompose-web-inbox-page}/.openspec.yaml (100%)
rename openspec/changes/{decompose-web-inbox-page => archive/2026-03-05-decompose-web-inbox-page}/design.md (100%)
rename openspec/changes/{decompose-web-inbox-page => archive/2026-03-05-decompose-web-inbox-page}/proposal.md (100%)
rename openspec/changes/{decompose-web-inbox-page => archive/2026-03-05-decompose-web-inbox-page}/specs/web-inbox-modularity/spec.md (100%)
rename openspec/changes/{decompose-web-inbox-page => archive/2026-03-05-decompose-web-inbox-page}/tasks.md (100%)
rename openspec/changes/{unify-inbox-widget-notification-cues => archive/2026-03-05-unify-inbox-widget-notification-cues}/.openspec.yaml (100%)
rename openspec/changes/{unify-inbox-widget-notification-cues => archive/2026-03-05-unify-inbox-widget-notification-cues}/design.md (100%)
rename openspec/changes/{unify-inbox-widget-notification-cues => archive/2026-03-05-unify-inbox-widget-notification-cues}/proposal.md (100%)
rename openspec/changes/{unify-inbox-widget-notification-cues => archive/2026-03-05-unify-inbox-widget-notification-cues}/specs/cross-surface-notification-cues/spec.md (100%)
rename openspec/changes/{unify-inbox-widget-notification-cues => archive/2026-03-05-unify-inbox-widget-notification-cues}/tasks.md (100%)
rename openspec/changes/{unify-markdown-rendering-utility => archive/2026-03-05-unify-markdown-rendering-utility}/.openspec.yaml (100%)
rename openspec/changes/{unify-markdown-rendering-utility => archive/2026-03-05-unify-markdown-rendering-utility}/design.md (100%)
rename openspec/changes/{unify-markdown-rendering-utility => archive/2026-03-05-unify-markdown-rendering-utility}/proposal.md (100%)
rename openspec/changes/{unify-markdown-rendering-utility => archive/2026-03-05-unify-markdown-rendering-utility}/specs/shared-markdown-rendering-sanitization/spec.md (100%)
rename openspec/changes/{unify-markdown-rendering-utility => archive/2026-03-05-unify-markdown-rendering-utility}/tasks.md (100%)
create mode 100644 openspec/specs/cross-surface-notification-cues/spec.md
create mode 100644 openspec/specs/shared-markdown-rendering-sanitization/spec.md
create mode 100644 openspec/specs/web-inbox-modularity/spec.md
diff --git a/openspec/changes/decompose-web-inbox-page/.openspec.yaml b/openspec/changes/archive/2026-03-05-decompose-web-inbox-page/.openspec.yaml
similarity index 100%
rename from openspec/changes/decompose-web-inbox-page/.openspec.yaml
rename to openspec/changes/archive/2026-03-05-decompose-web-inbox-page/.openspec.yaml
diff --git a/openspec/changes/decompose-web-inbox-page/design.md b/openspec/changes/archive/2026-03-05-decompose-web-inbox-page/design.md
similarity index 100%
rename from openspec/changes/decompose-web-inbox-page/design.md
rename to openspec/changes/archive/2026-03-05-decompose-web-inbox-page/design.md
diff --git a/openspec/changes/decompose-web-inbox-page/proposal.md b/openspec/changes/archive/2026-03-05-decompose-web-inbox-page/proposal.md
similarity index 100%
rename from openspec/changes/decompose-web-inbox-page/proposal.md
rename to openspec/changes/archive/2026-03-05-decompose-web-inbox-page/proposal.md
diff --git a/openspec/changes/decompose-web-inbox-page/specs/web-inbox-modularity/spec.md b/openspec/changes/archive/2026-03-05-decompose-web-inbox-page/specs/web-inbox-modularity/spec.md
similarity index 100%
rename from openspec/changes/decompose-web-inbox-page/specs/web-inbox-modularity/spec.md
rename to openspec/changes/archive/2026-03-05-decompose-web-inbox-page/specs/web-inbox-modularity/spec.md
diff --git a/openspec/changes/decompose-web-inbox-page/tasks.md b/openspec/changes/archive/2026-03-05-decompose-web-inbox-page/tasks.md
similarity index 100%
rename from openspec/changes/decompose-web-inbox-page/tasks.md
rename to openspec/changes/archive/2026-03-05-decompose-web-inbox-page/tasks.md
diff --git a/openspec/changes/unify-inbox-widget-notification-cues/.openspec.yaml b/openspec/changes/archive/2026-03-05-unify-inbox-widget-notification-cues/.openspec.yaml
similarity index 100%
rename from openspec/changes/unify-inbox-widget-notification-cues/.openspec.yaml
rename to openspec/changes/archive/2026-03-05-unify-inbox-widget-notification-cues/.openspec.yaml
diff --git a/openspec/changes/unify-inbox-widget-notification-cues/design.md b/openspec/changes/archive/2026-03-05-unify-inbox-widget-notification-cues/design.md
similarity index 100%
rename from openspec/changes/unify-inbox-widget-notification-cues/design.md
rename to openspec/changes/archive/2026-03-05-unify-inbox-widget-notification-cues/design.md
diff --git a/openspec/changes/unify-inbox-widget-notification-cues/proposal.md b/openspec/changes/archive/2026-03-05-unify-inbox-widget-notification-cues/proposal.md
similarity index 100%
rename from openspec/changes/unify-inbox-widget-notification-cues/proposal.md
rename to openspec/changes/archive/2026-03-05-unify-inbox-widget-notification-cues/proposal.md
diff --git a/openspec/changes/unify-inbox-widget-notification-cues/specs/cross-surface-notification-cues/spec.md b/openspec/changes/archive/2026-03-05-unify-inbox-widget-notification-cues/specs/cross-surface-notification-cues/spec.md
similarity index 100%
rename from openspec/changes/unify-inbox-widget-notification-cues/specs/cross-surface-notification-cues/spec.md
rename to openspec/changes/archive/2026-03-05-unify-inbox-widget-notification-cues/specs/cross-surface-notification-cues/spec.md
diff --git a/openspec/changes/unify-inbox-widget-notification-cues/tasks.md b/openspec/changes/archive/2026-03-05-unify-inbox-widget-notification-cues/tasks.md
similarity index 100%
rename from openspec/changes/unify-inbox-widget-notification-cues/tasks.md
rename to openspec/changes/archive/2026-03-05-unify-inbox-widget-notification-cues/tasks.md
diff --git a/openspec/changes/unify-markdown-rendering-utility/.openspec.yaml b/openspec/changes/archive/2026-03-05-unify-markdown-rendering-utility/.openspec.yaml
similarity index 100%
rename from openspec/changes/unify-markdown-rendering-utility/.openspec.yaml
rename to openspec/changes/archive/2026-03-05-unify-markdown-rendering-utility/.openspec.yaml
diff --git a/openspec/changes/unify-markdown-rendering-utility/design.md b/openspec/changes/archive/2026-03-05-unify-markdown-rendering-utility/design.md
similarity index 100%
rename from openspec/changes/unify-markdown-rendering-utility/design.md
rename to openspec/changes/archive/2026-03-05-unify-markdown-rendering-utility/design.md
diff --git a/openspec/changes/unify-markdown-rendering-utility/proposal.md b/openspec/changes/archive/2026-03-05-unify-markdown-rendering-utility/proposal.md
similarity index 100%
rename from openspec/changes/unify-markdown-rendering-utility/proposal.md
rename to openspec/changes/archive/2026-03-05-unify-markdown-rendering-utility/proposal.md
diff --git a/openspec/changes/unify-markdown-rendering-utility/specs/shared-markdown-rendering-sanitization/spec.md b/openspec/changes/archive/2026-03-05-unify-markdown-rendering-utility/specs/shared-markdown-rendering-sanitization/spec.md
similarity index 100%
rename from openspec/changes/unify-markdown-rendering-utility/specs/shared-markdown-rendering-sanitization/spec.md
rename to openspec/changes/archive/2026-03-05-unify-markdown-rendering-utility/specs/shared-markdown-rendering-sanitization/spec.md
diff --git a/openspec/changes/unify-markdown-rendering-utility/tasks.md b/openspec/changes/archive/2026-03-05-unify-markdown-rendering-utility/tasks.md
similarity index 100%
rename from openspec/changes/unify-markdown-rendering-utility/tasks.md
rename to openspec/changes/archive/2026-03-05-unify-markdown-rendering-utility/tasks.md
diff --git a/openspec/specs/cross-surface-notification-cues/spec.md b/openspec/specs/cross-surface-notification-cues/spec.md
new file mode 100644
index 0000000..2bfdd8f
--- /dev/null
+++ b/openspec/specs/cross-surface-notification-cues/spec.md
@@ -0,0 +1,34 @@
+# cross-surface-notification-cues Specification
+
+## Purpose
+TBD - created by archiving change unify-inbox-widget-notification-cues. Update Purpose after archive.
+## Requirements
+### Requirement: Notification cue core logic MUST be shared across web inbox and widget
+
+Unread snapshot construction, unread increase detection, and suppression predicates SHALL be implemented in a shared core utility consumed by both surfaces.
+
+#### Scenario: Unread increase is detected
+
+- **WHEN** unread counts increase for one or more conversations
+- **THEN** both web and widget cue adapters SHALL derive increases using the shared core algorithm
+
+### Requirement: Surface-specific defaults MUST remain explicit configuration
+
+Differences in defaults or persistence behavior between web and widget MUST be expressed through explicit adapter configuration rather than duplicated core algorithms.
+
+#### Scenario: Web and widget default preference values differ
+
+- **WHEN** a surface loads cue preferences without persisted settings
+- **THEN** each surface SHALL apply its explicit configured defaults
+- **AND** shared core cue calculations SHALL remain unchanged
+
+### Requirement: Cue suppression behavior MUST remain consistent for active focused conversations
+
+Cue suppression logic SHALL suppress attention cues when the active conversation is currently visible and focused according to shared predicate rules.
+
+#### Scenario: Active conversation unread increases while focused
+
+- **WHEN** unread count increases on the currently visible active conversation and focus/visibility are true
+- **THEN** attention cues SHALL be suppressed
+- **AND** cues for other conversations SHALL still be eligible
+
diff --git a/openspec/specs/shared-markdown-rendering-sanitization/spec.md b/openspec/specs/shared-markdown-rendering-sanitization/spec.md
new file mode 100644
index 0000000..aad3c55
--- /dev/null
+++ b/openspec/specs/shared-markdown-rendering-sanitization/spec.md
@@ -0,0 +1,39 @@
+# shared-markdown-rendering-sanitization Specification
+
+## Purpose
+TBD - created by archiving change unify-markdown-rendering-utility. Update Purpose after archive.
+## Requirements
+### Requirement: Web and widget MUST consume a shared markdown rendering implementation
+
+Markdown rendering and sanitization SHALL be implemented in a shared utility module consumed by both web and widget surfaces.
+
+#### Scenario: Core parser behavior update
+
+- **WHEN** parser settings (for example breaks/linkify behavior) are changed
+- **THEN** the update SHALL be made in the shared utility
+- **AND** both web and widget SHALL consume the updated behavior through shared imports
+
+### Requirement: Shared sanitization policy MUST enforce equivalent safety guarantees
+
+The shared utility MUST apply one canonical sanitization and link-hardening policy for supported markdown content.
+
+#### Scenario: Unsafe protocol appears in markdown link
+
+- **WHEN** markdown contains a link with a disallowed protocol
+- **THEN** rendered output SHALL remove or neutralize that unsafe link target
+- **AND** surrounding content SHALL still render safely
+
+#### Scenario: Allowed markdown image/link content is rendered
+
+- **WHEN** markdown includes allowed link and image content
+- **THEN** rendering SHALL preserve allowed elements and attributes according to the shared policy
+
+### Requirement: Shared utility MUST preserve frontmatter stripping and excerpt helper behavior
+
+The shared utility SHALL support frontmatter stripping and plain-text excerpt helpers used by consuming surfaces.
+
+#### Scenario: Markdown includes YAML frontmatter
+
+- **WHEN** input markdown begins with frontmatter metadata
+- **THEN** rendered output and excerpt generation SHALL ignore the frontmatter block
+
diff --git a/openspec/specs/web-inbox-modularity/spec.md b/openspec/specs/web-inbox-modularity/spec.md
new file mode 100644
index 0000000..7707002
--- /dev/null
+++ b/openspec/specs/web-inbox-modularity/spec.md
@@ -0,0 +1,52 @@
+# web-inbox-modularity Specification
+
+## Purpose
+TBD - created by archiving change decompose-web-inbox-page. Update Purpose after archive.
+## Requirements
+### Requirement: Inbox page MUST isolate orchestration concerns into domain modules
+
+The inbox route implementation SHALL separate orchestration concerns into explicit modules with clear contracts instead of concentrating all behavior in one route file.
+
+#### Scenario: Updating suggestion count logic
+
+- **WHEN** a contributor changes only suggestion-count fetch behavior
+- **THEN** the change SHALL be made in the suggestions domain module
+- **AND** conversation selection and URL-sync modules SHALL not require edits
+
+#### Scenario: Updating unread cue behavior
+
+- **WHEN** a contributor changes unread cue suppression logic
+- **THEN** the change SHALL be made in the attention-cue domain module
+- **AND** message action and compact panel modules SHALL remain unaffected
+
+### Requirement: Inbox refactor MUST preserve URL and selection synchronization behavior
+
+The inbox refactor MUST preserve current query-parameter behavior for conversation selection and de-selection.
+
+#### Scenario: Query parameter selects a conversation
+
+- **WHEN** the inbox route loads with a valid `conversationId` query parameter
+- **THEN** the selected conversation state SHALL match that identifier
+- **AND** the page SHALL keep selection synchronized with subsequent route updates
+
+#### Scenario: Selected conversation updates URL
+
+- **WHEN** an agent selects or clears a conversation in the inbox UI
+- **THEN** the route query parameter SHALL be updated consistently with current selection
+- **AND** stale legacy query keys SHALL be removed as they are today
+
+### Requirement: Inbox decomposition MUST preserve compact panel and cue behavior
+
+The refactor SHALL preserve compact sidecar panel rules and unread cue suppression logic.
+
+#### Scenario: Compact viewport state changes
+
+- **WHEN** viewport mode switches between compact and non-compact
+- **THEN** compact sidecar panel state SHALL reset according to existing behavior
+
+#### Scenario: Unread count increases for selected visible conversation
+
+- **WHEN** unread count increases on a conversation that is currently selected and visible with focus
+- **THEN** attention cues SHALL be suppressed
+- **AND** unread cues SHALL still trigger for other conversations
+
From 21080537a2025c035581700be7bda5770d3d1914 Mon Sep 17 00:00:00 2001
From: Jack D
Date: Thu, 5 Mar 2026 15:02:33 +0000
Subject: [PATCH 05/91] modularize convex notifications domain
---
.../tasks.md | 24 +-
packages/convex/convex/_generated/api.d.ts | 14 +
packages/convex/convex/notifications.ts | 1624 +----------------
.../convex/convex/notifications/README.md | 26 +
.../convex/convex/notifications/contracts.ts | 64 +
.../convex/convex/notifications/dispatch.ts | 222 +++
.../convex/notifications/emitters/chat.ts | 390 ++++
.../convex/notifications/emitters/ticket.ts | 225 +++
.../convex/convex/notifications/helpers.ts | 228 +++
.../convex/convex/notifications/recipients.ts | 215 +++
.../convex/convex/notifications/routing.ts | 318 ++++
.../tests/notificationEmailBatching.test.ts | 64 +
.../convex/tests/notificationRouting.test.ts | 278 +++
13 files changed, 2077 insertions(+), 1615 deletions(-)
create mode 100644 packages/convex/convex/notifications/README.md
create mode 100644 packages/convex/convex/notifications/contracts.ts
create mode 100644 packages/convex/convex/notifications/dispatch.ts
create mode 100644 packages/convex/convex/notifications/emitters/chat.ts
create mode 100644 packages/convex/convex/notifications/emitters/ticket.ts
create mode 100644 packages/convex/convex/notifications/helpers.ts
create mode 100644 packages/convex/convex/notifications/recipients.ts
create mode 100644 packages/convex/convex/notifications/routing.ts
create mode 100644 packages/convex/tests/notificationEmailBatching.test.ts
diff --git a/openspec/changes/modularize-convex-notifications-domain/tasks.md b/openspec/changes/modularize-convex-notifications-domain/tasks.md
index be6d79c..77e6d13 100644
--- a/openspec/changes/modularize-convex-notifications-domain/tasks.md
+++ b/openspec/changes/modularize-convex-notifications-domain/tasks.md
@@ -1,23 +1,23 @@
## 1. Module Boundary Setup
-- [ ] 1.1 Define notification domain boundaries (recipients, routing, delivery channels, event emitters) and create target module layout.
-- [ ] 1.2 Add typed internal contracts for shared notification context and payload helpers.
+- [x] 1.1 Define notification domain boundaries (recipients, routing, delivery channels, event emitters) and create target module layout.
+- [x] 1.2 Add typed internal contracts for shared notification context and payload helpers.
## 2. Incremental Extraction
-- [ ] 2.1 Extract pure helper logic (formatting, truncation, metadata rendering, batch selection) into focused utility modules.
-- [ ] 2.2 Extract recipient-resolution logic into dedicated modules for agent and visitor audiences.
-- [ ] 2.3 Extract channel-dispatch orchestration (email/push scheduling and logging) into dedicated modules.
-- [ ] 2.4 Extract event-specific emitters for chat and ticket events while keeping existing exported entry points stable.
+- [x] 2.1 Extract pure helper logic (formatting, truncation, metadata rendering, batch selection) into focused utility modules.
+- [x] 2.2 Extract recipient-resolution logic into dedicated modules for agent and visitor audiences.
+- [x] 2.3 Extract channel-dispatch orchestration (email/push scheduling and logging) into dedicated modules.
+- [x] 2.4 Extract event-specific emitters for chat and ticket events while keeping existing exported entry points stable.
## 3. Parity Verification
-- [ ] 3.1 Add tests for new visitor message routing and recipient selection parity.
-- [ ] 3.2 Add tests for debounced support-reply email batching parity.
-- [ ] 3.3 Add tests for ticket notification routing parity (assignment/status/comment paths).
+- [x] 3.1 Add tests for new visitor message routing and recipient selection parity.
+- [x] 3.2 Add tests for debounced support-reply email batching parity.
+- [x] 3.3 Add tests for ticket notification routing parity (assignment/status/comment paths).
## 4. Cleanup And Validation
-- [ ] 4.1 Remove obsolete monolithic logic from `notifications.ts` after extraction.
-- [ ] 4.2 Run targeted Convex typecheck/tests and strict OpenSpec validation for the change.
-- [ ] 4.3 Document notification module ownership and extension patterns for contributors.
+- [x] 4.1 Remove obsolete monolithic logic from `notifications.ts` after extraction.
+- [x] 4.2 Run targeted Convex typecheck/tests and strict OpenSpec validation for the change.
+- [x] 4.3 Document notification module ownership and extension patterns for contributors.
diff --git a/packages/convex/convex/_generated/api.d.ts b/packages/convex/convex/_generated/api.d.ts
index 26fbb9b..d2f3943 100644
--- a/packages/convex/convex/_generated/api.d.ts
+++ b/packages/convex/convex/_generated/api.d.ts
@@ -49,6 +49,13 @@ import type * as migrations_removePasswordHash from "../migrations/removePasswor
import type * as migrations_removeUseSignedSessions from "../migrations/removeUseSignedSessions.js";
import type * as notificationSettings from "../notificationSettings.js";
import type * as notifications from "../notifications.js";
+import type * as notifications_contracts from "../notifications/contracts.js";
+import type * as notifications_dispatch from "../notifications/dispatch.js";
+import type * as notifications_emitters_chat from "../notifications/emitters/chat.js";
+import type * as notifications_emitters_ticket from "../notifications/emitters/ticket.js";
+import type * as notifications_helpers from "../notifications/helpers.js";
+import type * as notifications_recipients from "../notifications/recipients.js";
+import type * as notifications_routing from "../notifications/routing.js";
import type * as officeHours from "../officeHours.js";
import type * as originValidation from "../originValidation.js";
import type * as outboundMessages from "../outboundMessages.js";
@@ -135,6 +142,13 @@ declare const fullApi: ApiFromModules<{
"migrations/removeUseSignedSessions": typeof migrations_removeUseSignedSessions;
notificationSettings: typeof notificationSettings;
notifications: typeof notifications;
+ "notifications/contracts": typeof notifications_contracts;
+ "notifications/dispatch": typeof notifications_dispatch;
+ "notifications/emitters/chat": typeof notifications_emitters_chat;
+ "notifications/emitters/ticket": typeof notifications_emitters_ticket;
+ "notifications/helpers": typeof notifications_helpers;
+ "notifications/recipients": typeof notifications_recipients;
+ "notifications/routing": typeof notifications_routing;
officeHours: typeof officeHours;
originValidation: typeof originValidation;
outboundMessages: typeof outboundMessages;
diff --git a/packages/convex/convex/notifications.ts b/packages/convex/convex/notifications.ts
index 58f761b..acc2831 100644
--- a/packages/convex/convex/notifications.ts
+++ b/packages/convex/convex/notifications.ts
@@ -1,1603 +1,21 @@
-import { v } from "convex/values";
-import { internalMutation, internalAction, internalQuery } from "./_generated/server";
-import { internal } from "./_generated/api";
-import { Doc, Id } from "./_generated/dataModel";
-import { jsonRecordValidator } from "./validators";
-import { sendEmail } from "./email";
-import {
- resolveMemberNewVisitorMessagePreference,
- resolveWorkspaceNewVisitorMessageDefaults,
-} from "./lib/notificationPreferences";
-
-export const getPushTokensForWorkspace = internalQuery({
- args: {
- workspaceId: v.id("workspaces"),
- excludeUserId: v.optional(v.id("users")),
- event: v.optional(v.literal("newVisitorMessage")),
- },
- handler: async (ctx, args) => {
- const users = await ctx.db
- .query("users")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
-
- const workspaceDefaults = args.event
- ? await ctx.db
- .query("workspaceNotificationDefaults")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .first()
- : null;
-
- const defaultNewVisitorMessagePreferences =
- resolveWorkspaceNewVisitorMessageDefaults(workspaceDefaults);
-
- const tokens: { token: string; platform: "ios" | "android"; userId: Id<"users"> }[] = [];
-
- for (const user of users) {
- if (args.excludeUserId && user._id === args.excludeUserId) {
- continue;
- }
-
- const prefs = await ctx.db
- .query("notificationPreferences")
- .withIndex("by_user_workspace", (q) =>
- q.eq("userId", user._id).eq("workspaceId", args.workspaceId)
- )
- .first();
-
- const pushEnabled =
- args.event === "newVisitorMessage"
- ? resolveMemberNewVisitorMessagePreference(prefs, defaultNewVisitorMessagePreferences)
- .push
- : !prefs?.muted;
-
- if (!pushEnabled) {
- continue;
- }
-
- const userTokens = await ctx.db
- .query("pushTokens")
- .withIndex("by_user", (q) => q.eq("userId", user._id))
- .collect();
-
- for (const t of userTokens) {
- if (t.notificationsEnabled === false) {
- continue;
- }
- tokens.push({
- token: t.token,
- platform: t.platform,
- userId: user._id,
- });
- }
- }
-
- return tokens;
- },
-});
-
-export const getMemberRecipientsForNewVisitorMessage = internalQuery({
- args: {
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- const users = await ctx.db
- .query("users")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
-
- const workspaceDefaults = await ctx.db
- .query("workspaceNotificationDefaults")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .first();
-
- const defaultNewVisitorMessagePreferences =
- resolveWorkspaceNewVisitorMessageDefaults(workspaceDefaults);
-
- const emailRecipients: string[] = [];
- const pushRecipients: {
- token: string;
- platform: "ios" | "android";
- userId: Id<"users">;
- }[] = [];
-
- const decisions: Array<{
- userId: Id<"users">;
- emailEnabled: boolean;
- pushEnabled: boolean;
- pushTokenCount: number;
- emailAddress: string | null;
- }> = [];
-
- for (const user of users) {
- const prefs = await ctx.db
- .query("notificationPreferences")
- .withIndex("by_user_workspace", (q) =>
- q.eq("userId", user._id).eq("workspaceId", args.workspaceId)
- )
- .first();
-
- const effective = resolveMemberNewVisitorMessagePreference(
- prefs,
- defaultNewVisitorMessagePreferences
- );
-
- if (effective.email && user.email) {
- emailRecipients.push(user.email);
- }
-
- let enabledPushTokenCount = 0;
- if (effective.push) {
- const userTokens = await ctx.db
- .query("pushTokens")
- .withIndex("by_user", (q) => q.eq("userId", user._id))
- .collect();
-
- for (const token of userTokens) {
- if (token.notificationsEnabled === false) {
- continue;
- }
- enabledPushTokenCount += 1;
- pushRecipients.push({
- token: token.token,
- platform: token.platform,
- userId: user._id,
- });
- }
- }
-
- decisions.push({
- userId: user._id,
- emailEnabled: effective.email,
- pushEnabled: effective.push,
- pushTokenCount: enabledPushTokenCount,
- emailAddress: user.email ?? null,
- });
- }
-
- return {
- emailRecipients,
- pushRecipients,
- decisions,
- };
- },
-});
-
-export const getVisitorRecipientsForSupportReply = internalQuery({
- args: {
- conversationId: v.id("conversations"),
- channel: v.optional(v.union(v.literal("chat"), v.literal("email"))),
- },
- handler: async (ctx, args) => {
- const conversation = (await ctx.db.get(args.conversationId)) as Doc<"conversations"> | null;
- const visitorId = conversation?.visitorId;
- if (!visitorId) {
- return {
- emailRecipient: null as string | null,
- pushTokens: [] as string[],
- };
- }
-
- const visitor = (await ctx.db.get(visitorId)) as Doc<"visitors"> | null;
- const visitorTokens = await ctx.db
- .query("visitorPushTokens")
- .withIndex("by_visitor", (q) => q.eq("visitorId", visitorId))
- .collect();
-
- return {
- emailRecipient: visitor?.email && args.channel !== "email" ? visitor.email : null,
- pushTokens: visitorTokens
- .filter((token) => token.notificationsEnabled !== false)
- .map((token) => token.token),
- };
- },
-});
-
-export const sendPushNotification = internalAction({
- args: {
- tokens: v.array(v.string()),
- title: v.string(),
- body: v.string(),
- data: v.optional(jsonRecordValidator),
- },
- handler: async (
- ctx,
- args
- ): Promise<{
- success: boolean;
- sent: number;
- failed?: number;
- error?: string;
- tickets: Array<{
- status: string;
- id?: string;
- error?: string;
- errorCode?: string;
- token?: string;
- }>;
- }> => {
- if (args.tokens.length === 0) {
- return { success: true, sent: 0, tickets: [] };
- }
-
- return await ctx.runAction(internal.push.sendPush, {
- tokens: args.tokens,
- title: args.title,
- body: args.body,
- data: args.data,
- });
- },
-});
-
-export const sendNotificationEmail = internalAction({
- args: {
- to: v.string(),
- subject: v.string(),
- html: v.string(),
- },
- handler: async (_ctx, args) => {
- return await sendEmail(args.to, args.subject, args.html);
- },
-});
-
-const notificationEventTypeValidator = v.union(
- v.literal("chat_message"),
- v.literal("new_conversation"),
- v.literal("assignment"),
- v.literal("ticket_created"),
- v.literal("ticket_status_changed"),
- v.literal("ticket_assigned"),
- v.literal("ticket_comment"),
- v.literal("ticket_customer_reply"),
- v.literal("ticket_resolved"),
- v.literal("outbound_message"),
- v.literal("carousel_trigger"),
- v.literal("push_campaign")
-);
-const notificationDomainValidator = v.union(
- v.literal("chat"),
- v.literal("ticket"),
- v.literal("outbound"),
- v.literal("campaign")
-);
-const notificationAudienceValidator = v.union(
- v.literal("agent"),
- v.literal("visitor"),
- v.literal("both")
-);
-const notificationActorTypeValidator = v.union(
- v.literal("agent"),
- v.literal("visitor"),
- v.literal("bot"),
- v.literal("system")
-);
-const notificationChannelValidator = v.union(
- v.literal("push"),
- v.literal("email"),
- v.literal("web"),
- v.literal("widget")
-);
-const notificationRecipientTypeValidator = v.union(v.literal("agent"), v.literal("visitor"));
-
-type NotificationPushAttempt = {
- dedupeKey: string;
- recipientType: "agent" | "visitor";
- userId?: Id<"users">;
- visitorId?: Id<"visitors">;
- tokens: string[];
-};
-
-function truncatePreview(value: string, maxLength: number): string {
- return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`;
-}
-
-function buildDefaultEventKey(args: {
- eventType: string;
- conversationId?: Id<"conversations">;
- ticketId?: Id<"tickets">;
- outboundMessageId?: Id<"outboundMessages">;
- campaignId?: Id<"pushCampaigns">;
- actorUserId?: Id<"users">;
- actorVisitorId?: Id<"visitors">;
-}) {
- const primaryId =
- args.conversationId ?? args.ticketId ?? args.outboundMessageId ?? args.campaignId ?? "none";
- const actorId = args.actorUserId ?? args.actorVisitorId ?? "system";
- return `${args.eventType}:${String(primaryId)}:${String(actorId)}:${Date.now()}`;
-}
-
-async function resolveDefaultVisitorRecipients(
- ctx: any,
- args: {
- conversationId?: Id<"conversations">;
- ticketId?: Id<"tickets">;
- }
-): Promise[]> {
- if (args.conversationId) {
- const conversation = (await ctx.db.get(args.conversationId)) as Doc<"conversations"> | null;
- if (conversation?.visitorId) {
- return [conversation.visitorId];
- }
- }
- if (args.ticketId) {
- const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null;
- if (ticket?.visitorId) {
- return [ticket.visitorId];
- }
- }
- return [];
-}
-
-export const logDeliveryOutcome = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- eventId: v.optional(v.id("notificationEvents")),
- eventKey: v.string(),
- dedupeKey: v.string(),
- channel: notificationChannelValidator,
- recipientType: notificationRecipientTypeValidator,
- userId: v.optional(v.id("users")),
- visitorId: v.optional(v.id("visitors")),
- tokenCount: v.optional(v.number()),
- status: v.union(v.literal("delivered"), v.literal("suppressed"), v.literal("failed")),
- reason: v.optional(v.string()),
- error: v.optional(v.string()),
- metadata: v.optional(jsonRecordValidator),
- },
- handler: async (ctx, args) => {
- await ctx.db.insert("notificationDeliveries", {
- workspaceId: args.workspaceId,
- eventId: args.eventId,
- eventKey: args.eventKey,
- dedupeKey: args.dedupeKey,
- channel: args.channel,
- recipientType: args.recipientType,
- userId: args.userId,
- visitorId: args.visitorId,
- tokenCount: args.tokenCount,
- status: args.status,
- reason: args.reason,
- error: args.error,
- metadata: args.metadata,
- createdAt: Date.now(),
- });
- },
-});
-
-export const dispatchPushAttempts = internalAction({
- args: {
- workspaceId: v.id("workspaces"),
- eventId: v.optional(v.id("notificationEvents")),
- eventKey: v.string(),
- title: v.optional(v.string()),
- body: v.string(),
- data: v.optional(jsonRecordValidator),
- attempts: v.array(
- v.object({
- dedupeKey: v.string(),
- recipientType: notificationRecipientTypeValidator,
- userId: v.optional(v.id("users")),
- visitorId: v.optional(v.id("visitors")),
- tokens: v.array(v.string()),
- })
- ),
- },
- handler: async (ctx, args) => {
- let delivered = 0;
- let failed = 0;
- const results: Array<{
- dedupeKey: string;
- status: "delivered" | "suppressed" | "failed";
- sent: number;
- failed: number;
- error?: string;
- reason?: string;
- }> = [];
-
- for (const attempt of args.attempts) {
- if (attempt.tokens.length === 0) {
- failed += 1;
- await ctx.runMutation(internal.notifications.logDeliveryOutcome, {
- workspaceId: args.workspaceId,
- eventId: args.eventId,
- eventKey: args.eventKey,
- dedupeKey: attempt.dedupeKey,
- channel: "push",
- recipientType: attempt.recipientType,
- userId: attempt.userId,
- visitorId: attempt.visitorId,
- tokenCount: 0,
- status: "suppressed",
- reason: "missing_push_token",
- });
- results.push({
- dedupeKey: attempt.dedupeKey,
- status: "suppressed",
- sent: 0,
- failed: 0,
- reason: "missing_push_token",
- });
- continue;
- }
-
- const result = await ctx.runAction(internal.push.sendPush, {
- tokens: attempt.tokens,
- title: args.title,
- body: args.body,
- data: args.data,
- });
- const sent = result.sent ?? 0;
- const failedCount = result.failed ?? 0;
- const failedTicket = (result.tickets ?? []).find(
- (ticket: { status?: string; error?: string }) => ticket.status === "error"
- );
-
- if (sent > 0) {
- delivered += 1;
- await ctx.runMutation(internal.notifications.logDeliveryOutcome, {
- workspaceId: args.workspaceId,
- eventId: args.eventId,
- eventKey: args.eventKey,
- dedupeKey: attempt.dedupeKey,
- channel: "push",
- recipientType: attempt.recipientType,
- userId: attempt.userId,
- visitorId: attempt.visitorId,
- tokenCount: attempt.tokens.length,
- status: "delivered",
- ...(failedCount > 0
- ? {
- reason: "partial_delivery",
- metadata: {
- sent,
- failed: failedCount,
- },
- }
- : {}),
- });
- results.push({
- dedupeKey: attempt.dedupeKey,
- status: "delivered",
- sent,
- failed: failedCount,
- ...(failedCount > 0 ? { reason: "partial_delivery" } : {}),
- });
- } else {
- failed += 1;
- const errorMessage = result.error ?? failedTicket?.error ?? "Push transport error";
- await ctx.runMutation(internal.notifications.logDeliveryOutcome, {
- workspaceId: args.workspaceId,
- eventId: args.eventId,
- eventKey: args.eventKey,
- dedupeKey: attempt.dedupeKey,
- channel: "push",
- recipientType: attempt.recipientType,
- userId: attempt.userId,
- visitorId: attempt.visitorId,
- tokenCount: attempt.tokens.length,
- status: "failed",
- error: errorMessage,
- });
- results.push({
- dedupeKey: attempt.dedupeKey,
- status: "failed",
- sent,
- failed: failedCount || attempt.tokens.length,
- error: errorMessage,
- });
- }
- }
-
- return {
- attempted: args.attempts.length,
- delivered,
- failed,
- results,
- };
- },
-});
-
-export const routeEvent = internalMutation({
- args: {
- eventType: notificationEventTypeValidator,
- domain: notificationDomainValidator,
- audience: notificationAudienceValidator,
- workspaceId: v.id("workspaces"),
- actorType: notificationActorTypeValidator,
- actorUserId: v.optional(v.id("users")),
- actorVisitorId: v.optional(v.id("visitors")),
- conversationId: v.optional(v.id("conversations")),
- ticketId: v.optional(v.id("tickets")),
- outboundMessageId: v.optional(v.id("outboundMessages")),
- campaignId: v.optional(v.id("pushCampaigns")),
- title: v.optional(v.string()),
- body: v.string(),
- data: v.optional(jsonRecordValidator),
- recipientUserIds: v.optional(v.array(v.id("users"))),
- recipientVisitorIds: v.optional(v.array(v.id("visitors"))),
- excludeUserIds: v.optional(v.array(v.id("users"))),
- excludeVisitorIds: v.optional(v.array(v.id("visitors"))),
- eventKey: v.optional(v.string()),
- },
- handler: async (ctx, args) => {
- const eventKey = args.eventKey ?? buildDefaultEventKey(args);
- const eventId = await ctx.db.insert("notificationEvents", {
- workspaceId: args.workspaceId,
- eventKey,
- eventType: args.eventType,
- domain: args.domain,
- audience: args.audience,
- actorType: args.actorType,
- actorUserId: args.actorUserId,
- actorVisitorId: args.actorVisitorId,
- conversationId: args.conversationId,
- ticketId: args.ticketId,
- outboundMessageId: args.outboundMessageId,
- campaignId: args.campaignId,
- title: args.title,
- bodyPreview: truncatePreview(args.body, 280),
- data: args.data,
- createdAt: Date.now(),
- });
-
- const attempts: NotificationPushAttempt[] = [];
- let suppressed = 0;
-
- const recordSuppressed = async (entry: {
- dedupeKey: string;
- recipientType: "agent" | "visitor";
- userId?: Id<"users">;
- visitorId?: Id<"visitors">;
- reason: string;
- error?: string;
- }) => {
- suppressed += 1;
- await ctx.db.insert("notificationDeliveries", {
- workspaceId: args.workspaceId,
- eventId,
- eventKey,
- dedupeKey: entry.dedupeKey,
- channel: "push",
- recipientType: entry.recipientType,
- userId: entry.userId,
- visitorId: entry.visitorId,
- status: "suppressed",
- reason: entry.reason,
- error: entry.error,
- createdAt: Date.now(),
- });
- };
-
- const explicitUserIds = new Set(args.recipientUserIds ?? []);
- const explicitVisitorIds = new Set(args.recipientVisitorIds ?? []);
- const excludeUserIds = new Set(args.excludeUserIds ?? []);
- const excludeVisitorIds = new Set(args.excludeVisitorIds ?? []);
-
- if (args.actorUserId) {
- excludeUserIds.add(args.actorUserId);
- }
- if (args.actorVisitorId) {
- excludeVisitorIds.add(args.actorVisitorId);
- }
-
- if (args.audience === "agent" || args.audience === "both") {
- const agentUserIds =
- explicitUserIds.size > 0
- ? Array.from(explicitUserIds)
- : (
- await ctx.db
- .query("users")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect()
- ).map((user) => user._id);
-
- const workspaceDefaults = await ctx.db
- .query("workspaceNotificationDefaults")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .first();
- const defaultNewVisitorMessagePreferences =
- resolveWorkspaceNewVisitorMessageDefaults(workspaceDefaults);
-
- for (const userId of new Set(agentUserIds)) {
- const dedupeKey = `${eventKey}:agent:${userId}:push`;
- if (excludeUserIds.has(userId)) {
- await recordSuppressed({
- dedupeKey,
- recipientType: "agent",
- userId,
- reason: "sender_excluded",
- });
- continue;
- }
-
- const user = (await ctx.db.get(userId)) as Doc<"users"> | null;
- if (!user || user.workspaceId !== args.workspaceId) {
- await recordSuppressed({
- dedupeKey,
- recipientType: "agent",
- userId,
- reason: "recipient_out_of_workspace",
- });
- continue;
- }
-
- const existingDedupe = await ctx.db
- .query("notificationDedupeKeys")
- .withIndex("by_dedupe_key", (q) => q.eq("dedupeKey", dedupeKey))
- .first();
- if (existingDedupe) {
- await recordSuppressed({
- dedupeKey,
- recipientType: "agent",
- userId,
- reason: "duplicate_event_recipient_channel",
- });
- continue;
- }
-
- const prefs = await ctx.db
- .query("notificationPreferences")
- .withIndex("by_user_workspace", (q) =>
- q.eq("userId", userId).eq("workspaceId", args.workspaceId)
- )
- .first();
- const pushEnabled =
- args.eventType === "chat_message" && args.actorType === "visitor"
- ? resolveMemberNewVisitorMessagePreference(prefs, defaultNewVisitorMessagePreferences)
- .push
- : !prefs?.muted;
- if (!pushEnabled) {
- await recordSuppressed({
- dedupeKey,
- recipientType: "agent",
- userId,
- reason: "preference_muted",
- });
- continue;
- }
-
- const tokens = (
- await ctx.db
- .query("pushTokens")
- .withIndex("by_user", (q) => q.eq("userId", userId))
- .collect()
- )
- .filter((token) => token.notificationsEnabled !== false)
- .map((token) => token.token);
- if (tokens.length === 0) {
- await recordSuppressed({
- dedupeKey,
- recipientType: "agent",
- userId,
- reason: "missing_push_token",
- });
- continue;
- }
-
- await ctx.db.insert("notificationDedupeKeys", {
- dedupeKey,
- eventId,
- eventKey,
- workspaceId: args.workspaceId,
- channel: "push",
- recipientType: "agent",
- userId,
- createdAt: Date.now(),
- });
- attempts.push({
- dedupeKey,
- recipientType: "agent",
- userId,
- tokens,
- });
- }
- }
-
- if (args.audience === "visitor" || args.audience === "both") {
- const visitorIds =
- explicitVisitorIds.size > 0
- ? Array.from(explicitVisitorIds)
- : await resolveDefaultVisitorRecipients(ctx, {
- conversationId: args.conversationId,
- ticketId: args.ticketId,
- });
-
- for (const visitorId of new Set(visitorIds)) {
- const dedupeKey = `${eventKey}:visitor:${visitorId}:push`;
- if (excludeVisitorIds.has(visitorId)) {
- await recordSuppressed({
- dedupeKey,
- recipientType: "visitor",
- visitorId,
- reason: "sender_excluded",
- });
- continue;
- }
-
- const visitor = (await ctx.db.get(visitorId)) as Doc<"visitors"> | null;
- if (!visitor || visitor.workspaceId !== args.workspaceId) {
- await recordSuppressed({
- dedupeKey,
- recipientType: "visitor",
- visitorId,
- reason: "recipient_out_of_workspace",
- });
- continue;
- }
-
- const existingDedupe = await ctx.db
- .query("notificationDedupeKeys")
- .withIndex("by_dedupe_key", (q) => q.eq("dedupeKey", dedupeKey))
- .first();
- if (existingDedupe) {
- await recordSuppressed({
- dedupeKey,
- recipientType: "visitor",
- visitorId,
- reason: "duplicate_event_recipient_channel",
- });
- continue;
- }
-
- const tokens = (
- await ctx.db
- .query("visitorPushTokens")
- .withIndex("by_visitor", (q) => q.eq("visitorId", visitorId))
- .collect()
- )
- .filter((token) => token.notificationsEnabled !== false)
- .map((token) => token.token);
- if (tokens.length === 0) {
- await recordSuppressed({
- dedupeKey,
- recipientType: "visitor",
- visitorId,
- reason: "missing_push_token",
- });
- continue;
- }
-
- await ctx.db.insert("notificationDedupeKeys", {
- dedupeKey,
- eventId,
- eventKey,
- workspaceId: args.workspaceId,
- channel: "push",
- recipientType: "visitor",
- visitorId,
- createdAt: Date.now(),
- });
- attempts.push({
- dedupeKey,
- recipientType: "visitor",
- visitorId,
- tokens,
- });
- }
- }
-
- if (attempts.length > 0) {
- await ctx.scheduler.runAfter(0, internal.notifications.dispatchPushAttempts, {
- workspaceId: args.workspaceId,
- eventId,
- eventKey,
- title: args.title,
- body: args.body,
- data: args.data,
- attempts,
- });
- }
-
- return {
- eventId,
- eventKey,
- scheduled: attempts.length,
- suppressed,
- };
- },
-});
-
-const ADMIN_WEB_APP_BASE_URL =
- process.env.OPENCOM_WEB_APP_URL ?? process.env.NEXT_PUBLIC_OPENCOM_WEB_APP_URL ?? "";
-const EMAIL_DEBOUNCE_MS = 60_000;
-const MAX_BATCH_MESSAGES = 8;
-const MAX_THREAD_MESSAGES = 12;
-
-function escapeHtml(value: string): string {
- return value
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
-}
-
-function normalizeHttpUrl(value: string | null | undefined): string | null {
- const rawValue = value?.trim();
- if (!rawValue) {
- return null;
- }
-
- try {
- const url = new URL(rawValue);
- if (url.protocol !== "http:" && url.protocol !== "https:") {
- return null;
- }
- return url.toString();
- } catch {
- return null;
- }
-}
-
-function buildAdminConversationInboxUrl(conversationId: Id<"conversations">): string | null {
- const normalizedBaseUrl = normalizeHttpUrl(ADMIN_WEB_APP_BASE_URL);
- if (!normalizedBaseUrl) {
- return null;
- }
-
- try {
- const url = new URL(normalizedBaseUrl);
- url.pathname = "/inbox";
- url.search = "";
- url.searchParams.set("conversationId", conversationId);
- return url.toString();
- } catch {
- return null;
- }
-}
-
-function formatEmailTimestamp(timestamp: number): string {
- const date = new Date(timestamp);
- if (Number.isNaN(date.getTime())) {
- return "Unknown";
- }
-
- return date
- .toISOString()
- .replace("T", " ")
- .replace(/\.\d{3}Z$/, " UTC");
-}
-
-function renderMetadataList(
- metadata: Array<{ label: string; value: string | null | undefined }>
-): string {
- const items = metadata
- .filter((entry) => entry.value && entry.value.trim().length > 0)
- .map(
- (entry) => `- ${escapeHtml(entry.label)}: ${escapeHtml(entry.value!)}
`
- );
-
- if (items.length === 0) {
- return "";
- }
-
- return ``;
-}
-
-function truncateText(value: string, maxLength: number): string {
- return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
-}
-
-function formatMessageContentForEmail(content: string): string {
- return escapeHtml(truncateText(content, 600)).replace(/\n/g, "
");
-}
-
-function getSupportSenderLabel(
- message: Doc<"messages">,
- supportSenderLabels: Map
-): string {
- if (message.senderType === "bot") {
- return "Support bot";
- }
- if (message.senderType === "agent" || message.senderType === "user") {
- return supportSenderLabels.get(message.senderId) ?? "Support";
- }
- return "Support";
-}
-
-async function resolveSupportSenderLabels(
- ctx: any,
- messages: Doc<"messages">[]
-): Promise
+ {errorFeedback && }
{enabled && (
diff --git a/apps/web/src/app/settings/MessengerSettingsSection.test.tsx b/apps/web/src/app/settings/MessengerSettingsSection.test.tsx
new file mode 100644
index 0000000..52d12b0
--- /dev/null
+++ b/apps/web/src/app/settings/MessengerSettingsSection.test.tsx
@@ -0,0 +1,100 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { useMutation, useQuery } from "convex/react";
+import { MessengerSettingsSection } from "./MessengerSettingsSection";
+
+vi.mock("convex/react", () => ({
+ useQuery: vi.fn(),
+ useMutation: vi.fn(),
+}));
+
+vi.mock("@opencom/convex", () => ({
+ api: {
+ messengerSettings: {
+ getOrCreate: "messengerSettings.getOrCreate",
+ upsert: "messengerSettings.upsert",
+ generateLogoUploadUrl: "messengerSettings.generateLogoUploadUrl",
+ saveLogo: "messengerSettings.saveLogo",
+ deleteLogo: "messengerSettings.deleteLogo",
+ },
+ },
+}));
+
+describe("MessengerSettingsSection error feedback", () => {
+ const workspaceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as any;
+ const messengerSettingsFixture = {
+ primaryColor: "#792cd4",
+ backgroundColor: "#792cd4",
+ themeMode: "system",
+ launcherPosition: "right",
+ launcherSideSpacing: 20,
+ launcherBottomSpacing: 20,
+ showLauncher: true,
+ welcomeMessage: "Hi there! How can we help you today?",
+ teamIntroduction: "",
+ showTeammateAvatars: true,
+ supportedLanguages: ["en"],
+ defaultLanguage: "en",
+ privacyPolicyUrl: null,
+ mobileEnabled: true,
+ logo: null,
+ } as const;
+ let upsertMock: ReturnType
;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ upsertMock = vi.fn().mockResolvedValue(undefined);
+
+ const mockedUseMutation = useMutation as unknown as ReturnType;
+ mockedUseMutation.mockImplementation((mutationRef: unknown) => {
+ if (mutationRef === "messengerSettings.upsert") {
+ return upsertMock;
+ }
+ return vi.fn().mockResolvedValue(undefined);
+ });
+
+ const mockedUseQuery = useQuery as unknown as ReturnType;
+ mockedUseQuery.mockImplementation((queryRef: unknown, args: unknown) => {
+ if (args === "skip") {
+ return undefined;
+ }
+ if (queryRef === "messengerSettings.getOrCreate") {
+ return messengerSettingsFixture;
+ }
+ return undefined;
+ });
+ });
+
+ it("shows actionable feedback when logo validation fails", async () => {
+ render();
+
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement | null;
+ expect(fileInput).not.toBeNull();
+
+ const oversizedFile = new File([new Uint8Array(100 * 1024 + 1)], "logo.png", {
+ type: "image/png",
+ });
+ fireEvent.change(fileInput!, {
+ target: { files: [oversizedFile] },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent("Logo must be under 100KB.");
+ });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Choose a smaller PNG or JPG and upload again."
+ );
+ });
+
+ it("maps unknown save errors through shared normalization", async () => {
+ upsertMock.mockRejectedValue(new Error("Service unavailable"));
+
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /save messenger settings/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toHaveTextContent("Service unavailable");
+ });
+ expect(screen.getByRole("alert")).toHaveTextContent("Review your changes and try again.");
+ });
+});
diff --git a/apps/web/src/app/settings/MessengerSettingsSection.tsx b/apps/web/src/app/settings/MessengerSettingsSection.tsx
index 0c0ebc8..962ae01 100644
--- a/apps/web/src/app/settings/MessengerSettingsSection.tsx
+++ b/apps/web/src/app/settings/MessengerSettingsSection.tsx
@@ -3,9 +3,11 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "convex/react";
import { Button, Card, Input } from "@opencom/ui";
+import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared";
import { Palette, Upload, Eye, X } from "lucide-react";
import { api } from "@opencom/convex";
import type { Id } from "@opencom/convex/dataModel";
+import { ErrorFeedbackBanner } from "@/components/ErrorFeedbackBanner";
const SUPPORTED_LANGUAGES = [
{ code: "en", name: "English" },
@@ -67,6 +69,7 @@ export function MessengerSettingsSection({
const [isSaving, setIsSaving] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [showPreview, setShowPreview] = useState(false);
+ const [errorFeedback, setErrorFeedback] = useState(null);
useEffect(() => {
if (messengerSettings) {
@@ -90,6 +93,7 @@ export function MessengerSettingsSection({
const handleSave = async () => {
if (!workspaceId) return;
+ setErrorFeedback(null);
setIsSaving(true);
try {
await upsertSettings({
@@ -111,7 +115,12 @@ export function MessengerSettingsSection({
});
} catch (error) {
console.error("Failed to save messenger settings:", error);
- alert(error instanceof Error ? error.message : "Failed to save settings");
+ setErrorFeedback(
+ normalizeUnknownError(error, {
+ fallbackMessage: "Failed to save messenger settings",
+ nextAction: "Review your changes and try again.",
+ })
+ );
} finally {
setIsSaving(false);
}
@@ -120,16 +129,23 @@ export function MessengerSettingsSection({
const handleLogoUpload = async (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (!file || !workspaceId) return;
+ setErrorFeedback(null);
// Validate file size (max 100KB)
if (file.size > 100 * 1024) {
- alert("Logo must be under 100KB");
+ setErrorFeedback({
+ message: "Logo must be under 100KB.",
+ nextAction: "Choose a smaller PNG or JPG and upload again.",
+ });
return;
}
// Validate file type
if (!file.type.startsWith("image/")) {
- alert("Please upload an image file");
+ setErrorFeedback({
+ message: "Please upload an image file.",
+ nextAction: "Use a PNG or JPG logo.",
+ });
return;
}
@@ -150,7 +166,12 @@ export function MessengerSettingsSection({
reader.readAsDataURL(file);
} catch (error) {
console.error("Failed to upload logo:", error);
- alert("Failed to upload logo");
+ setErrorFeedback(
+ normalizeUnknownError(error, {
+ fallbackMessage: "Failed to upload logo",
+ nextAction: "Try again with a valid image file.",
+ })
+ );
} finally {
setIsUploadingLogo(false);
}
@@ -158,11 +179,18 @@ export function MessengerSettingsSection({
const handleDeleteLogo = async () => {
if (!workspaceId) return;
+ setErrorFeedback(null);
try {
await deleteLogo({ workspaceId });
setLogoPreview(null);
} catch (error) {
console.error("Failed to delete logo:", error);
+ setErrorFeedback(
+ normalizeUnknownError(error, {
+ fallbackMessage: "Failed to delete logo",
+ nextAction: "Try again in a moment.",
+ })
+ );
}
};
@@ -194,6 +222,7 @@ export function MessengerSettingsSection({
Customize the appearance of your messenger widget and mobile SDK to match your brand.
+ {errorFeedback && }
{/* Settings Column */}
diff --git a/apps/web/src/app/settings/NotificationSettingsSection.tsx b/apps/web/src/app/settings/NotificationSettingsSection.tsx
index 03cd884..0cc2392 100644
--- a/apps/web/src/app/settings/NotificationSettingsSection.tsx
+++ b/apps/web/src/app/settings/NotificationSettingsSection.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { Bell } from "lucide-react";
import { Button, Card } from "@opencom/ui";
+import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared";
import { api } from "@opencom/convex";
import type { Id } from "@opencom/convex/dataModel";
import {
@@ -11,6 +12,7 @@ import {
loadInboxCuePreferences,
saveInboxCuePreferences,
} from "@/lib/inboxNotificationCues";
+import { ErrorFeedbackBanner } from "@/components/ErrorFeedbackBanner";
interface NotificationSettingsSectionProps {
workspaceId?: Id<"workspaces">;
@@ -44,6 +46,7 @@ export function NotificationSettingsSection({
const [browserNotificationsEnabled, setBrowserNotificationsEnabled] = useState(false);
const [soundEnabled, setSoundEnabled] = useState(false);
const [savingCues, setSavingCues] = useState(false);
+ const [errorFeedback, setErrorFeedback] = useState
(null);
useEffect(() => {
if (myPreferences) {
@@ -76,6 +79,7 @@ export function NotificationSettingsSection({
if (!workspaceId) {
return;
}
+ setErrorFeedback(null);
setSavingMine(true);
try {
@@ -85,7 +89,12 @@ export function NotificationSettingsSection({
newVisitorMessagePush: myPushEnabled,
});
} catch (error) {
- alert(error instanceof Error ? error.message : "Failed to save notification preferences");
+ setErrorFeedback(
+ normalizeUnknownError(error, {
+ fallbackMessage: "Failed to save notification preferences",
+ nextAction: "Review preference toggles and try again.",
+ })
+ );
} finally {
setSavingMine(false);
}
@@ -95,6 +104,7 @@ export function NotificationSettingsSection({
if (!workspaceId) {
return;
}
+ setErrorFeedback(null);
setSavingDefaults(true);
try {
@@ -104,8 +114,11 @@ export function NotificationSettingsSection({
newVisitorMessagePush: defaultPushEnabled,
});
} catch (error) {
- alert(
- error instanceof Error ? error.message : "Failed to save workspace notification defaults"
+ setErrorFeedback(
+ normalizeUnknownError(error, {
+ fallbackMessage: "Failed to save workspace notification defaults",
+ nextAction: "Review workspace defaults and try again.",
+ })
);
} finally {
setSavingDefaults(false);
@@ -149,6 +162,7 @@ export function NotificationSettingsSection({
Configure which message events notify you and which channels are used.
+ {errorFeedback && }
My Message Notifications
diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx
index b3637d8..e23c8c2 100644
--- a/apps/web/src/app/settings/page.tsx
+++ b/apps/web/src/app/settings/page.tsx
@@ -5,6 +5,7 @@ import { useState, useEffect, useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useMutation, useAction } from "convex/react";
import { Button, Card, Input } from "@opencom/ui";
+import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared";
import {
Copy,
Check,
@@ -23,6 +24,7 @@ import {
import { useAuth } from "@/contexts/AuthContext";
import { useBackend } from "@/contexts/BackendContext";
import { AppLayout } from "@/components/AppLayout";
+import { ErrorFeedbackBanner } from "@/components/ErrorFeedbackBanner";
import { api } from "@opencom/convex";
import { appConfirm } from "@/lib/appConfirm";
import type { Id } from "@opencom/convex/dataModel";
@@ -123,6 +125,20 @@ function SettingsContent(): React.JSX.Element | null {
currentRole: string;
newRole: string;
} | null>(null);
+ const [pageErrorFeedback, setPageErrorFeedback] = useState
(null);
+
+ const setSettingsErrorFeedback = (
+ error: unknown,
+ fallbackMessage: string,
+ nextAction: string
+ ): void => {
+ setPageErrorFeedback(
+ normalizeUnknownError(error, {
+ fallbackMessage,
+ nextAction,
+ })
+ );
+ };
useEffect(() => {
if (workspace) {
@@ -146,10 +162,14 @@ function SettingsContent(): React.JSX.Element | null {
const handleSaveSignupSettings = async () => {
if (!activeWorkspace?._id) return;
+ setPageErrorFeedback(null);
// Ensure at least one auth method is enabled
if (!authMethodPassword && !authMethodOtp) {
- alert("At least one authentication method must be enabled.");
+ setPageErrorFeedback({
+ message: "At least one authentication method must be enabled.",
+ nextAction: "Enable Password or OTP, then save again.",
+ });
return;
}
@@ -171,7 +191,11 @@ function SettingsContent(): React.JSX.Element | null {
authMethods,
});
} catch (err) {
- alert(err instanceof Error ? err.message : "Failed to save signup settings");
+ setSettingsErrorFeedback(
+ err,
+ "Failed to save signup settings",
+ "Review signup settings and try again."
+ );
} finally {
setIsSavingSignup(false);
}
@@ -179,6 +203,7 @@ function SettingsContent(): React.JSX.Element | null {
const handleSaveHelpCenterAccessPolicy = async () => {
if (!activeWorkspace?._id) return;
+ setPageErrorFeedback(null);
setIsSavingHelpCenterPolicy(true);
try {
@@ -187,7 +212,11 @@ function SettingsContent(): React.JSX.Element | null {
policy: helpCenterAccessPolicy,
});
} catch (err) {
- alert(err instanceof Error ? err.message : "Failed to save help center access policy");
+ setSettingsErrorFeedback(
+ err,
+ "Failed to save help center access policy",
+ "Confirm access policy values and try again."
+ );
} finally {
setIsSavingHelpCenterPolicy(false);
}
@@ -279,27 +308,34 @@ function SettingsContent(): React.JSX.Element | null {
membershipId: Id<"workspaceMembers">,
newRole: "admin" | "agent" | "viewer"
) => {
+ setPageErrorFeedback(null);
try {
await updateRole({ membershipId, role: newRole });
setShowRoleConfirm(null);
} catch (err) {
- alert(err instanceof Error ? err.message : "Failed to update role");
+ setSettingsErrorFeedback(err, "Failed to update role", "Refresh the member list and try again.");
}
};
const handleTransferOwnership = async () => {
if (!activeWorkspace?._id || !transferTargetId) return;
+ setPageErrorFeedback(null);
try {
await transferOwnership({ workspaceId: activeWorkspace._id, newOwnerId: transferTargetId });
setShowTransferOwnership(false);
setTransferTargetId(null);
} catch (err) {
- alert(err instanceof Error ? err.message : "Failed to transfer ownership");
+ setSettingsErrorFeedback(
+ err,
+ "Failed to transfer ownership",
+ "Verify target admin permissions and try again."
+ );
}
};
const handleRemoveMember = async (membershipId: Id<"workspaceMembers">, memberName: string) => {
+ setPageErrorFeedback(null);
if (!(await appConfirm(`Are you sure you want to remove ${memberName} from this workspace?`))) {
return;
}
@@ -307,15 +343,16 @@ function SettingsContent(): React.JSX.Element | null {
try {
await removeMember({ membershipId });
} catch (err) {
- alert(err instanceof Error ? err.message : "Failed to remove member");
+ setSettingsErrorFeedback(err, "Failed to remove member", "Try again in a moment.");
}
};
const handleCancelInvitation = async (invitationId: Id<"workspaceInvitations">) => {
+ setPageErrorFeedback(null);
try {
await cancelInvitation({ invitationId });
} catch (err) {
- alert(err instanceof Error ? err.message : "Failed to cancel invitation");
+ setSettingsErrorFeedback(err, "Failed to cancel invitation", "Refresh invitations and retry.");
}
};
@@ -333,6 +370,7 @@ function SettingsContent(): React.JSX.Element | null {
const handleSaveEmailSettings = async () => {
if (!activeWorkspace?._id) return;
+ setPageErrorFeedback(null);
setIsSavingEmail(true);
try {
@@ -344,7 +382,7 @@ function SettingsContent(): React.JSX.Element | null {
enabled: emailEnabled,
});
} catch (err) {
- alert(err instanceof Error ? err.message : "Failed to save email settings");
+ setSettingsErrorFeedback(err, "Failed to save email settings", "Review email fields and try again.");
} finally {
setIsSavingEmail(false);
}
@@ -510,6 +548,7 @@ function SettingsContent(): React.JSX.Element | null {
Manage workspace configuration, security, and channels.
+ {pageErrorFeedback && }
+ {feedback.message}
+ {feedback.nextAction && {feedback.nextAction}
}
+
+ );
+}
diff --git a/apps/widget/src/AuthoringOverlay.tsx b/apps/widget/src/AuthoringOverlay.tsx
index 2c080bc..e481f91 100644
--- a/apps/widget/src/AuthoringOverlay.tsx
+++ b/apps/widget/src/AuthoringOverlay.tsx
@@ -2,7 +2,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "@opencom/convex";
import type { Id } from "@opencom/convex/dataModel";
+import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared";
import { scoreSelectorQuality } from "@opencom/sdk-core";
+import { ErrorFeedbackBanner } from "./components/ErrorFeedbackBanner";
interface TourStep {
_id: Id<"tourSteps">;
@@ -131,6 +133,7 @@ export function AuthoringOverlay({ token, onExit }: AuthoringOverlayProps) {
const [selectorWarnings, setSelectorWarnings] = useState([]);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isSelecting, setIsSelecting] = useState(true);
+ const [errorFeedback, setErrorFeedback] = useState(null);
const [previewPosition, setPreviewPosition] = useState<{ top: number; left: number } | null>(
null
);
@@ -277,6 +280,7 @@ export function AuthoringOverlay({ token, onExit }: AuthoringOverlayProps) {
const handleConfirmSelector = useCallback(async () => {
if (!currentStep || !selectedSelector) return;
+ setErrorFeedback(null);
try {
await updateStepMutation({
@@ -297,7 +301,12 @@ export function AuthoringOverlay({ token, onExit }: AuthoringOverlayProps) {
handleCancelSelection();
} catch (error) {
console.error("Failed to save selector:", error);
- alert("Failed to save selector");
+ setErrorFeedback(
+ normalizeUnknownError(error, {
+ fallbackMessage: "Failed to save selector.",
+ nextAction: "Try selecting the target element again.",
+ })
+ );
}
}, [
currentStep,
@@ -308,6 +317,7 @@ export function AuthoringOverlay({ token, onExit }: AuthoringOverlayProps) {
steps,
setCurrentStepMutation,
handleCancelSelection,
+ setErrorFeedback,
]);
const handlePreviousStep = useCallback(async () => {
@@ -391,6 +401,11 @@ export function AuthoringOverlay({ token, onExit }: AuthoringOverlayProps) {
+ {errorFeedback && (
+
+
+
+ )}
{/* Step info panel */}
diff --git a/apps/widget/src/TooltipAuthoringOverlay.tsx b/apps/widget/src/TooltipAuthoringOverlay.tsx
index 9e429ba..f107962 100644
--- a/apps/widget/src/TooltipAuthoringOverlay.tsx
+++ b/apps/widget/src/TooltipAuthoringOverlay.tsx
@@ -3,6 +3,8 @@ import { useQuery, useMutation } from "convex/react";
import { api } from "@opencom/convex";
import { scoreSelectorQuality, type SelectorQualityMetadata } from "@opencom/sdk-core";
import type { Id } from "@opencom/convex/dataModel";
+import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared";
+import { ErrorFeedbackBanner } from "./components/ErrorFeedbackBanner";
interface TooltipAuthoringOverlayProps {
token: string;
@@ -96,6 +98,7 @@ export function TooltipAuthoringOverlay({
const [selectedSelector, setSelectedSelector] = useState("");
const [selectorQuality, setSelectorQuality] = useState(null);
const [isSelecting, setIsSelecting] = useState(true);
+ const [errorFeedback, setErrorFeedback] = useState(null);
const [previewPosition, setPreviewPosition] = useState<{ top: number; left: number } | null>(
null
);
@@ -218,6 +221,7 @@ export function TooltipAuthoringOverlay({
const handleConfirmSelector = async () => {
if (!selectedSelector) return;
+ setErrorFeedback(null);
try {
await updateSelectorMutation({
@@ -231,7 +235,12 @@ export function TooltipAuthoringOverlay({
onExit();
} catch (error) {
console.error("Failed to save selector:", error);
- alert("Failed to save selector");
+ setErrorFeedback(
+ normalizeUnknownError(error, {
+ fallbackMessage: "Failed to save selector.",
+ nextAction: "Try selecting the target element again.",
+ })
+ );
}
};
@@ -290,6 +299,11 @@ export function TooltipAuthoringOverlay({
+ {errorFeedback && (
+
+
+
+ )}
{/* Instructions panel */}
diff --git a/apps/widget/src/Widget.tsx b/apps/widget/src/Widget.tsx
index e37260f..4ac9f95 100644
--- a/apps/widget/src/Widget.tsx
+++ b/apps/widget/src/Widget.tsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "@opencom/convex";
+import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared";
import { MessageCircle, X, Plus, Search, Map, CheckSquare, Ticket, Home } from "./icons";
import { Home as HomeComponent, useHomeConfig } from "./components/Home";
import { ConversationList } from "./components/ConversationList";
@@ -107,6 +108,7 @@ export function Widget({
const [forcedTourId, setForcedTourId] = useState
| null>(null);
const [selectedTicketId, setSelectedTicketId] = useState | null>(null);
const [isSubmittingTicket, setIsSubmittingTicket] = useState(false);
+ const [ticketErrorFeedback, setTicketErrorFeedback] = useState(null);
const [sessionShownSurveyIds, setSessionShownSurveyIds] = useState>(new Set());
const [completedSurveyIds, setCompletedSurveyIds] = useState>(new Set());
const [surveyEligibilityUnavailable, setSurveyEligibilityUnavailable] = useState(false);
@@ -936,6 +938,7 @@ export function Widget({
};
const handleBackFromTickets = () => {
+ setTicketErrorFeedback(null);
if (view === "ticket-detail") {
setSelectedTicketId(null);
}
@@ -945,6 +948,7 @@ export function Widget({
const handleSubmitTicket = async (formData: Record) => {
if (!visitorId || !activeWorkspaceId || isSubmittingTicket) return;
+ setTicketErrorFeedback(null);
const ticketFields = (ticketForm?.fields ?? []) as Array<{ id: string; label?: string }>;
const getFieldValueByHint = (hints: string[]): string | undefined => {
@@ -969,7 +973,10 @@ export function Widget({
getFieldValueByHint(["description", "details", "message"]);
if (!subject.trim()) {
- alert("Please provide a subject for your ticket");
+ setTicketErrorFeedback({
+ message: "Please provide a subject for your ticket.",
+ nextAction: "Add a short subject, then submit again.",
+ });
return;
}
@@ -988,7 +995,12 @@ export function Widget({
setView("ticket-detail");
} catch (error) {
console.error("Failed to create ticket:", error);
- alert("Failed to submit ticket. Please try again.");
+ setTicketErrorFeedback(
+ normalizeUnknownError(error, {
+ fallbackMessage: "Failed to submit ticket.",
+ nextAction: "Please try again.",
+ })
+ );
} finally {
setIsSubmittingTicket(false);
}
@@ -1139,6 +1151,7 @@ export function Widget({
+ {errorFeedback &&
}
{ticketForm?.description && (
{ticketForm.description}
)}
diff --git a/apps/widget/src/styles.css b/apps/widget/src/styles.css
index 6e042bd..7e23961 100644
--- a/apps/widget/src/styles.css
+++ b/apps/widget/src/styles.css
@@ -2336,6 +2336,15 @@
z-index: 10000000;
}
+.opencom-authoring-feedback {
+ position: fixed;
+ top: 64px;
+ left: 16px;
+ right: 16px;
+ max-width: 560px;
+ z-index: 10000001;
+}
+
.opencom-authoring-toolbar-left,
.opencom-authoring-toolbar-center,
.opencom-authoring-toolbar-right {
@@ -3392,6 +3401,27 @@
gap: 16px;
}
+.opencom-error-feedback {
+ border-radius: 8px;
+ border: 1px solid #fecaca;
+ background: #fef2f2;
+ padding: 10px 12px;
+}
+
+.opencom-error-feedback-message {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.4;
+ color: #b91c1c;
+}
+
+.opencom-error-feedback-next-action {
+ margin: 6px 0 0;
+ font-size: 12px;
+ line-height: 1.4;
+ color: #dc2626;
+}
+
.opencom-ticket-form-description {
font-size: 14px;
color: var(--opencom-text-muted);
diff --git a/apps/widget/src/test/widgetTicketErrorFeedback.test.tsx b/apps/widget/src/test/widgetTicketErrorFeedback.test.tsx
new file mode 100644
index 0000000..02819f8
--- /dev/null
+++ b/apps/widget/src/test/widgetTicketErrorFeedback.test.tsx
@@ -0,0 +1,331 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useMutation, useQuery } from "convex/react";
+import { Widget } from "../Widget";
+
+vi.mock("convex/react", () => ({
+ useQuery: vi.fn(),
+ useMutation: vi.fn(),
+}));
+
+vi.mock("@opencom/convex", () => ({
+ api: {
+ workspaces: {
+ get: "workspaces.get",
+ validateOrigin: "workspaces.validateOrigin",
+ },
+ tickets: {
+ listByVisitor: "tickets.listByVisitor",
+ get: "tickets.get",
+ addComment: "tickets.addComment",
+ create: "tickets.create",
+ },
+ ticketForms: {
+ getDefaultForVisitor: "ticketForms.getDefaultForVisitor",
+ },
+ conversations: {
+ listByVisitor: "conversations.listByVisitor",
+ getTotalUnreadForVisitor: "conversations.getTotalUnreadForVisitor",
+ createForVisitor: "conversations.createForVisitor",
+ markAsRead: "conversations.markAsRead",
+ },
+ articles: {
+ searchForVisitor: "articles.searchForVisitor",
+ listForVisitor: "articles.listForVisitor",
+ },
+ collections: {
+ listHierarchy: "collections.listHierarchy",
+ },
+ automationSettings: {
+ getOrCreate: "automationSettings.getOrCreate",
+ },
+ commonIssueButtons: {
+ list: "commonIssueButtons.list",
+ },
+ officeHours: {
+ isCurrentlyOpen: "officeHours.isCurrentlyOpen",
+ getExpectedReplyTime: "officeHours.getExpectedReplyTime",
+ },
+ tourProgress: {
+ getAvailableTours: "tourProgress.getAvailableTours",
+ },
+ tours: {
+ listAll: "tours.listAll",
+ },
+ checklists: {
+ getEligible: "checklists.getEligible",
+ },
+ surveys: {
+ getActiveSurveys: "surveys.getActiveSurveys",
+ },
+ tooltips: {
+ getAvailableTooltips: "tooltips.getAvailableTooltips",
+ },
+ },
+}));
+
+vi.mock("../main", () => ({
+ setStartTourCallback: vi.fn(),
+ setGetAvailableToursCallback: vi.fn(),
+}));
+
+vi.mock("../components/Home", () => ({
+ Home: () =>
,
+ useHomeConfig: vi.fn(() => ({
+ enabled: true,
+ tabs: [
+ { id: "home", enabled: true, visibleTo: "all" },
+ { id: "messages", enabled: true, visibleTo: "all" },
+ { id: "help", enabled: true, visibleTo: "all" },
+ { id: "tickets", enabled: true, visibleTo: "all" },
+ ],
+ })),
+}));
+
+vi.mock("../components/ConversationList", () => ({
+ ConversationList: () =>
,
+}));
+
+vi.mock("../components/ConversationView", () => ({
+ ConversationView: () =>
,
+}));
+
+vi.mock("../components/HelpCenter", () => ({
+ HelpCenter: () =>
,
+}));
+
+vi.mock("../components/ArticleDetail", () => ({
+ ArticleDetail: () =>
,
+}));
+
+vi.mock("../components/TourPicker", () => ({
+ TourPicker: () =>
,
+}));
+
+vi.mock("../components/TasksList", () => ({
+ TasksList: () =>
,
+}));
+
+vi.mock("../components/TicketsList", () => ({
+ TicketsList: () =>
,
+}));
+
+vi.mock("../components/TicketDetail", () => ({
+ TicketDetail: () =>
,
+}));
+
+vi.mock("../components/TicketCreate", () => ({
+ TicketCreate: ({
+ onSubmit,
+ errorFeedback,
+ }: {
+ onSubmit: (data: Record
) => Promise;
+ errorFeedback: { message: string; nextAction?: string } | null;
+ }) => (
+
+ {errorFeedback && (
+
+ {errorFeedback.message}
+ {errorFeedback.nextAction}
+
+ )}
+
+
+
+ ),
+}));
+
+vi.mock("../TourOverlay", () => ({
+ TourOverlay: () => null,
+}));
+
+vi.mock("../OutboundOverlay", () => ({
+ OutboundOverlay: () => null,
+}));
+
+vi.mock("../TooltipOverlay", () => ({
+ TooltipOverlay: () => null,
+}));
+
+vi.mock("../SurveyOverlay", () => ({
+ SurveyOverlay: () => null,
+}));
+
+vi.mock("../hooks/useWidgetSession", () => ({
+ useWidgetSession: vi.fn(() => ({
+ sessionId: "session_1",
+ visitorId: "visitor_1",
+ setVisitorId: vi.fn(),
+ visitorIdRef: { current: "visitor_1" },
+ sessionToken: "wst_test",
+ sessionTokenRef: { current: "wst_test" },
+ })),
+}));
+
+vi.mock("../hooks/useWidgetSettings", () => ({
+ useWidgetSettings: vi.fn(() => ({
+ messengerSettings: {
+ showLauncher: true,
+ primaryColor: "#792cd4",
+ backgroundColor: "#ffffff",
+ launcherIconUrl: "",
+ logo: "",
+ welcomeMessage: "Welcome",
+ teamIntroduction: "Team intro",
+ },
+ effectiveTheme: "light",
+ })),
+}));
+
+vi.mock("../hooks/useEventTracking", () => ({
+ useEventTracking: vi.fn(() => ({
+ handleTrackEvent: vi.fn(),
+ })),
+}));
+
+vi.mock("../hooks/useNavigationTracking", () => ({
+ useNavigationTracking: vi.fn(),
+}));
+
+vi.mock("../utils/dom", () => ({
+ checkElementsAvailable: vi.fn(() => true),
+}));
+
+describe("Widget ticket error feedback", () => {
+ let createTicketMock: ReturnType;
+
+ const openTicketCreateView = async () => {
+ render();
+
+ fireEvent.click(screen.getByTestId("widget-launcher"));
+ await waitFor(() => {
+ expect(screen.queryByTestId("widget-launcher")).not.toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTitle("My Tickets"));
+ fireEvent.click(screen.getByRole("button", { name: /new ticket/i }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("ticket-create")).toBeInTheDocument();
+ });
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ createTicketMock = vi.fn().mockResolvedValue("ticket_1");
+
+ const mockedUseMutation = useMutation as unknown as ReturnType;
+ mockedUseMutation.mockImplementation((mutationRef: unknown) => {
+ if (mutationRef === "tickets.create") {
+ return createTicketMock;
+ }
+ return vi.fn().mockResolvedValue(undefined);
+ });
+
+ const mockedUseQuery = useQuery as unknown as ReturnType;
+ mockedUseQuery.mockImplementation((queryRef: unknown, args: unknown) => {
+ if (args === "skip") {
+ return undefined;
+ }
+ if (queryRef === "workspaces.get") {
+ return { _id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" };
+ }
+ if (queryRef === "workspaces.validateOrigin") {
+ return { valid: true };
+ }
+ if (queryRef === "tickets.listByVisitor") {
+ return [];
+ }
+ if (queryRef === "tickets.get") {
+ return null;
+ }
+ if (queryRef === "ticketForms.getDefaultForVisitor") {
+ return { _id: "ticket_form_1", fields: [] };
+ }
+ if (queryRef === "conversations.listByVisitor") {
+ return [];
+ }
+ if (queryRef === "conversations.getTotalUnreadForVisitor") {
+ return 0;
+ }
+ if (queryRef === "articles.listForVisitor") {
+ return [];
+ }
+ if (queryRef === "articles.searchForVisitor") {
+ return [];
+ }
+ if (queryRef === "collections.listHierarchy") {
+ return [];
+ }
+ if (queryRef === "automationSettings.getOrCreate") {
+ return {
+ suggestArticlesEnabled: false,
+ collectEmailEnabled: false,
+ showReplyTimeEnabled: false,
+ askForRatingEnabled: false,
+ };
+ }
+ if (queryRef === "commonIssueButtons.list") {
+ return [];
+ }
+ if (queryRef === "officeHours.isCurrentlyOpen") {
+ return { isOpen: true };
+ }
+ if (queryRef === "officeHours.getExpectedReplyTime") {
+ return null;
+ }
+ if (queryRef === "tourProgress.getAvailableTours") {
+ return [];
+ }
+ if (queryRef === "tours.listAll") {
+ return [];
+ }
+ if (queryRef === "checklists.getEligible") {
+ return [];
+ }
+ if (queryRef === "surveys.getActiveSurveys") {
+ return [];
+ }
+ if (queryRef === "tooltips.getAvailableTooltips") {
+ return [];
+ }
+ return undefined;
+ });
+ });
+
+ it("shows actionable validation guidance instead of browser alert for empty subject", async () => {
+ await openTicketCreateView();
+
+ fireEvent.click(screen.getByTestId("ticket-submit-empty"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("ticket-error")).toHaveTextContent(
+ "Please provide a subject for your ticket."
+ );
+ });
+ expect(screen.getByTestId("ticket-error")).toHaveTextContent(
+ "Add a short subject, then submit again."
+ );
+ expect(createTicketMock).not.toHaveBeenCalled();
+ });
+
+ it("surfaces normalized mutation errors with retry guidance", async () => {
+ createTicketMock.mockRejectedValue(new Error("Ticket service unavailable"));
+ await openTicketCreateView();
+
+ fireEvent.click(screen.getByTestId("ticket-submit-valid"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("ticket-error")).toHaveTextContent("Ticket service unavailable");
+ });
+ expect(screen.getByTestId("ticket-error")).toHaveTextContent("Please try again.");
+ });
+});
diff --git a/packages/convex/convex/_generated/api.d.ts b/packages/convex/convex/_generated/api.d.ts
index 523332e..eb1d9bf 100644
--- a/packages/convex/convex/_generated/api.d.ts
+++ b/packages/convex/convex/_generated/api.d.ts
@@ -80,7 +80,20 @@ import type * as surveys from "../surveys.js";
import type * as tags from "../tags.js";
import type * as testAdmin from "../testAdmin.js";
import type * as testData from "../testData.js";
+import type * as testData_cleanup from "../testData/cleanup.js";
+import type * as testData_demoWorkspace from "../testData/demoWorkspace.js";
+import type * as testData_landing from "../testData/landing.js";
+import type * as testData_seeds from "../testData/seeds.js";
import type * as testing_helpers from "../testing/helpers.js";
+import type * as testing_helpers_ai from "../testing/helpers/ai.js";
+import type * as testing_helpers_cleanup from "../testing/helpers/cleanup.js";
+import type * as testing_helpers_content from "../testing/helpers/content.js";
+import type * as testing_helpers_conversations from "../testing/helpers/conversations.js";
+import type * as testing_helpers_email from "../testing/helpers/email.js";
+import type * as testing_helpers_notifications from "../testing/helpers/notifications.js";
+import type * as testing_helpers_series from "../testing/helpers/series.js";
+import type * as testing_helpers_tickets from "../testing/helpers/tickets.js";
+import type * as testing_helpers_workspace from "../testing/helpers/workspace.js";
import type * as ticketForms from "../ticketForms.js";
import type * as tickets from "../tickets.js";
import type * as tooltipAuthoringSessions from "../tooltipAuthoringSessions.js";
@@ -180,7 +193,20 @@ declare const fullApi: ApiFromModules<{
tags: typeof tags;
testAdmin: typeof testAdmin;
testData: typeof testData;
+ "testData/cleanup": typeof testData_cleanup;
+ "testData/demoWorkspace": typeof testData_demoWorkspace;
+ "testData/landing": typeof testData_landing;
+ "testData/seeds": typeof testData_seeds;
"testing/helpers": typeof testing_helpers;
+ "testing/helpers/ai": typeof testing_helpers_ai;
+ "testing/helpers/cleanup": typeof testing_helpers_cleanup;
+ "testing/helpers/content": typeof testing_helpers_content;
+ "testing/helpers/conversations": typeof testing_helpers_conversations;
+ "testing/helpers/email": typeof testing_helpers_email;
+ "testing/helpers/notifications": typeof testing_helpers_notifications;
+ "testing/helpers/series": typeof testing_helpers_series;
+ "testing/helpers/tickets": typeof testing_helpers_tickets;
+ "testing/helpers/workspace": typeof testing_helpers_workspace;
ticketForms: typeof ticketForms;
tickets: typeof tickets;
tooltipAuthoringSessions: typeof tooltipAuthoringSessions;
diff --git a/packages/convex/convex/testData.ts b/packages/convex/convex/testData.ts
index 0910fa7..7bfdbab 100644
--- a/packages/convex/convex/testData.ts
+++ b/packages/convex/convex/testData.ts
@@ -1,3355 +1,23 @@
import { internalMutation } from "./_generated/server";
-import { v } from "convex/values";
-import { Id } from "./_generated/dataModel";
-import { formatReadableVisitorId } from "./visitorReadableId";
-
-const E2E_TEST_PREFIX = "e2e_test_";
-
-function requireTestDataEnabled() {
- if (process.env.ALLOW_TEST_DATA !== "true") {
- throw new Error("Test data mutations are disabled");
- }
-}
-
-/**
- * Seeds a test tour with steps for E2E testing.
- */
-export const seedTour = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.optional(v.string()),
- status: v.optional(v.union(v.literal("draft"), v.literal("active"), v.literal("archived"))),
- targetPageUrl: v.optional(v.string()),
- steps: v.optional(
- v.array(
- v.object({
- type: v.union(v.literal("pointer"), v.literal("post"), v.literal("video")),
- title: v.optional(v.string()),
- content: v.string(),
- elementSelector: v.optional(v.string()),
- routePath: v.optional(v.string()),
- advanceOn: v.optional(
- v.union(v.literal("click"), v.literal("elementClick"), v.literal("fieldFill"))
- ),
- position: v.optional(
- v.union(
- v.literal("auto"),
- v.literal("left"),
- v.literal("right"),
- v.literal("above"),
- v.literal("below")
- )
- ),
- size: v.optional(v.union(v.literal("small"), v.literal("large"))),
- })
- )
- ),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const name = args.name || `${E2E_TEST_PREFIX}tour_${randomSuffix}`;
-
- const tourId = await ctx.db.insert("tours", {
- workspaceId: args.workspaceId,
- name,
- description: "E2E test tour",
- status: args.status || "active",
- targetingRules: args.targetPageUrl
- ? {
- pageUrl: args.targetPageUrl,
- }
- : undefined,
- displayMode: "first_time_only",
- priority: 100,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- const steps = args.steps || [
- {
- type: "post" as const,
- title: "Welcome",
- content: "Welcome to the E2E test tour!",
- },
- {
- type: "pointer" as const,
- title: "Step 1",
- content: "This is the first step",
- elementSelector: "[data-testid='tour-target-1']",
- },
- {
- type: "pointer" as const,
- title: "Step 2",
- content: "This is the second step",
- elementSelector: "[data-testid='tour-target-2']",
- },
- ];
-
- const stepIds: Id<"tourSteps">[] = [];
- for (let i = 0; i < steps.length; i++) {
- const step = steps[i];
- const stepId = await ctx.db.insert("tourSteps", {
- workspaceId: args.workspaceId,
- tourId,
- type: step.type,
- order: i,
- title: step.title,
- content: step.content,
- elementSelector: step.elementSelector,
- routePath: step.routePath,
- position: step.position ?? "auto",
- size: step.size ?? "small",
- advanceOn: step.advanceOn ?? "click",
- createdAt: timestamp,
- updatedAt: timestamp,
- });
- stepIds.push(stepId);
- }
-
- return { tourId, stepIds, name };
- },
-});
-
-/**
- * Seeds a test survey with questions for E2E testing.
- */
-export const seedSurvey = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.optional(v.string()),
- format: v.optional(v.union(v.literal("small"), v.literal("large"))),
- status: v.optional(
- v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))
- ),
- questionType: v.optional(
- v.union(
- v.literal("nps"),
- v.literal("numeric_scale"),
- v.literal("star_rating"),
- v.literal("emoji_rating"),
- v.literal("short_text"),
- v.literal("multiple_choice")
- )
- ),
- triggerType: v.optional(
- v.union(
- v.literal("immediate"),
- v.literal("page_visit"),
- v.literal("time_on_page"),
- v.literal("event")
- )
- ),
- triggerPageUrl: v.optional(v.string()),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const name = args.name || `${E2E_TEST_PREFIX}survey_${randomSuffix}`;
- const format = args.format || "small";
- const questionType = args.questionType || "nps";
-
- const questionId = `q_${randomSuffix}`;
- const questions = [
- {
- id: questionId,
- type: questionType,
- title:
- questionType === "nps"
- ? "How likely are you to recommend us?"
- : "What do you think of our product?",
- required: true,
- ...(questionType === "multiple_choice"
- ? {
- options: {
- choices: ["Excellent", "Good", "Average", "Poor"],
- },
- }
- : {}),
- ...(questionType === "numeric_scale"
- ? {
- options: {
- scaleStart: 1,
- scaleEnd: 5,
- startLabel: "Poor",
- endLabel: "Excellent",
- },
- }
- : {}),
- },
- ];
-
- const surveyId = await ctx.db.insert("surveys", {
- workspaceId: args.workspaceId,
- name,
- description: "E2E test survey",
- format,
- status: args.status || "active",
- questions,
- introStep:
- format === "large"
- ? {
- title: "Quick Survey",
- description: "Help us improve by answering a quick question",
- buttonText: "Start",
- }
- : undefined,
- thankYouStep: {
- title: "Thank you!",
- description: "Your feedback has been recorded",
- buttonText: "Done",
- },
- showProgressBar: true,
- showDismissButton: true,
- triggers: args.triggerType
- ? {
- type: args.triggerType,
- pageUrl: args.triggerPageUrl,
- pageUrlMatch: args.triggerPageUrl ? "contains" : undefined,
- delaySeconds: args.triggerType === "time_on_page" ? 5 : undefined,
- }
- : {
- type: "immediate",
- },
- frequency: "once",
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { surveyId, name, questionId };
- },
-});
-
-/**
- * Seeds a test carousel with slides for E2E testing.
- */
-export const seedCarousel = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.optional(v.string()),
- status: v.optional(
- v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))
- ),
- screens: v.optional(
- v.array(
- v.object({
- title: v.optional(v.string()),
- body: v.optional(v.string()),
- imageUrl: v.optional(v.string()),
- })
- )
- ),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const name = args.name || `${E2E_TEST_PREFIX}carousel_${randomSuffix}`;
-
- type CarouselScreen = {
- id: string;
- title?: string;
- body?: string;
- imageUrl?: string;
- buttons?: Array<{
- text: string;
- action: "url" | "dismiss" | "next" | "deeplink";
- url?: string;
- deepLink?: string;
- }>;
- };
-
- let screens: CarouselScreen[];
-
- if (args.screens) {
- screens = args.screens.map((s, i) => ({
- id: `screen_${i}_${randomSuffix}`,
- title: s.title,
- body: s.body,
- imageUrl: s.imageUrl,
- }));
- } else {
- screens = [
- {
- id: `screen_1_${randomSuffix}`,
- title: "Welcome!",
- body: "This is the first slide of the E2E test carousel",
- buttons: [
- { text: "Next", action: "next" },
- { text: "Dismiss", action: "dismiss" },
- ],
- },
- {
- id: `screen_2_${randomSuffix}`,
- title: "Feature Highlight",
- body: "Check out our amazing features",
- buttons: [{ text: "Next", action: "next" }],
- },
- {
- id: `screen_3_${randomSuffix}`,
- title: "Get Started",
- body: "Ready to begin?",
- buttons: [
- { text: "Done", action: "dismiss" },
- { text: "Learn More", action: "url", url: "https://example.com" },
- ],
- },
- ];
- }
-
- const carouselId = await ctx.db.insert("carousels", {
- workspaceId: args.workspaceId,
- name,
- screens,
- status: args.status || "active",
- priority: 100,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { carouselId, name };
- },
-});
-
-/**
- * Seeds a test outbound message for E2E testing.
- */
-export const seedOutboundMessage = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.optional(v.string()),
- type: v.optional(v.union(v.literal("chat"), v.literal("post"), v.literal("banner"))),
- status: v.optional(
- v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))
- ),
- triggerType: v.optional(
- v.union(
- v.literal("immediate"),
- v.literal("page_visit"),
- v.literal("time_on_page"),
- v.literal("scroll_depth"),
- v.literal("event")
- )
- ),
- triggerPageUrl: v.optional(v.string()),
- senderId: v.optional(v.id("users")),
- clickAction: v.optional(
- v.object({
- type: v.union(
- v.literal("open_messenger"),
- v.literal("open_new_conversation"),
- v.literal("open_widget_tab"),
- v.literal("open_help_article"),
- v.literal("open_url"),
- v.literal("dismiss")
- ),
- tabId: v.optional(v.string()),
- articleId: v.optional(v.id("articles")),
- url: v.optional(v.string()),
- prefillMessage: v.optional(v.string()),
- })
- ),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const name = args.name || `${E2E_TEST_PREFIX}message_${randomSuffix}`;
- const type = args.type || "chat";
-
- const content: {
- text?: string;
- senderId?: Id<"users">;
- title?: string;
- body?: string;
- style?: "inline" | "floating";
- dismissible?: boolean;
- buttons?: Array<{
- text: string;
- action:
- | "url"
- | "dismiss"
- | "tour"
- | "open_new_conversation"
- | "open_help_article"
- | "open_widget_tab";
- url?: string;
- }>;
- clickAction?: {
- type:
- | "open_messenger"
- | "open_new_conversation"
- | "open_widget_tab"
- | "open_help_article"
- | "open_url"
- | "dismiss";
- tabId?: string;
- articleId?: Id<"articles">;
- url?: string;
- prefillMessage?: string;
- };
- } = {};
-
- if (type === "chat") {
- content.text = "Hello! This is an E2E test message. How can we help you today?";
- content.senderId = args.senderId;
- } else if (type === "post") {
- content.title = "E2E Test Announcement";
- content.body = "This is a test post message for E2E testing.";
- content.buttons = [
- { text: "Learn More", action: "url", url: "https://example.com" },
- { text: "Dismiss", action: "dismiss" },
- ];
- } else if (type === "banner") {
- content.text = "E2E Test Banner - Limited time offer!";
- content.style = "floating";
- content.dismissible = true;
- content.buttons = [{ text: "View Offer", action: "url", url: "https://example.com" }];
- }
-
- if (args.clickAction) {
- content.clickAction = args.clickAction;
- }
-
- const messageId = await ctx.db.insert("outboundMessages", {
- workspaceId: args.workspaceId,
- name,
- type,
- content,
- status: args.status || "active",
- triggers: {
- type: args.triggerType || "immediate",
- pageUrl: args.triggerPageUrl,
- pageUrlMatch: args.triggerPageUrl ? "contains" : undefined,
- delaySeconds: args.triggerType === "time_on_page" ? 3 : undefined,
- scrollPercent: args.triggerType === "scroll_depth" ? 50 : undefined,
- },
- frequency: "once",
- priority: 100,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { messageId, name };
- },
-});
-
-/**
- * Seeds test articles in a collection for E2E testing.
- */
-export const seedArticles = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- collectionName: v.optional(v.string()),
- articleCount: v.optional(v.number()),
- includesDraft: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const collectionName = args.collectionName || `${E2E_TEST_PREFIX}collection_${randomSuffix}`;
- const articleCount = args.articleCount || 3;
-
- // Create collection
- const collectionId = await ctx.db.insert("collections", {
- workspaceId: args.workspaceId,
- name: collectionName,
- slug: collectionName.toLowerCase().replace(/\s+/g, "-"),
- description: "E2E test article collection",
- order: 0,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- // Create articles
- const articleIds: Id<"articles">[] = [];
- const articles = [
- {
- title: "Getting Started Guide",
- content:
- "# Getting Started\n\nWelcome to our platform! This guide will help you get started quickly.\n\n## Step 1: Create an Account\n\nFirst, sign up for an account...\n\n## Step 2: Configure Settings\n\nNext, configure your settings...",
- },
- {
- title: "FAQ",
- content:
- "# Frequently Asked Questions\n\n## How do I reset my password?\n\nYou can reset your password by clicking the forgot password link.\n\n## How do I contact support?\n\nReach out to us through the chat widget.",
- },
- {
- title: "Troubleshooting Common Issues",
- content:
- "# Troubleshooting\n\n## Login Issues\n\nIf you can't log in, try clearing your browser cache.\n\n## Performance Issues\n\nMake sure you have a stable internet connection.",
- },
- ];
-
- for (let i = 0; i < articleCount; i++) {
- const article = articles[i % articles.length];
- const isDraft = args.includesDraft && i === articleCount - 1;
-
- const articleId = await ctx.db.insert("articles", {
- workspaceId: args.workspaceId,
- collectionId,
- title: `${E2E_TEST_PREFIX}${article.title}`,
- slug: `${E2E_TEST_PREFIX}${article.title.toLowerCase().replace(/\s+/g, "-")}-${randomSuffix}-${i}`,
- content: article.content,
- status: isDraft ? "draft" : "published",
- order: i,
- createdAt: timestamp,
- updatedAt: timestamp,
- publishedAt: isDraft ? undefined : timestamp,
- });
- articleIds.push(articleId);
- }
-
- return { collectionId, collectionName, articleIds };
- },
-});
-
-/**
- * Seeds a test visitor with custom attributes for E2E testing.
- */
-export const seedVisitor = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- email: v.optional(v.string()),
- name: v.optional(v.string()),
- externalUserId: v.optional(v.string()),
- customAttributes: v.optional(v.any()),
- location: v.optional(
- v.object({
- city: v.optional(v.string()),
- region: v.optional(v.string()),
- country: v.optional(v.string()),
- countryCode: v.optional(v.string()),
- })
- ),
- device: v.optional(
- v.object({
- browser: v.optional(v.string()),
- os: v.optional(v.string()),
- deviceType: v.optional(v.string()),
- })
- ),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const sessionId = `${E2E_TEST_PREFIX}session_${timestamp}_${randomSuffix}`;
-
- const visitorId = await ctx.db.insert("visitors", {
- sessionId,
- workspaceId: args.workspaceId,
- email: args.email || `${E2E_TEST_PREFIX}visitor_${randomSuffix}@test.opencom.dev`,
- name: args.name || `E2E Test Visitor ${randomSuffix}`,
- externalUserId: args.externalUserId,
- customAttributes: args.customAttributes || {
- plan: "free",
- signupDate: new Date().toISOString(),
- },
- location: args.location || {
- city: "San Francisco",
- region: "California",
- country: "United States",
- countryCode: "US",
- },
- device: args.device || {
- browser: "Chrome",
- os: "macOS",
- deviceType: "desktop",
- },
- firstSeenAt: timestamp,
- lastSeenAt: timestamp,
- createdAt: timestamp,
- });
-
- await ctx.db.patch(visitorId, {
- readableId: formatReadableVisitorId(visitorId),
- });
-
- return { visitorId, sessionId };
- },
-});
-
-/**
- * Seeds a test segment for E2E testing.
- */
-export const seedSegment = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.optional(v.string()),
- audienceRules: v.optional(v.any()),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const name = args.name || `${E2E_TEST_PREFIX}segment_${randomSuffix}`;
-
- const defaultRules = {
- type: "group" as const,
- operator: "and" as const,
- conditions: [
- {
- type: "condition" as const,
- property: { source: "system" as const, key: "email" },
- operator: "is_set" as const,
- },
- ],
- };
-
- const segmentId = await ctx.db.insert("segments", {
- workspaceId: args.workspaceId,
- name,
- description: "E2E test segment",
- audienceRules: args.audienceRules || defaultRules,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { segmentId, name };
- },
-});
-
-/**
- * Seeds messenger settings for E2E testing.
- */
-export const seedMessengerSettings = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- primaryColor: v.optional(v.string()),
- welcomeMessage: v.optional(v.string()),
- launcherPosition: v.optional(v.union(v.literal("right"), v.literal("left"))),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const timestamp = Date.now();
-
- // Check if settings exist, update or create
- const existing = await ctx.db
- .query("messengerSettings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .first();
-
- if (existing) {
- await ctx.db.patch(existing._id, {
- primaryColor: args.primaryColor || "#792cd4",
- backgroundColor: "#792cd4",
- welcomeMessage: args.welcomeMessage || "Hello! How can we help you today?",
- launcherPosition: args.launcherPosition || "right",
- updatedAt: timestamp,
- });
- return { settingsId: existing._id };
- }
-
- const settingsId = await ctx.db.insert("messengerSettings", {
- workspaceId: args.workspaceId,
- primaryColor: args.primaryColor || "#792cd4",
- backgroundColor: "#792cd4",
- themeMode: "light",
- launcherPosition: args.launcherPosition || "right",
- launcherSideSpacing: 20,
- launcherBottomSpacing: 20,
- showLauncher: true,
- welcomeMessage: args.welcomeMessage || "Hello! How can we help you today?",
- showTeammateAvatars: true,
- supportedLanguages: ["en"],
- defaultLanguage: "en",
- mobileEnabled: true,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { settingsId };
- },
-});
-
-/**
- * Seeds AI agent settings for E2E testing.
- */
-export const seedAIAgentSettings = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- enabled: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const timestamp = Date.now();
-
- // Check if settings exist, update or create
- const existing = await ctx.db
- .query("aiAgentSettings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .first();
-
- if (existing) {
- await ctx.db.patch(existing._id, {
- enabled: args.enabled ?? true,
- updatedAt: timestamp,
- });
- return { settingsId: existing._id };
- }
-
- const settingsId = await ctx.db.insert("aiAgentSettings", {
- workspaceId: args.workspaceId,
- enabled: args.enabled ?? true,
- knowledgeSources: ["articles"],
- confidenceThreshold: 0.7,
- personality: "helpful and friendly",
- handoffMessage: "Let me connect you with a human agent.",
- model: "gpt-5-nano",
- suggestionsEnabled: true,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { settingsId };
- },
-});
-
-/**
- * Cleans up all test data with the e2e_test_ prefix from a workspace.
- */
-export const cleanupTestData = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const { workspaceId } = args;
- let cleaned = {
- tours: 0,
- tourSteps: 0,
- tourProgress: 0,
- surveys: 0,
- surveyResponses: 0,
- surveyImpressions: 0,
- carousels: 0,
- carouselImpressions: 0,
- outboundMessages: 0,
- outboundMessageImpressions: 0,
- articles: 0,
- collections: 0,
- segments: 0,
- visitors: 0,
- checklists: 0,
- tooltips: 0,
- snippets: 0,
- emailCampaigns: 0,
- tickets: 0,
- };
-
- // Clean up tours and related data
- const tours = await ctx.db
- .query("tours")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const tour of tours) {
- if (tour.name.startsWith(E2E_TEST_PREFIX)) {
- // Delete tour steps
- const steps = await ctx.db
- .query("tourSteps")
- .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
- .collect();
- for (const step of steps) {
- await ctx.db.delete(step._id);
- cleaned.tourSteps++;
- }
-
- // Delete tour progress
- const progress = await ctx.db
- .query("tourProgress")
- .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
- .collect();
- for (const p of progress) {
- await ctx.db.delete(p._id);
- cleaned.tourProgress++;
- }
-
- await ctx.db.delete(tour._id);
- cleaned.tours++;
- }
- }
-
- // Clean up surveys and related data
- const surveys = await ctx.db
- .query("surveys")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const survey of surveys) {
- if (survey.name.startsWith(E2E_TEST_PREFIX)) {
- // Delete survey responses
- const responses = await ctx.db
- .query("surveyResponses")
- .withIndex("by_survey", (q) => q.eq("surveyId", survey._id))
- .collect();
- for (const response of responses) {
- await ctx.db.delete(response._id);
- cleaned.surveyResponses++;
- }
-
- // Delete survey impressions
- const impressions = await ctx.db
- .query("surveyImpressions")
- .withIndex("by_survey", (q) => q.eq("surveyId", survey._id))
- .collect();
- for (const impression of impressions) {
- await ctx.db.delete(impression._id);
- cleaned.surveyImpressions++;
- }
-
- await ctx.db.delete(survey._id);
- cleaned.surveys++;
- }
- }
-
- // Clean up carousels and related data
- const carousels = await ctx.db
- .query("carousels")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const carousel of carousels) {
- if (carousel.name.startsWith(E2E_TEST_PREFIX)) {
- // Delete carousel impressions
- const impressions = await ctx.db
- .query("carouselImpressions")
- .withIndex("by_carousel", (q) => q.eq("carouselId", carousel._id))
- .collect();
- for (const impression of impressions) {
- await ctx.db.delete(impression._id);
- cleaned.carouselImpressions++;
- }
-
- await ctx.db.delete(carousel._id);
- cleaned.carousels++;
- }
- }
-
- // Clean up outbound messages and related data
- const outboundMessages = await ctx.db
- .query("outboundMessages")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const message of outboundMessages) {
- if (message.name.startsWith(E2E_TEST_PREFIX)) {
- // Delete message impressions
- const impressions = await ctx.db
- .query("outboundMessageImpressions")
- .withIndex("by_message", (q) => q.eq("messageId", message._id))
- .collect();
- for (const impression of impressions) {
- await ctx.db.delete(impression._id);
- cleaned.outboundMessageImpressions++;
- }
-
- await ctx.db.delete(message._id);
- cleaned.outboundMessages++;
- }
- }
-
- // Clean up articles
- const articles = await ctx.db
- .query("articles")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const article of articles) {
- if (article.title.startsWith(E2E_TEST_PREFIX) || article.slug.startsWith(E2E_TEST_PREFIX)) {
- await ctx.db.delete(article._id);
- cleaned.articles++;
- }
- }
-
- // Clean up collections
- const collections = await ctx.db
- .query("collections")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const collection of collections) {
- if (
- collection.name.startsWith(E2E_TEST_PREFIX) ||
- collection.slug.startsWith(E2E_TEST_PREFIX)
- ) {
- await ctx.db.delete(collection._id);
- cleaned.collections++;
- }
- }
-
- // Clean up segments
- const segments = await ctx.db
- .query("segments")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const segment of segments) {
- if (segment.name.startsWith(E2E_TEST_PREFIX)) {
- await ctx.db.delete(segment._id);
- cleaned.segments++;
- }
- }
-
- // Clean up checklists
- const checklists = await ctx.db
- .query("checklists")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const checklist of checklists) {
- if (checklist.name.startsWith(E2E_TEST_PREFIX)) {
- const progress = await ctx.db
- .query("checklistProgress")
- .withIndex("by_checklist", (q) => q.eq("checklistId", checklist._id))
- .collect();
- for (const p of progress) {
- await ctx.db.delete(p._id);
- }
- await ctx.db.delete(checklist._id);
- cleaned.checklists++;
- }
- }
-
- // Clean up tooltips
- const tooltips = await ctx.db
- .query("tooltips")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const tooltip of tooltips) {
- if (tooltip.name.startsWith(E2E_TEST_PREFIX)) {
- await ctx.db.delete(tooltip._id);
- cleaned.tooltips++;
- }
- }
-
- // Clean up snippets
- const snippets = await ctx.db
- .query("snippets")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const snippet of snippets) {
- if (snippet.name.startsWith(E2E_TEST_PREFIX)) {
- await ctx.db.delete(snippet._id);
- cleaned.snippets++;
- }
- }
-
- // Clean up email campaigns
- const emailCampaigns = await ctx.db
- .query("emailCampaigns")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const campaign of emailCampaigns) {
- if (campaign.name.startsWith(E2E_TEST_PREFIX)) {
- await ctx.db.delete(campaign._id);
- cleaned.emailCampaigns++;
- }
- }
-
- // Clean up tickets
- const tickets = await ctx.db
- .query("tickets")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const ticket of tickets) {
- if (ticket.subject.startsWith(E2E_TEST_PREFIX)) {
- const comments = await ctx.db
- .query("ticketComments")
- .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
- .collect();
- for (const comment of comments) {
- await ctx.db.delete(comment._id);
- }
- await ctx.db.delete(ticket._id);
- cleaned.tickets++;
- }
- }
-
- // Clean up visitors with test prefix in session or email
- const visitors = await ctx.db
- .query("visitors")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const visitor of visitors) {
- if (
- visitor.sessionId.startsWith(E2E_TEST_PREFIX) ||
- (visitor.email && visitor.email.startsWith(E2E_TEST_PREFIX))
- ) {
- // Delete visitor events
- const events = await ctx.db
- .query("events")
- .withIndex("by_visitor", (q) => q.eq("visitorId", visitor._id))
- .collect();
- for (const event of events) {
- await ctx.db.delete(event._id);
- }
-
- // Delete visitor conversations
- const conversations = await ctx.db
- .query("conversations")
- .withIndex("by_visitor", (q) => q.eq("visitorId", visitor._id))
- .collect();
- for (const conversation of conversations) {
- const messages = await ctx.db
- .query("messages")
- .withIndex("by_conversation", (q) => q.eq("conversationId", conversation._id))
- .collect();
- for (const message of messages) {
- await ctx.db.delete(message._id);
- }
- await ctx.db.delete(conversation._id);
- }
-
- await ctx.db.delete(visitor._id);
- cleaned.visitors++;
- }
- }
-
- return { success: true, cleaned };
- },
-});
-
-/**
- * Seeds all test data at once for a complete E2E test setup.
- */
-export const seedAll = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const { workspaceId } = args;
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
-
- // Create a test visitor
- const visitorSessionId = `${E2E_TEST_PREFIX}session_${timestamp}_${randomSuffix}`;
- const visitorId = await ctx.db.insert("visitors", {
- sessionId: visitorSessionId,
- workspaceId,
- email: `${E2E_TEST_PREFIX}visitor_${randomSuffix}@test.opencom.dev`,
- name: `E2E Test Visitor`,
- customAttributes: { plan: "pro", signupDate: new Date().toISOString() },
- firstSeenAt: timestamp,
- lastSeenAt: timestamp,
- createdAt: timestamp,
- });
-
- await ctx.db.patch(visitorId, {
- readableId: formatReadableVisitorId(visitorId),
- });
-
- // Create a test tour
- const tourName = `${E2E_TEST_PREFIX}tour_${randomSuffix}`;
- const tourId = await ctx.db.insert("tours", {
- workspaceId,
- name: tourName,
- description: "E2E test tour",
- status: "active",
- displayMode: "first_time_only",
- priority: 100,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- await ctx.db.insert("tourSteps", {
- workspaceId,
- tourId,
- type: "post",
- order: 0,
- title: "Welcome",
- content: "Welcome to the test tour!",
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- // Create a test survey
- const surveyName = `${E2E_TEST_PREFIX}survey_${randomSuffix}`;
- const surveyId = await ctx.db.insert("surveys", {
- workspaceId,
- name: surveyName,
- format: "small",
- status: "active",
- questions: [
- {
- id: `q_${randomSuffix}`,
- type: "nps",
- title: "How likely are you to recommend us?",
- required: true,
- },
- ],
- thankYouStep: {
- title: "Thank you!",
- description: "Your feedback is appreciated.",
- },
- triggers: { type: "immediate" },
- frequency: "once",
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- // Create test articles
- const collectionName = `${E2E_TEST_PREFIX}collection_${randomSuffix}`;
- const collectionId = await ctx.db.insert("collections", {
- workspaceId,
- name: collectionName,
- slug: collectionName.toLowerCase().replace(/\s+/g, "-"),
- description: "E2E test collection",
- order: 0,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- const articleId = await ctx.db.insert("articles", {
- workspaceId,
- collectionId,
- title: `${E2E_TEST_PREFIX}Getting Started`,
- slug: `${E2E_TEST_PREFIX}getting-started-${randomSuffix}`,
- content: "# Getting Started\n\nWelcome to our platform!",
- status: "published",
- order: 0,
- createdAt: timestamp,
- updatedAt: timestamp,
- publishedAt: timestamp,
- });
-
- // Create test segment
- const segmentName = `${E2E_TEST_PREFIX}segment_${randomSuffix}`;
- const segmentId = await ctx.db.insert("segments", {
- workspaceId,
- name: segmentName,
- description: "E2E test segment",
- audienceRules: {
- type: "group" as const,
- operator: "and" as const,
- conditions: [
- {
- type: "condition" as const,
- property: { source: "system" as const, key: "email" },
- operator: "is_set" as const,
- },
- ],
- },
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- return {
- visitorId,
- visitorSessionId,
- tourId,
- surveyId,
- collectionId,
- articleId,
- segmentId,
- };
- },
-});
-
-/**
- * Cleans up all E2E test data across all workspaces.
- */
-export const cleanupAll = internalMutation({
- args: {},
- handler: async (ctx) => {
- requireTestDataEnabled();
- let totalCleaned = {
- workspaces: 0,
- items: 0,
- };
-
- // Get all workspaces
- const workspaces = await ctx.db.query("workspaces").collect();
-
- for (const workspace of workspaces) {
- // Clean up test data in each workspace
- const tours = await ctx.db
- .query("tours")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
- .collect();
-
- let hasTestData = false;
- for (const tour of tours) {
- if (tour.name.startsWith(E2E_TEST_PREFIX)) {
- hasTestData = true;
- const steps = await ctx.db
- .query("tourSteps")
- .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
- .collect();
- for (const step of steps) {
- await ctx.db.delete(step._id);
- totalCleaned.items++;
- }
- await ctx.db.delete(tour._id);
- totalCleaned.items++;
- }
- }
-
- const surveys = await ctx.db
- .query("surveys")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
- .collect();
-
- for (const survey of surveys) {
- if (survey.name.startsWith(E2E_TEST_PREFIX)) {
- hasTestData = true;
- await ctx.db.delete(survey._id);
- totalCleaned.items++;
- }
- }
-
- const articles = await ctx.db
- .query("articles")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
- .collect();
-
- for (const article of articles) {
- if (article.title.startsWith(E2E_TEST_PREFIX)) {
- hasTestData = true;
- await ctx.db.delete(article._id);
- totalCleaned.items++;
- }
- }
-
- const collections = await ctx.db
- .query("collections")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
- .collect();
-
- for (const collection of collections) {
- if (collection.name.startsWith(E2E_TEST_PREFIX)) {
- hasTestData = true;
- await ctx.db.delete(collection._id);
- totalCleaned.items++;
- }
- }
-
- const segments = await ctx.db
- .query("segments")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
- .collect();
-
- for (const segment of segments) {
- if (segment.name.startsWith(E2E_TEST_PREFIX)) {
- hasTestData = true;
- await ctx.db.delete(segment._id);
- totalCleaned.items++;
- }
- }
-
- const visitors = await ctx.db
- .query("visitors")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
- .collect();
-
- for (const visitor of visitors) {
- if (visitor.sessionId.startsWith(E2E_TEST_PREFIX)) {
- hasTestData = true;
- await ctx.db.delete(visitor._id);
- totalCleaned.items++;
- }
- }
-
- if (hasTestData) {
- totalCleaned.workspaces++;
- }
- }
-
- return { success: true, totalCleaned };
- },
-});
-
-/**
- * Clears all tours for a workspace. Used for testing empty state.
- * This is safe for parallel tests as it only affects the specified workspace.
- */
-export const clearAllTours = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const { workspaceId } = args;
- let deletedCount = 0;
-
- // Get all tours for this workspace
- const tours = await ctx.db
- .query("tours")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const tour of tours) {
- // Delete tour steps first
- const steps = await ctx.db
- .query("tourSteps")
- .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
- .collect();
- for (const step of steps) {
- await ctx.db.delete(step._id);
- }
-
- // Delete tour progress
- const progress = await ctx.db
- .query("tourProgress")
- .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
- .collect();
- for (const p of progress) {
- await ctx.db.delete(p._id);
- }
-
- // Delete the tour
- await ctx.db.delete(tour._id);
- deletedCount++;
- }
-
- return { success: true, deletedCount };
- },
-});
-
-/**
- * Seeds comprehensive demo data for screenshot automation.
- * Creates realistic data across all major features so screenshots
- * show a "full" workspace state.
- *
- * Use SEED_DATA=true with the screenshot scripts to invoke this.
- */
-export const seedDemoData = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const { workspaceId } = args;
- const now = Date.now();
- const DAY = 86400000;
-
- // ── Clean up stale e2e_test segments from previous runs ────────
- const oldSegments = await ctx.db
- .query("segments")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const seg of oldSegments) {
- if (seg.name.startsWith(E2E_TEST_PREFIX)) {
- await ctx.db.delete(seg._id);
- }
- }
-
- // ── Visitors ────────────────────────────────────────────────────
- const visitors: Id<"visitors">[] = [];
- const visitorProfiles = [
- {
- name: "Sarah Chen",
- email: "sarah.chen@acme.io",
- city: "San Francisco",
- country: "United States",
- countryCode: "US",
- browser: "Chrome",
- os: "macOS",
- plan: "pro",
- company: "Acme Inc",
- },
- {
- name: "Marcus Johnson",
- email: "marcus@techstart.co",
- city: "Austin",
- country: "United States",
- countryCode: "US",
- browser: "Firefox",
- os: "Windows",
- plan: "free",
- company: "TechStart",
- },
- {
- name: "Priya Sharma",
- email: "priya@globalcorp.com",
- city: "London",
- country: "United Kingdom",
- countryCode: "GB",
- browser: "Safari",
- os: "macOS",
- plan: "enterprise",
- company: "GlobalCorp",
- },
- {
- name: "Alex Rivera",
- email: "alex@designhub.io",
- city: "New York",
- country: "United States",
- countryCode: "US",
- browser: "Chrome",
- os: "Windows",
- plan: "pro",
- company: "DesignHub",
- },
- {
- name: "Emma Wilson",
- email: "emma@retailplus.com",
- city: "Toronto",
- country: "Canada",
- countryCode: "CA",
- browser: "Edge",
- os: "Windows",
- plan: "free",
- company: "RetailPlus",
- },
- {
- name: "Kenji Tanaka",
- email: "kenji@appsol.jp",
- city: "Tokyo",
- country: "Japan",
- countryCode: "JP",
- browser: "Chrome",
- os: "macOS",
- plan: "pro",
- company: "AppSolutions",
- },
- ];
-
- for (let i = 0; i < visitorProfiles.length; i++) {
- const p = visitorProfiles[i];
- const vid = await ctx.db.insert("visitors", {
- sessionId: `${E2E_TEST_PREFIX}demo_session_${i}`,
- workspaceId,
- email: p.email,
- name: p.name,
- customAttributes: {
- plan: p.plan,
- company: p.company,
- signupDate: new Date(now - (i + 1) * 7 * DAY).toISOString(),
- },
- location: { city: p.city, country: p.country, countryCode: p.countryCode },
- device: { browser: p.browser, os: p.os, deviceType: "desktop" },
- firstSeenAt: now - (i + 1) * 7 * DAY,
- lastSeenAt: now - i * DAY,
- createdAt: now - (i + 1) * 7 * DAY,
- });
- await ctx.db.patch(vid, {
- readableId: formatReadableVisitorId(vid),
- });
- visitors.push(vid);
- }
-
- // ── Conversations + Messages ────────────────────────────────────
- const conversationData = [
- {
- visitorIdx: 0,
- status: "open" as const,
- msgs: [
- {
- sender: "visitor",
- content: "Hi! I'm having trouble setting up the API integration. Can you help?",
- },
- {
- sender: "bot",
- content:
- "Of course! I'd be happy to help with API integration. Could you tell me which endpoint you're working with?",
- },
- { sender: "visitor", content: "The webhooks endpoint — I keep getting 401 errors." },
- ],
- },
- {
- visitorIdx: 1,
- status: "open" as const,
- msgs: [
- { sender: "visitor", content: "Is there a way to export my analytics data as CSV?" },
- {
- sender: "bot",
- content:
- "Yes! You can export analytics from the Reports page. Click the export icon in the top right corner.",
- },
- ],
- },
- {
- visitorIdx: 2,
- status: "closed" as const,
- msgs: [
- {
- sender: "visitor",
- content: "We need to upgrade our plan to Enterprise. Who should I contact?",
- },
- {
- sender: "bot",
- content:
- "I'll connect you with our sales team right away. They typically respond within an hour.",
- },
- { sender: "visitor", content: "Great, thank you!" },
- ],
- },
- {
- visitorIdx: 3,
- status: "open" as const,
- msgs: [
- {
- sender: "visitor",
- content: "The tooltip builder is not loading on our staging environment.",
- },
- ],
- },
- {
- visitorIdx: 4,
- status: "snoozed" as const,
- msgs: [
- { sender: "visitor", content: "Can we customise the widget colours to match our brand?" },
- {
- sender: "bot",
- content:
- "Absolutely! Go to Settings → Messenger and update the primary colour. Changes apply instantly.",
- },
- { sender: "visitor", content: "Perfect, I'll try that after our deploy on Monday." },
- ],
- },
- ];
-
- const conversationIds: Id<"conversations">[] = [];
- for (const conv of conversationData) {
- const createdAt = now - (5 - conv.visitorIdx) * DAY;
- const cid = await ctx.db.insert("conversations", {
- workspaceId,
- visitorId: visitors[conv.visitorIdx],
- status: conv.status,
- createdAt,
- updatedAt: createdAt + conv.msgs.length * 60000,
- lastMessageAt: createdAt + conv.msgs.length * 60000,
- unreadByAgent: conv.status === "open" ? 1 : 0,
- });
- conversationIds.push(cid);
-
- for (let j = 0; j < conv.msgs.length; j++) {
- const m = conv.msgs[j];
- await ctx.db.insert("messages", {
- conversationId: cid,
- senderId:
- m.sender === "visitor" ? (visitors[conv.visitorIdx] as unknown as string) : "system",
- senderType: m.sender === "visitor" ? "visitor" : "bot",
- content: m.content,
- createdAt: createdAt + j * 60000,
- });
- }
- }
-
- // ── Tickets ──────────────────────────────────────────────────────
- const ticketData = [
- {
- visitorIdx: 0,
- subject: "API webhook returns 401 Unauthorized",
- priority: "high" as const,
- status: "in_progress" as const,
- },
- {
- visitorIdx: 1,
- subject: "CSV export missing date column",
- priority: "normal" as const,
- status: "submitted" as const,
- },
- {
- visitorIdx: 2,
- subject: "Enterprise plan upgrade request",
- priority: "normal" as const,
- status: "resolved" as const,
- },
- {
- visitorIdx: 3,
- subject: "Tooltip builder blank on staging",
- priority: "high" as const,
- status: "submitted" as const,
- },
- {
- visitorIdx: 4,
- subject: "Widget colour customisation help",
- priority: "low" as const,
- status: "waiting_on_customer" as const,
- },
- {
- visitorIdx: 5,
- subject: "SDK initialisation error on iOS 17",
- priority: "urgent" as const,
- status: "in_progress" as const,
- },
- ];
-
- for (let i = 0; i < ticketData.length; i++) {
- const t = ticketData[i];
- await ctx.db.insert("tickets", {
- workspaceId,
- visitorId: visitors[t.visitorIdx],
- subject: `${E2E_TEST_PREFIX}${t.subject}`,
- description: `Customer reported: ${t.subject}`,
- status: t.status,
- priority: t.priority,
- createdAt: now - (6 - i) * DAY,
- updatedAt: now - i * DAY,
- resolvedAt: t.status === "resolved" ? now - i * DAY : undefined,
- });
- }
-
- // ── Articles + Collections ───────────────────────────────────────
- const collections = [
- {
- name: "Getting Started",
- desc: "Everything you need to begin",
- articles: [
- {
- title: "Quick Start Guide",
- content:
- "# Quick Start Guide\n\nWelcome to Opencom! Follow these steps to get started in under 5 minutes.\n\n## Step 1: Install the Widget\n\nAdd the JavaScript snippet to your website's `` tag.\n\n## Step 2: Configure Your Messenger\n\nCustomise colours, welcome message, and team availability.\n\n## Step 3: Start Conversations\n\nYour visitors can now reach you through the widget!",
- },
- {
- title: "Installing the Widget",
- content:
- '# Installing the Widget\n\nThe Opencom widget can be installed on any website.\n\n## HTML Installation\n\n```html\n\n```\n\n## React Installation\n\n```bash\nnpm install @opencom/react\n```\n\nSee the SDK documentation for framework-specific guides.',
- },
- {
- title: "Setting Up Your Team",
- content:
- "# Setting Up Your Team\n\nInvite your team members and assign roles.\n\n## Roles\n\n- **Owner**: Full access\n- **Admin**: Manage settings and team\n- **Agent**: Handle conversations and tickets",
- },
- ],
- },
- {
- name: "Messaging & Inbox",
- desc: "Managing conversations and messages",
- articles: [
- {
- title: "Using the Inbox",
- content:
- "# Using the Inbox\n\nThe inbox is your central hub for all customer conversations.\n\n## Filtering Conversations\n\nUse the sidebar filters to view open, closed, or snoozed conversations.\n\n## Assigning Conversations\n\nClick the assignee dropdown to route conversations to specific agents.",
- },
- {
- title: "Outbound Messages",
- content:
- "# Outbound Messages\n\nSend targeted messages to your users based on behaviour and attributes.\n\n## Message Types\n\n- **Chat**: Appears as a chat bubble\n- **Post**: Rich content card\n- **Banner**: Top or bottom bar\n\n## Targeting\n\nUse audience rules to show messages to specific segments.",
- },
- ],
- },
- {
- name: "Help Center",
- desc: "Build a self-service knowledge base",
- articles: [
- {
- title: "Creating Articles",
- content:
- "# Creating Articles\n\nWrite help articles with our rich text editor.\n\n## Markdown Support\n\nArticles support full Markdown including code blocks, tables, and images.\n\n## Publishing\n\nSave as draft or publish immediately. Published articles appear in the widget.",
- },
- {
- title: "Organising Collections",
- content:
- "# Organising Collections\n\nGroup related articles into collections for easy browsing.\n\n## Collection Icons\n\nChoose an icon for each collection to make navigation intuitive.\n\n## Ordering\n\nDrag and drop to reorder collections and articles.",
- },
- ],
- },
- ];
-
- for (let ci = 0; ci < collections.length; ci++) {
- const c = collections[ci];
- const slug = `${E2E_TEST_PREFIX}${c.name.toLowerCase().replace(/\s+/g, "-")}`;
- const collectionId = await ctx.db.insert("collections", {
- workspaceId,
- name: `${E2E_TEST_PREFIX}${c.name}`,
- slug,
- description: c.desc,
- order: ci,
- createdAt: now - 30 * DAY,
- updatedAt: now,
- });
-
- for (let ai = 0; ai < c.articles.length; ai++) {
- const a = c.articles[ai];
- await ctx.db.insert("articles", {
- workspaceId,
- collectionId,
- title: `${E2E_TEST_PREFIX}${a.title}`,
- slug: `${E2E_TEST_PREFIX}${a.title.toLowerCase().replace(/\s+/g, "-")}-${ci}-${ai}`,
- content: a.content,
- status: "published",
- order: ai,
- createdAt: now - 30 * DAY + ai * DAY,
- updatedAt: now,
- publishedAt: now - 30 * DAY + ai * DAY,
- });
- }
- }
-
- // ── Snippets ─────────────────────────────────────────────────────
- const snippetData = [
- {
- name: "Greeting",
- shortcut: "hi",
- content: "Hi there! Thanks for reaching out. How can I help you today?",
- },
- {
- name: "Escalation",
- shortcut: "esc",
- content:
- "I'm going to loop in a specialist who can help with this. They'll follow up shortly.",
- },
- {
- name: "Follow Up",
- shortcut: "fu",
- content:
- "Just checking in — were you able to resolve the issue? Let me know if you need any further help.",
- },
- {
- name: "Closing",
- shortcut: "close",
- content: "Glad I could help! Feel free to reach out any time. Have a great day!",
- },
- {
- name: "Bug Report Ack",
- shortcut: "bug",
- content:
- "Thanks for reporting this. I've created a ticket and our engineering team will investigate. I'll keep you posted on progress.",
- },
- ];
-
- for (const s of snippetData) {
- await ctx.db.insert("snippets", {
- workspaceId,
- name: `${E2E_TEST_PREFIX}${s.name}`,
- shortcut: s.shortcut,
- content: s.content,
- createdAt: now - 14 * DAY,
- updatedAt: now,
- });
- }
-
- // ── Outbound Messages ────────────────────────────────────────────
- const outboundData = [
- {
- name: "Welcome Chat",
- type: "chat" as const,
- status: "active" as const,
- text: "Welcome! Let us know if you need help getting started.",
- },
- {
- name: "Feature Announcement",
- type: "post" as const,
- status: "active" as const,
- title: "New: AI-Powered Suggestions",
- body: "Our AI agent can now suggest help articles to your visitors automatically.",
- },
- {
- name: "Upgrade Banner",
- type: "banner" as const,
- status: "active" as const,
- text: "Unlock advanced analytics — upgrade to Pro today.",
- },
- {
- name: "Feedback Request",
- type: "chat" as const,
- status: "draft" as const,
- text: "We'd love to hear your feedback! How has your experience been so far?",
- },
- ];
-
- for (let i = 0; i < outboundData.length; i++) {
- const o = outboundData[i];
- const content: Record = {};
- if (o.type === "chat") {
- content.text = o.text;
- } else if (o.type === "post") {
- content.title = o.title;
- content.body = o.body;
- } else {
- content.text = o.text;
- content.style = "floating";
- content.dismissible = true;
- }
-
- await ctx.db.insert("outboundMessages", {
- workspaceId,
- name: `${E2E_TEST_PREFIX}${o.name}`,
- type: o.type,
- content: content as any,
- status: o.status,
- triggers: { type: "immediate" },
- frequency: "once",
- priority: 100 - i * 10,
- createdAt: now - (10 - i) * DAY,
- updatedAt: now,
- });
- }
-
- // ── Tours ────────────────────────────────────────────────────────
- const tourData = [
- {
- name: "Product Walkthrough",
- status: "active" as const,
- targetPageUrl: undefined as string | undefined,
- steps: [
- {
- type: "post" as const,
- title: "Welcome to Opencom!",
- content: "Let us show you around the key features.",
- },
- {
- type: "pointer" as const,
- title: "Your Inbox",
- content: "All customer conversations land here.",
- elementSelector: "[data-testid='nav-inbox']",
- },
- {
- type: "pointer" as const,
- title: "Knowledge Base",
- content: "Create help articles for self-service.",
- elementSelector: "[data-testid='nav-knowledge']",
- },
- ],
- },
- {
- name: "Widget Demo Tour",
- status: "active" as const,
- targetPageUrl: "*widget-demo*",
- steps: [
- {
- type: "post" as const,
- title: "Welcome to Opencom!",
- content: "Let us give you a quick tour of our platform and show you the key features.",
- },
- {
- type: "pointer" as const,
- title: "Tour Target 1",
- content: "This is the first interactive element you can explore.",
- elementSelector: "#tour-target-1",
- },
- {
- type: "pointer" as const,
- title: "Tour Target 2",
- content: "Here's another feature worth checking out.",
- elementSelector: "#tour-target-2",
- },
- {
- type: "pointer" as const,
- title: "Tour Target 3",
- content: "And one more thing to discover!",
- elementSelector: "#tour-target-3",
- },
- ],
- },
- {
- name: "Inbox Tour",
- status: "active" as const,
- targetPageUrl: undefined as string | undefined,
- steps: [
- {
- type: "post" as const,
- title: "Master Your Inbox",
- content: "Learn how to manage conversations efficiently.",
- },
- {
- type: "pointer" as const,
- title: "Filters",
- content: "Filter by status, assignee, or channel.",
- elementSelector: "[data-testid='inbox-filters']",
- },
- ],
- },
- {
- name: "Settings Tour",
- status: "draft" as const,
- targetPageUrl: undefined as string | undefined,
- steps: [
- {
- type: "post" as const,
- title: "Customise Your Workspace",
- content: "Adjust settings to match your workflow.",
- },
- ],
- },
- ];
-
- for (let i = 0; i < tourData.length; i++) {
- const t = tourData[i];
- const tourId = await ctx.db.insert("tours", {
- workspaceId,
- name: `${E2E_TEST_PREFIX}${t.name}`,
- description: `Demo tour: ${t.name}`,
- status: t.status,
- targetingRules: t.targetPageUrl ? { pageUrl: t.targetPageUrl } : undefined,
- displayMode: "first_time_only",
- priority: 100 - i * 10,
- createdAt: now - 20 * DAY,
- updatedAt: now,
- });
-
- for (let si = 0; si < t.steps.length; si++) {
- const s = t.steps[si];
- await ctx.db.insert("tourSteps", {
- workspaceId: args.workspaceId,
- tourId,
- type: s.type,
- order: si,
- title: s.title,
- content: s.content,
- elementSelector: "elementSelector" in s ? s.elementSelector : undefined,
- position: "auto",
- advanceOn: "click",
- createdAt: now - 20 * DAY,
- updatedAt: now,
- });
- }
- }
-
- // ── Surveys ──────────────────────────────────────────────────────
- const surveyData = [
- {
- name: "NPS Survey",
- format: "small" as const,
- status: "active" as const,
- qType: "nps" as const,
- qTitle: "How likely are you to recommend Opencom?",
- },
- {
- name: "Feature Satisfaction",
- format: "large" as const,
- status: "active" as const,
- qType: "star_rating" as const,
- qTitle: "How would you rate our product tours feature?",
- },
- {
- name: "Onboarding Feedback",
- format: "small" as const,
- status: "draft" as const,
- qType: "multiple_choice" as const,
- qTitle: "How did you hear about us?",
- },
- ];
-
- for (const s of surveyData) {
- await ctx.db.insert("surveys", {
- workspaceId,
- name: `${E2E_TEST_PREFIX}${s.name}`,
- description: `Demo survey: ${s.name}`,
- format: s.format,
- status: s.status,
- questions: [
- {
- id: `q_demo_${s.name.toLowerCase().replace(/\s+/g, "_")}`,
- type: s.qType as
- | "nps"
- | "numeric_scale"
- | "star_rating"
- | "emoji_rating"
- | "dropdown"
- | "short_text"
- | "long_text"
- | "multiple_choice",
- title: s.qTitle,
- required: true,
- ...(s.qType === "multiple_choice"
- ? {
- options: {
- choices: [
- "Google Search",
- "Friend or Colleague",
- "Social Media",
- "Blog Post",
- "Other",
- ],
- },
- }
- : {}),
- },
- ],
- introStep:
- s.format === "large"
- ? {
- title: s.name,
- description: "Help us improve by sharing your feedback",
- buttonText: "Start",
- }
- : undefined,
- thankYouStep: { title: "Thank you!", description: "Your feedback helps us improve." },
- triggers: { type: "immediate" },
- frequency: "once",
- createdAt: now - 15 * DAY,
- updatedAt: now,
- });
- }
-
- // ── Checklists ───────────────────────────────────────────────────
- await ctx.db.insert("checklists", {
- workspaceId,
- name: `${E2E_TEST_PREFIX}Onboarding Checklist`,
- description: "Get started with Opencom in 5 easy steps",
- tasks: [
- {
- id: "task_1",
- title: "Install the widget",
- description: "Add the snippet to your site",
- completionType: "manual",
- },
- {
- id: "task_2",
- title: "Customise your messenger",
- description: "Set brand colours and welcome message",
- completionType: "manual",
- },
- {
- id: "task_3",
- title: "Create your first article",
- description: "Write a help article for your users",
- completionType: "manual",
- },
- {
- id: "task_4",
- title: "Invite a teammate",
- description: "Add a colleague to your workspace",
- completionType: "manual",
- },
- {
- id: "task_5",
- title: "Send your first message",
- description: "Create an outbound message",
- completionType: "manual",
- },
- ],
- status: "active",
- createdAt: now - 14 * DAY,
- updatedAt: now,
- });
-
- await ctx.db.insert("checklists", {
- workspaceId,
- name: `${E2E_TEST_PREFIX}Advanced Setup`,
- description: "Unlock the full power of Opencom",
- tasks: [
- {
- id: "task_a1",
- title: "Set up audience segments",
- description: "Target users by attributes",
- completionType: "manual",
- },
- {
- id: "task_a2",
- title: "Create a product tour",
- description: "Guide users through features",
- completionType: "manual",
- },
- {
- id: "task_a3",
- title: "Configure AI agent",
- description: "Enable AI-powered responses",
- completionType: "manual",
- },
- ],
- status: "draft",
- createdAt: now - 7 * DAY,
- updatedAt: now,
- });
-
- // ── Tooltips ─────────────────────────────────────────────────────
- const tooltipData = [
- {
- name: "Inbox Filter Tip",
- selector: "[data-testid='inbox-filters']",
- content: "Use filters to quickly find conversations by status or assignee.",
- trigger: "hover" as const,
- },
- {
- name: "New Article Tip",
- selector: "[data-testid='new-article-btn']",
- content: "Click here to create a new help article for your knowledge base.",
- trigger: "hover" as const,
- },
- {
- name: "Export Data Tip",
- selector: "[data-testid='export-btn']",
- content: "Export your data as CSV or JSON for reporting.",
- trigger: "click" as const,
- },
- ];
-
- for (const t of tooltipData) {
- await ctx.db.insert("tooltips", {
- workspaceId,
- name: `${E2E_TEST_PREFIX}${t.name}`,
- elementSelector: t.selector,
- content: t.content,
- triggerType: t.trigger,
- createdAt: now - 10 * DAY,
- updatedAt: now,
- });
- }
-
- // ── Segments ─────────────────────────────────────────────────────
- const segmentData = [
- {
- name: "Active Users",
- desc: "Users seen in the last 7 days",
- rules: {
- type: "group" as const,
- operator: "and" as const,
- conditions: [
- {
- type: "condition" as const,
- property: { source: "system" as const, key: "lastSeenAt" },
- operator: "greater_than" as const,
- value: now - 7 * DAY,
- },
- ],
- },
- },
- {
- name: "Pro Plan Users",
- desc: "Users on the Pro plan",
- rules: {
- type: "group" as const,
- operator: "and" as const,
- conditions: [
- {
- type: "condition" as const,
- property: { source: "custom" as const, key: "plan" },
- operator: "equals" as const,
- value: "pro",
- },
- ],
- },
- },
- {
- name: "Enterprise Leads",
- desc: "Enterprise plan users",
- rules: {
- type: "group" as const,
- operator: "and" as const,
- conditions: [
- {
- type: "condition" as const,
- property: { source: "custom" as const, key: "plan" },
- operator: "equals" as const,
- value: "enterprise",
- },
- ],
- },
- },
- ];
-
- for (const s of segmentData) {
- await ctx.db.insert("segments", {
- workspaceId,
- name: `${E2E_TEST_PREFIX}${s.name}`,
- description: s.desc,
- audienceRules: s.rules,
- createdAt: now - 14 * DAY,
- updatedAt: now,
- });
- }
-
- // ── Email Campaigns ──────────────────────────────────────────────
- const campaignData = [
- {
- name: "Welcome Email",
- subject: "Welcome to Opencom!",
- status: "sent" as const,
- content: "Welcome!
Thanks for signing up. Here's how to get started...
",
- stats: {
- pending: 0,
- sent: 1240,
- delivered: 1210,
- opened: 845,
- clicked: 320,
- bounced: 12,
- unsubscribed: 3,
- },
- },
- {
- name: "Feature Update",
- subject: "New: AI Agent is here",
- status: "sent" as const,
- content: "AI Agent
Your visitors can now get instant answers powered by AI.
",
- stats: {
- pending: 0,
- sent: 980,
- delivered: 965,
- opened: 612,
- clicked: 198,
- bounced: 5,
- unsubscribed: 1,
- },
- },
- {
- name: "Re-engagement",
- subject: "We miss you!",
- status: "draft" as const,
- content: "Come back!
It's been a while. See what's new...
",
- stats: undefined,
- },
- ];
-
- for (const c of campaignData) {
- await ctx.db.insert("emailCampaigns", {
- workspaceId,
- name: `${E2E_TEST_PREFIX}${c.name}`,
- subject: c.subject,
- content: c.content,
- status: c.status,
- stats: c.stats,
- sentAt: c.status === "sent" ? now - 3 * DAY : undefined,
- createdAt: now - 10 * DAY,
- updatedAt: now,
- });
- }
-
- // ── Messenger Settings ───────────────────────────────────────────
- const existingSettings = await ctx.db
- .query("messengerSettings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .first();
-
- if (existingSettings) {
- await ctx.db.patch(existingSettings._id, {
- primaryColor: "#792cd4",
- backgroundColor: "#792cd4",
- welcomeMessage: "Hi there! How can we help you today?",
- launcherPosition: "right",
- updatedAt: now,
- });
- } else {
- await ctx.db.insert("messengerSettings", {
- workspaceId,
- primaryColor: "#792cd4",
- backgroundColor: "#792cd4",
- themeMode: "light",
- launcherPosition: "right",
- launcherSideSpacing: 20,
- launcherBottomSpacing: 20,
- showLauncher: true,
- welcomeMessage: "Hi there! How can we help you today?",
- showTeammateAvatars: true,
- supportedLanguages: ["en"],
- defaultLanguage: "en",
- mobileEnabled: true,
- createdAt: now,
- updatedAt: now,
- });
- }
-
- // ── AI Agent Settings ────────────────────────────────────────────
- const existingAI = await ctx.db
- .query("aiAgentSettings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .first();
-
- if (!existingAI) {
- await ctx.db.insert("aiAgentSettings", {
- workspaceId,
- enabled: true,
- knowledgeSources: ["articles"],
- confidenceThreshold: 0.7,
- personality: "helpful and friendly",
- handoffMessage: "Let me connect you with a human agent.",
- model: "gpt-5-nano",
- suggestionsEnabled: true,
- createdAt: now,
- updatedAt: now,
- });
- }
-
- return {
- visitors: visitors.length,
- conversations: conversationIds.length,
- tickets: ticketData.length,
- articles: collections.reduce((sum, c) => sum + c.articles.length, 0),
- collections: collections.length,
- snippets: snippetData.length,
- outboundMessages: outboundData.length,
- tours: tourData.length,
- surveys: surveyData.length,
- checklists: 2,
- tooltips: tooltipData.length,
- segments: segmentData.length,
- emailCampaigns: campaignData.length,
- };
- },
-});
-
-/**
- * Gets the count of tours for a workspace. Used for testing.
- */
-export const getTourCount = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const tours = await ctx.db
- .query("tours")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
- return { count: tours.length };
- },
-});
-
-const LANDING_DEMO_PREFIX = "LANDING_DEMO_";
-const LANDING_DEMO_ARTICLE_MARKER = ``;
-const LANDING_DEMO_ARTICLE_CONTENT_SUFFIX = `\n\n${LANDING_DEMO_ARTICLE_MARKER}`;
-const LANDING_DEMO_SLUG_SUFFIX = "landing-demo";
-
-function isLandingDemoArticle(article: { title: string; content: string }): boolean {
- return (
- article.title.startsWith(LANDING_DEMO_PREFIX) ||
- article.content.trimEnd().endsWith(LANDING_DEMO_ARTICLE_MARKER)
- );
-}
-
-/**
- * Cleans up all landing demo data from a workspace.
- */
-export const cleanupLandingDemo = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const { workspaceId } = args;
- let cleaned = {
- tours: 0,
- tourSteps: 0,
- checklists: 0,
- articles: 0,
- collections: 0,
- outboundMessages: 0,
- surveys: 0,
- tooltips: 0,
- };
-
- // Clean up tours and steps
- const tours = await ctx.db
- .query("tours")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const tour of tours) {
- if (tour.name.startsWith(LANDING_DEMO_PREFIX)) {
- const steps = await ctx.db
- .query("tourSteps")
- .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
- .collect();
- for (const step of steps) {
- await ctx.db.delete(step._id);
- cleaned.tourSteps++;
- }
- const progress = await ctx.db
- .query("tourProgress")
- .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
- .collect();
- for (const p of progress) {
- await ctx.db.delete(p._id);
- }
- await ctx.db.delete(tour._id);
- cleaned.tours++;
- }
- }
-
- // Clean up checklists
- const checklists = await ctx.db
- .query("checklists")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const checklist of checklists) {
- if (checklist.name.startsWith(LANDING_DEMO_PREFIX)) {
- const progress = await ctx.db
- .query("checklistProgress")
- .withIndex("by_checklist", (q) => q.eq("checklistId", checklist._id))
- .collect();
- for (const p of progress) {
- await ctx.db.delete(p._id);
- }
- await ctx.db.delete(checklist._id);
- cleaned.checklists++;
- }
- }
-
- // Clean up articles
- const articles = await ctx.db
- .query("articles")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- const demoCollectionIds = new Set>();
- for (const article of articles) {
- if (isLandingDemoArticle(article)) {
- if (article.collectionId) {
- demoCollectionIds.add(article.collectionId);
- }
- await ctx.db.delete(article._id);
- cleaned.articles++;
- }
- }
-
- // Clean up collections
- const collections = await ctx.db
- .query("collections")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const collection of collections) {
- if (
- collection.name.startsWith(LANDING_DEMO_PREFIX) ||
- collection.slug.endsWith(`-${LANDING_DEMO_SLUG_SUFFIX}`) ||
- demoCollectionIds.has(collection._id)
- ) {
- await ctx.db.delete(collection._id);
- cleaned.collections++;
- }
- }
-
- // Clean up outbound messages
- const outboundMessages = await ctx.db
- .query("outboundMessages")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const message of outboundMessages) {
- if (message.name.startsWith(LANDING_DEMO_PREFIX)) {
- const impressions = await ctx.db
- .query("outboundMessageImpressions")
- .withIndex("by_message", (q) => q.eq("messageId", message._id))
- .collect();
- for (const imp of impressions) {
- await ctx.db.delete(imp._id);
- }
- await ctx.db.delete(message._id);
- cleaned.outboundMessages++;
- }
- }
-
- // Clean up surveys
- const surveys = await ctx.db
- .query("surveys")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const survey of surveys) {
- if (survey.name.startsWith(LANDING_DEMO_PREFIX)) {
- const responses = await ctx.db
- .query("surveyResponses")
- .withIndex("by_survey", (q) => q.eq("surveyId", survey._id))
- .collect();
- for (const r of responses) {
- await ctx.db.delete(r._id);
- }
- const impressions = await ctx.db
- .query("surveyImpressions")
- .withIndex("by_survey", (q) => q.eq("surveyId", survey._id))
- .collect();
- for (const imp of impressions) {
- await ctx.db.delete(imp._id);
- }
- await ctx.db.delete(survey._id);
- cleaned.surveys++;
- }
- }
-
- // Clean up tooltips
- const tooltips = await ctx.db
- .query("tooltips")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const tooltip of tooltips) {
- if (tooltip.name.startsWith(LANDING_DEMO_PREFIX)) {
- await ctx.db.delete(tooltip._id);
- cleaned.tooltips++;
- }
- }
-
- return { success: true, cleaned };
- },
-});
-
-/**
- * Seeds curated demo content for the landing page workspace.
- * Idempotent — cleans up previous landing demo data before re-seeding.
- */
-export const seedLandingDemo = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- requireTestDataEnabled();
- const { workspaceId } = args;
- const now = Date.now();
- const DAY = 86400000;
-
- // ── Idempotent: clean up previous landing demo data ──────────
- const oldTours = await ctx.db
- .query("tours")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const tour of oldTours) {
- if (tour.name.startsWith(LANDING_DEMO_PREFIX)) {
- const steps = await ctx.db
- .query("tourSteps")
- .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
- .collect();
- for (const s of steps) await ctx.db.delete(s._id);
- const prog = await ctx.db
- .query("tourProgress")
- .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
- .collect();
- for (const p of prog) await ctx.db.delete(p._id);
- await ctx.db.delete(tour._id);
- }
- }
- const oldChecklists = await ctx.db
- .query("checklists")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const c of oldChecklists) {
- if (c.name.startsWith(LANDING_DEMO_PREFIX)) {
- const prog = await ctx.db
- .query("checklistProgress")
- .withIndex("by_checklist", (q) => q.eq("checklistId", c._id))
- .collect();
- for (const p of prog) await ctx.db.delete(p._id);
- await ctx.db.delete(c._id);
- }
- }
- const oldArticles = await ctx.db
- .query("articles")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- const oldDemoCollectionIds = new Set>();
- for (const a of oldArticles) {
- if (isLandingDemoArticle(a)) {
- if (a.collectionId) {
- oldDemoCollectionIds.add(a.collectionId);
- }
- await ctx.db.delete(a._id);
- }
- }
- const oldCollections = await ctx.db
- .query("collections")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const c of oldCollections) {
- if (
- c.name.startsWith(LANDING_DEMO_PREFIX) ||
- c.slug.endsWith(`-${LANDING_DEMO_SLUG_SUFFIX}`) ||
- oldDemoCollectionIds.has(c._id)
- ) {
- await ctx.db.delete(c._id);
- }
- }
- const oldOutbound = await ctx.db
- .query("outboundMessages")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const m of oldOutbound) {
- if (m.name.startsWith(LANDING_DEMO_PREFIX)) {
- const imps = await ctx.db
- .query("outboundMessageImpressions")
- .withIndex("by_message", (q) => q.eq("messageId", m._id))
- .collect();
- for (const i of imps) await ctx.db.delete(i._id);
- await ctx.db.delete(m._id);
- }
- }
- const oldSurveys = await ctx.db
- .query("surveys")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const s of oldSurveys) {
- if (s.name.startsWith(LANDING_DEMO_PREFIX)) {
- const resp = await ctx.db
- .query("surveyResponses")
- .withIndex("by_survey", (q) => q.eq("surveyId", s._id))
- .collect();
- for (const r of resp) await ctx.db.delete(r._id);
- const imps = await ctx.db
- .query("surveyImpressions")
- .withIndex("by_survey", (q) => q.eq("surveyId", s._id))
- .collect();
- for (const i of imps) await ctx.db.delete(i._id);
- await ctx.db.delete(s._id);
- }
- }
- const oldTooltips = await ctx.db
- .query("tooltips")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const t of oldTooltips) {
- if (t.name.startsWith(LANDING_DEMO_PREFIX)) await ctx.db.delete(t._id);
- }
-
- // ── Product Tour ─────────────────────────────────────────────
- const tourId = await ctx.db.insert("tours", {
- workspaceId,
- name: `${LANDING_DEMO_PREFIX}Landing Page Tour`,
- description: "Interactive tour of the Opencom landing page",
- status: "active",
- targetingRules: undefined,
- displayMode: "first_time_only",
- priority: 100,
- createdAt: now - 7 * DAY,
- updatedAt: now,
- });
-
- const tourSteps = [
- {
- type: "post" as const,
- title: "Welcome to Opencom!",
- content: "Let us give you a quick tour of the open-source customer messaging platform.",
- },
- {
- type: "pointer" as const,
- title: "Launch the Hosted Demo",
- content: "Start here to open a live Opencom workspace and explore the product in minutes.",
- elementSelector: "[data-tour-target='hero-primary-cta']",
- },
- {
- type: "pointer" as const,
- title: "Explore the Platform",
- content:
- "Shared inbox, product tours, tickets, outbound messages, and AI agent workflows run on one real-time stack.",
- elementSelector: "[data-tour-target='features-section']",
- },
- {
- type: "pointer" as const,
- title: "Native Product Tours",
- content:
- "Opencom tours attach to real UI elements, so onboarding remains fast and resilient as your app evolves.",
- elementSelector: "[data-tour-target='showcase-product-tour']",
- },
- {
- type: "pointer" as const,
- title: "Ready to Build",
- content:
- "Jump into the hosted onboarding flow and start shipping your own customer messaging stack.",
- elementSelector: "[data-tour-target='final-cta-primary']",
- },
- ];
-
- const stepIds: Id<"tourSteps">[] = [];
- for (let i = 0; i < tourSteps.length; i++) {
- const s = tourSteps[i];
- const stepId = await ctx.db.insert("tourSteps", {
- workspaceId: workspaceId,
- tourId,
- type: s.type,
- order: i,
- title: s.title,
- content: s.content,
- elementSelector: s.elementSelector,
- position: "auto",
- advanceOn: "click",
- createdAt: now - 7 * DAY,
- updatedAt: now,
- });
- stepIds.push(stepId);
- }
-
- // ── Checklist ────────────────────────────────────────────────
- const checklistId = await ctx.db.insert("checklists", {
- workspaceId,
- name: `${LANDING_DEMO_PREFIX}Explore Opencom`,
- description: "Discover the key features of the open-source customer messaging platform",
- tasks: [
- {
- id: "task_1",
- title: "Take the guided tour",
- description: "Walk through the landing page highlights",
- completionType: "manual",
- },
- {
- id: "task_2",
- title: "Browse the knowledge base",
- description: "Read help articles in the widget Help tab",
- completionType: "manual",
- },
- {
- id: "task_3",
- title: "Start a conversation",
- description: "Send a message through the chat widget",
- completionType: "manual",
- },
- {
- id: "task_4",
- title: "Check out the docs",
- description: "Visit the documentation to learn about deployment",
- completionType: "manual",
- },
- {
- id: "task_5",
- title: "Star us on GitHub",
- description: "Show your support by starring the repository",
- completionType: "manual",
- },
- ],
- status: "active",
- createdAt: now - 7 * DAY,
- updatedAt: now,
- });
-
- // ── Knowledge Base ───────────────────────────────────────────
- const repoDocsBase = "https://github.com/opencom-org/opencom/blob/main";
- const docsBase = `${repoDocsBase}/docs`;
- const ossDocsBase = `${docsBase}/open-source`;
- const collectionsData = [
- {
- name: "Hosted Onboarding",
- desc: "Fastest path to evaluate Opencom before running your own backend",
- articles: [
- {
- title: "Hosted Quick Start",
- content: `# Hosted Quick Start
-
-Hosted mode is the fastest way to evaluate Opencom without managing infrastructure first.
-
-## Recommended path
-1. Sign up at https://app.opencom.dev and create a workspace.
-2. Invite teammates and verify inbox access.
-3. Copy the widget snippet from Settings -> Widget Installation.
-4. Add the snippet to your site and confirm the launcher opens.
-5. Validate core flows: conversations, help center, tours, outbound, and surveys.
-
-## When to move off hosted
-Switch to your own backend when you need stricter data controls, custom deployment workflows, or isolated environments.
-
-## Canonical docs
-- [Setup and deployment guide](${ossDocsBase}/setup-self-host-and-deploy.md)
-- [README deployment options](${repoDocsBase}/README.md#deployment-options)`,
- },
- {
- title: "Hosted Workspace Setup Checklist",
- content: `# Hosted Workspace Setup Checklist
-
-Use this checklist after creating your workspace:
-
-1. Configure workspace profile and teammate access.
-2. Review Signup Settings and authentication methods.
-3. Configure Security settings: allowed origins and identity verification mode.
-4. Install the widget and run a test conversation.
-5. Publish at least one help center collection for self service support.
-
-## Canonical docs
-- [Root README workspace and auth settings](${repoDocsBase}/README.md#workspace-settings)
-- [Security reference](${docsBase}/security.md)
-- [Widget SDK reference](${docsBase}/widget-sdk.md)`,
- },
- {
- title: "Move from Hosted to Custom Backend",
- content: `# Move from Hosted to Custom Backend
-
-You can start hosted and then migrate to your own Convex backend.
-
-## Migration outline
-1. Deploy packages/convex to your Convex project.
-2. Configure required backend environment variables.
-3. Connect web and mobile apps to your backend URL.
-4. Reinstall your site widget with your backend URL and workspace ID.
-5. Re-test identity verification, events, and messaging workflows.
-
-## Canonical docs
-- [Setup and self host guide](${ossDocsBase}/setup-self-host-and-deploy.md)
-- [Connecting to a self hosted backend](${repoDocsBase}/README.md#connecting-to-a-self-hosted-backend)
-- [Security and operations](${ossDocsBase}/security-and-operations.md)`,
- },
- {
- title: "Hosted FAQs and Next Steps",
- content: `# Hosted FAQs and Next Steps
-
-## Common questions
-- Where should I start? Hosted onboarding is best for fast evaluation.
-- Can I self host later? Yes. Deployment profiles support gradual migration.
-- Where do I find implementation docs? GitHub docs are the source of truth.
-- Where can I ask product and setup questions? Use GitHub Discussions.
-
-## Next steps
-1. Choose a deployment profile.
-2. Complete security setup before production traffic.
-3. Run the verification checklist before launch.
-
-## Canonical docs
-- [OSS docs hub](${ossDocsBase}/README.md)
-- [Testing and verification](${ossDocsBase}/testing-and-verification.md)
-- [GitHub discussions](https://github.com/opencom-org/opencom/discussions)`,
- },
- ],
- },
- {
- name: "Self Hosting and Deployment",
- desc: "Canonical setup and deployment paths for custom infrastructure",
- articles: [
- {
- title: "Self Host Fast Path",
- content: `# Self Host Fast Path
-
-The quickest self hosted setup uses scripts/setup.sh.
-
-## Prerequisites
-- Node.js 18+
-- PNPM 9+
-- Convex account
-
-## Fast path flow
-1. Clone the repo.
-2. Run scripts/setup.sh.
-3. Complete prompts for auth and workspace setup.
-4. Start local apps and verify widget connectivity.
-
-## Canonical docs
-- [Setup and self host guide](${ossDocsBase}/setup-self-host-and-deploy.md)
-- [Root README quick start](${repoDocsBase}/README.md#quick-start-self-hosters)
-- [Scripts reference](${docsBase}/scripts-reference.md)`,
- },
- {
- title: "Manual Setup and Local Development",
- content: `# Manual Setup and Local Development
-
-Use the manual path if you want full control over each setup step.
-
-## Typical sequence
-1. Install dependencies at repo root.
-2. Start Convex from packages/convex.
-3. Start web and widget apps.
-4. Optionally run landing and mobile apps.
-
-## Why use manual setup
-- Better visibility into environment wiring.
-- Easier to debug auth and configuration issues.
-- Useful for advanced CI or custom deployment pipelines.
-
-## Canonical docs
-- [Manual setup flow](${ossDocsBase}/setup-self-host-and-deploy.md#manual-setup-step-by-step)
-- [Architecture and repo map](${ossDocsBase}/architecture-and-repo-map.md)
-- [Testing guide](${docsBase}/testing.md)`,
- },
- {
- title: "Deployment Profiles Explained",
- content: `# Deployment Profiles Explained
-
-Opencom supports multiple deployment profiles:
-
-1. Hosted apps plus custom backend.
-2. Self hosted web plus custom backend.
-3. Full self host of apps plus backend.
-4. Optional widget CDN publishing workflow.
-
-Choose based on infrastructure ownership, compliance needs, and release control.
-
-## Canonical docs
-- [Deployment profiles](${ossDocsBase}/setup-self-host-and-deploy.md#deployment-profiles)
-- [Architecture deployment topology](${docsBase}/architecture.md#deployment-topology)
-- [Root README deployment options](${repoDocsBase}/README.md#deployment-options)`,
- },
- {
- title: "Environment Variables by Surface",
- content: `# Environment Variables by Surface
-
-The most important variables are grouped by runtime surface:
-
-- Convex backend: auth, email, security, CORS, AI, and test-data gates.
-- Web app: default backend URL and widget demo overrides.
-- Mobile app: default backend URL for operator workflows.
-- Landing app: widget URL and workspace-specific demo wiring.
-- Widget app: local development convex URL and workspace ID.
-
-Set secrets in deployment environments and never commit them to source control.
-
-## Canonical docs
-- [Environment variable matrix](${ossDocsBase}/setup-self-host-and-deploy.md#environment-variables)
-- [Security critical variables](${docsBase}/security.md#security-critical-env-vars)
-- [Root README env reference](${repoDocsBase}/README.md#environment-variables-reference)`,
- },
- ],
- },
- {
- name: "Widget Integration",
- desc: "Install, configure, and harden the website widget and help center",
- articles: [
- {
- title: "Widget Installation Patterns",
- content: `# Widget Installation Patterns
-
-Opencom supports declarative script-tag install and manual SDK initialization.
-
-## Common patterns
-1. Static or multi page websites.
-2. SPA frameworks that load script once at app boot.
-3. Next.js App Router integration using runtime environment variables.
-4. Consent managed script injection after user opt-in.
-5. Self hosted widget loader URL for infrastructure ownership.
-
-## Canonical docs
-- [Widget SDK installation and scenarios](${docsBase}/widget-sdk.md)
-- [README widget installation](${repoDocsBase}/README.md#widget-installation)`,
- },
- {
- title: "Identify Users and Track Events",
- content: `# Identify Users and Track Events
-
-Call identify after login so conversations and history map to known users.
-
-Track product events to power targeting, automation, and reporting.
-
-Recommended event model:
-- stable event names
-- consistent property shapes
-- clear ownership between frontend and backend teams
-
-## Canonical docs
-- [Widget identify and track APIs](${docsBase}/widget-sdk.md#api-reference)
-- [Backend events and analytics API](${docsBase}/api-reference.md)
-- [Data model events table](${docsBase}/data-model.md)`,
- },
- {
- title: "Identity Verification with HMAC",
- content: `# Identity Verification with HMAC
-
-Identity verification prevents impersonation by requiring a server generated hash for identified users.
-
-## Implementation outline
-1. Enable identity verification in workspace security settings.
-2. Generate user hash on your server using the shared secret.
-3. Pass userHash when identifying users in the widget or SDK.
-4. Choose optional vs required verification mode.
-
-## Canonical docs
-- [Security identity verification guide](${docsBase}/security.md#identity-verification-hmac)
-- [Widget identity verification section](${docsBase}/widget-sdk.md#identity-verification)
-- [Mobile SDK identity verification](${docsBase}/mobile-sdks.md#identity-verification)`,
- },
- {
- title: "Widget Troubleshooting Checklist",
- content: `# Widget Troubleshooting Checklist
-
-If the widget is not behaving as expected, check:
-
-1. convexUrl and workspaceId values.
-2. Allowed origins and CSP directives.
-3. Session and identity verification state.
-4. Script load timing in your framework lifecycle.
-5. Network access to your Convex deployment.
-
-## Canonical docs
-- [Widget troubleshooting](${docsBase}/widget-sdk.md#troubleshooting)
-- [Security CORS guidance](${docsBase}/security.md#cors-configuration)
-- [Setup common failures](${ossDocsBase}/setup-self-host-and-deploy.md#common-setup-failures)`,
- },
- ],
- },
- {
- name: "Product and Engagement Guides",
- desc: "Practical guidance for inbox, help center, campaigns, and automation",
- articles: [
- {
- title: "Conversation and Inbox Workflow",
- content: `# Conversation and Inbox Workflow
-
-Opencom inbox operations center on conversation ownership, response speed, and clean routing.
-
-## Core practices
-1. Assign and triage quickly.
-2. Use snippets and tags for repeatable responses.
-3. Monitor unread and SLA indicators.
-4. Apply role based permissions for team safety.
-
-## Canonical docs
-- [Backend API conversations and messages](${docsBase}/api-reference.md)
-- [Architecture visitor interaction flow](${docsBase}/architecture.md#data-flow)
-- [Security authorization model](${docsBase}/security.md#authorization-model)`,
- },
- {
- title: "Help Center and Article Strategy",
- content: `# Help Center and Article Strategy
-
-A useful help center balances findability and depth.
-
-## Recommended structure
-1. Separate hosted onboarding from self hosting.
-2. Group articles by implementation phase, not internal teams.
-3. Keep short operational checklists in each article.
-4. Link each article to canonical source documents.
-5. Publish only reviewed content and keep drafts private.
-
-## Canonical docs
-- [API reference for articles and collections](${docsBase}/api-reference.md)
-- [Data model help center tables](${docsBase}/data-model.md#help-center-tables)
-- [Documentation source of truth contract](${ossDocsBase}/source-of-truth.md)`,
- },
- {
- title: "Tours Surveys Outbound and Checklists",
- content: `# Tours Surveys Outbound and Checklists
-
-Use engagement features together, not in isolation.
-
-## Suggested lifecycle
-1. Product tour to onboard first time users.
-2. Outbound message for contextual prompts.
-3. Survey for product or support feedback.
-4. Checklist for adoption milestones.
-
-Use targeting rules and frequency controls to avoid fatigue.
-
-## Canonical docs
-- [API reference for tours surveys outbound and checklists](${docsBase}/api-reference.md)
-- [Data model engagement tables](${docsBase}/data-model.md)
-- [Architecture campaign delivery flow](${docsBase}/architecture.md#data-flow)`,
- },
- {
- title: "Tickets Segments and Automation Basics",
- content: `# Tickets Segments and Automation Basics
-
-Ticket workflows pair well with segmentation and automation settings.
-
-## Foundations
-1. Define ticket forms for consistent intake.
-2. Build segments from visitor and event attributes.
-3. Use assignment and notification rules for routing.
-4. Track outcomes in reporting snapshots.
-
-## Canonical docs
-- [API reference tickets segments automation](${docsBase}/api-reference.md)
-- [Data model ticket and automation tables](${docsBase}/data-model.md)
-- [Architecture integration boundaries](${ossDocsBase}/architecture-and-repo-map.md)`,
- },
- ],
- },
- {
- name: "SDKs and API",
- desc: "Implementation paths for backend APIs and mobile SDK surfaces",
- articles: [
- {
- title: "Backend API Surface Overview",
- content: `# Backend API Surface Overview
-
-The backend exposes modules for conversations, content, campaigns, automation, reporting, and AI features.
-
-## Start here
-1. Identify the table or workflow you need.
-2. Map it to the corresponding API module.
-3. Validate permissions and workspace boundaries before integrating.
-
-## Canonical docs
-- [Backend API reference](${docsBase}/api-reference.md)
-- [Architecture and repository map](${ossDocsBase}/architecture-and-repo-map.md)
-- [Data model reference](${docsBase}/data-model.md)`,
- },
- {
- title: "React Native SDK Quick Start",
- content: `# React Native SDK Quick Start
-
-The React Native SDK provides a full messaging surface with hooks and components.
-
-## Typical flow
-1. Install the package.
-2. Wrap app with OpencomProvider.
-3. Initialize SDK with workspaceId and convexUrl.
-4. Identify logged in users.
-5. Register push tokens when needed.
-
-## Canonical docs
-- [Mobile SDK reference React Native section](${docsBase}/mobile-sdks.md#react-native-sdk)
-- [React Native SDK package README](${repoDocsBase}/packages/react-native-sdk/README.md)
-- [Push architecture](${docsBase}/mobile-sdks.md#push-notification-architecture)`,
- },
- {
- title: "iOS and Android SDK Quick Start",
- content: `# iOS and Android SDK Quick Start
-
-Opencom ships native SDKs for Swift and Kotlin.
-
-## Shared flow
-1. Initialize with workspaceId and convexUrl.
-2. Identify users after login.
-3. Track events for analytics and targeting.
-4. Present messenger or help center UI.
-5. Register push tokens with platform transport credentials.
-
-## Canonical docs
-- [Mobile SDK reference iOS and Android](${docsBase}/mobile-sdks.md)
-- [iOS SDK README](${repoDocsBase}/packages/ios-sdk/README.md)
-- [Android SDK README](${repoDocsBase}/packages/android-sdk/README.md)`,
- },
- {
- title: "Data Model for Integrations",
- content: `# Data Model for Integrations
-
-Use the data model reference when designing analytics exports, integrations, or migration tooling.
-
-## Priority tables to understand
-1. visitors and widgetSessions
-2. conversations and messages
-3. collections and articles
-4. campaigns and notification delivery
-5. automation and audit logs
-
-## Canonical docs
-- [Data model reference](${docsBase}/data-model.md)
-- [API module map](${docsBase}/api-reference.md)
-- [Architecture overview](${docsBase}/architecture.md)`,
- },
- ],
- },
- {
- name: "Security Testing and Operations",
- desc: "Production hardening, verification workflows, and operational readiness",
- articles: [
- {
- title: "Security Boundaries and Authorization",
- content: `# Security Boundaries and Authorization
-
-Opencom enforces separate trust boundaries for agents and visitors.
-
-## Key controls
-1. Role and permission checks for agent actions.
-2. Signed visitor sessions for visitor facing APIs.
-3. Workspace isolation across all core resources.
-4. Audit log coverage for high risk actions.
-
-## Canonical docs
-- [Platform security guide](${docsBase}/security.md)
-- [Security and operations guide](${ossDocsBase}/security-and-operations.md)
-- [Architecture authorization model](${docsBase}/architecture.md#authorization-model)`,
- },
- {
- title: "Webhook CORS and Discovery Route Security",
- content: `# Webhook CORS and Discovery Route Security
-
-Production deployments should harden both inbound webhooks and public metadata routes.
-
-## Must-have controls
-1. Verify webhook signatures.
-2. Keep signature enforcement fail closed.
-3. Configure explicit CORS origins for public discovery.
-4. Keep test data gateways disabled outside test deployments.
-
-## Canonical docs
-- [Webhook security details](${docsBase}/security.md#webhook-security)
-- [CORS and discovery guidance](${ossDocsBase}/security-and-operations.md#cors-and-public-discovery-route)
-- [Setup env variable requirements](${ossDocsBase}/setup-self-host-and-deploy.md#environment-variables)`,
- },
- {
- title: "Testing Workflow from Local to CI",
- content: `# Testing Workflow from Local to CI
-
-Use focused checks first, then run broader verification before merge or release.
-
-## Practical sequence
-1. Run package-level typecheck and tests for touched areas.
-2. Run targeted E2E specs when behavior spans app boundaries.
-3. Run CI-equivalent lint, typecheck, security gates, convex tests, and web E2E.
-4. Capture failures with reliability tooling before retries.
-
-## Canonical docs
-- [Testing and verification guide](${ossDocsBase}/testing-and-verification.md)
-- [Detailed testing guide](${docsBase}/testing.md)
-- [Scripts reference for test utilities](${docsBase}/scripts-reference.md)`,
- },
- {
- title: "Release Verification and Incident Readiness",
- content: `# Release Verification and Incident Readiness
-
-Release readiness combines functional quality checks with security and operational validation.
-
-## Release baseline
-1. Lint and typecheck.
-2. Security gate scripts.
-3. Convex package tests and web E2E.
-4. Review incident and vulnerability reporting workflow.
-
-## Incident readiness
-- Ensure auditability for critical events.
-- Keep rollback and communication paths documented.
-
-## Canonical docs
-- [Security and operations release baseline](${ossDocsBase}/security-and-operations.md)
-- [Source of truth contract](${ossDocsBase}/source-of-truth.md)
-- [Repository security policy](${repoDocsBase}/SECURITY.md)`,
- },
- ],
- },
- ];
-
- const collectionIds: Id<"collections">[] = [];
- const articleIds: Id<"articles">[] = [];
- for (let ci = 0; ci < collectionsData.length; ci++) {
- const c = collectionsData[ci];
- const slug = `${c.name.toLowerCase().replace(/\s+/g, "-")}-${LANDING_DEMO_SLUG_SUFFIX}`;
- const collectionId = await ctx.db.insert("collections", {
- workspaceId,
- name: c.name,
- slug,
- description: c.desc,
- order: ci,
- createdAt: now - 14 * DAY,
- updatedAt: now,
- });
- collectionIds.push(collectionId);
-
- for (let ai = 0; ai < c.articles.length; ai++) {
- const a = c.articles[ai];
- const articleId = await ctx.db.insert("articles", {
- workspaceId,
- collectionId,
- title: a.title,
- slug: `${a.title.toLowerCase().replace(/\s+/g, "-")}-${ci}-${ai}-${LANDING_DEMO_SLUG_SUFFIX}`.toLowerCase(),
- content: `${a.content}${LANDING_DEMO_ARTICLE_CONTENT_SUFFIX}`,
- status: "published",
- order: ai,
- createdAt: now - 14 * DAY + ai * DAY,
- updatedAt: now,
- publishedAt: now - 14 * DAY + ai * DAY,
- });
- articleIds.push(articleId);
- }
- }
-
- // ── Outbound Messages ────────────────────────────────────────
- const postMessageId = await ctx.db.insert("outboundMessages", {
- workspaceId,
- name: `${LANDING_DEMO_PREFIX}Welcome Post`,
- type: "post",
- content: {
- title: "Welcome to Opencom!",
- body: "The open-source customer messaging platform. Explore live chat, product tours, surveys, and a full knowledge base — all self-hosted.",
- buttons: [
- { text: "Start a Conversation", action: "open_new_conversation" as const },
- { text: "Dismiss", action: "dismiss" as const },
- ],
- clickAction: {
- type: "open_new_conversation" as const,
- },
- },
- status: "active",
- triggers: { type: "time_on_page", delaySeconds: 10 },
- frequency: "once",
- priority: 100,
- createdAt: now - 7 * DAY,
- updatedAt: now,
- });
-
- const bannerMessageId = await ctx.db.insert("outboundMessages", {
- workspaceId,
- name: `${LANDING_DEMO_PREFIX}Docs Banner`,
- type: "banner",
- content: {
- text: "Read the docs to deploy Opencom on your own infrastructure in minutes.",
- style: "floating" as const,
- dismissible: true,
- buttons: [{ text: "View Docs", action: "url" as const, url: "https://opencom.dev/docs" }],
- clickAction: {
- type: "open_url" as const,
- url: "https://opencom.dev/docs",
- },
- },
- status: "active",
- triggers: { type: "time_on_page", delaySeconds: 30 },
- frequency: "once",
- priority: 90,
- createdAt: now - 7 * DAY,
- updatedAt: now,
- });
-
- // ── Survey ───────────────────────────────────────────────────
- const surveyId = await ctx.db.insert("surveys", {
- workspaceId,
- name: `${LANDING_DEMO_PREFIX}Landing NPS`,
- description: "Quick NPS survey for landing page visitors",
- format: "small",
- status: "active",
- questions: [
- {
- id: "q_landing_nps",
- type: "nps",
- title: "How likely are you to recommend Opencom to a colleague?",
- required: true,
- },
- ],
- thankYouStep: {
- title: "Thank you!",
- description: "Your feedback helps us improve Opencom.",
- },
- triggers: { type: "time_on_page", delaySeconds: 60 },
- frequency: "once",
- createdAt: now - 7 * DAY,
- updatedAt: now,
- });
-
- // ── Tooltips ─────────────────────────────────────────────────
- const tooltipData = [
- {
- name: "Hero CTA Tooltip",
- selector: "[data-tour-target='hero-primary-cta']",
- content: "Open the hosted onboarding flow to get a live Opencom workspace running quickly.",
- },
- {
- name: "Tour Showcase Tooltip",
- selector: "[data-tour-target='showcase-product-tour']",
- content:
- "Preview how native product tours look when attached directly to your app interface.",
- },
- {
- name: "GitHub Nav Tooltip",
- selector: "[data-tour-target='nav-github']",
- content: "Star us on GitHub to show your support and stay updated on new releases.",
- },
- ];
-
- const tooltipIds: Id<"tooltips">[] = [];
- for (const t of tooltipData) {
- const tooltipId = await ctx.db.insert("tooltips", {
- workspaceId,
- name: `${LANDING_DEMO_PREFIX}${t.name}`,
- elementSelector: t.selector,
- content: t.content,
- triggerType: "hover",
- createdAt: now - 7 * DAY,
- updatedAt: now,
- });
- tooltipIds.push(tooltipId);
- }
-
- // ── Messenger Settings ───────────────────────────────────────
- const existingSettings = await ctx.db
- .query("messengerSettings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .first();
-
- if (existingSettings) {
- await ctx.db.patch(existingSettings._id, {
- primaryColor: "#792cd4",
- backgroundColor: "#792cd4",
- welcomeMessage:
- "Hey there! Welcome to Opencom — the open-source customer messaging platform. Ask us anything or explore the widget features.",
- launcherPosition: "right",
- updatedAt: now,
- });
- } else {
- await ctx.db.insert("messengerSettings", {
- workspaceId,
- primaryColor: "#792cd4",
- backgroundColor: "#792cd4",
- themeMode: "light",
- launcherPosition: "right",
- launcherSideSpacing: 20,
- launcherBottomSpacing: 20,
- showLauncher: true,
- welcomeMessage:
- "Hey there! Welcome to Opencom — the open-source customer messaging platform. Ask us anything or explore the widget features.",
- showTeammateAvatars: true,
- supportedLanguages: ["en"],
- defaultLanguage: "en",
- mobileEnabled: true,
- createdAt: now,
- updatedAt: now,
- });
- }
-
- return {
- tourId,
- tourSteps: stepIds.length,
- checklistId,
- collections: collectionIds.length,
- articles: articleIds.length,
- outboundMessages: { postMessageId, bannerMessageId },
- surveyId,
- tooltips: tooltipIds.length,
- };
- },
-});
+import { seedMutations } from "./testData/seeds";
+import { cleanupMutations } from "./testData/cleanup";
+import { demoWorkspaceMutations } from "./testData/demoWorkspace";
+import { landingDemoMutations } from "./testData/landing";
+
+export const seedTour: ReturnType = seedMutations.seedTour;
+export const seedSurvey: ReturnType = seedMutations.seedSurvey;
+export const seedCarousel: ReturnType = seedMutations.seedCarousel;
+export const seedOutboundMessage: ReturnType = seedMutations.seedOutboundMessage;
+export const seedArticles: ReturnType = seedMutations.seedArticles;
+export const seedVisitor: ReturnType = seedMutations.seedVisitor;
+export const seedSegment: ReturnType = seedMutations.seedSegment;
+export const seedMessengerSettings: ReturnType = seedMutations.seedMessengerSettings;
+export const seedAIAgentSettings: ReturnType = seedMutations.seedAIAgentSettings;
+export const cleanupTestData: ReturnType = cleanupMutations.cleanupTestData;
+export const seedAll: ReturnType = cleanupMutations.seedAll;
+export const cleanupAll: ReturnType = cleanupMutations.cleanupAll;
+export const clearAllTours: ReturnType = cleanupMutations.clearAllTours;
+export const seedDemoData: ReturnType = demoWorkspaceMutations.seedDemoData;
+export const getTourCount: ReturnType = cleanupMutations.getTourCount;
+export const cleanupLandingDemo: ReturnType = landingDemoMutations.cleanupLandingDemo;
+export const seedLandingDemo: ReturnType = landingDemoMutations.seedLandingDemo;
diff --git a/packages/convex/convex/testData/README.md b/packages/convex/convex/testData/README.md
new file mode 100644
index 0000000..9f2d209
--- /dev/null
+++ b/packages/convex/convex/testData/README.md
@@ -0,0 +1,14 @@
+# Convex Test Data Modules
+
+This directory owns internal test-data seed and cleanup definitions grouped by fixture domain.
+
+## Ownership
+
+- `seeds.ts`: Focused single-feature E2E seed helpers (tour/survey/carousel/outbound/articles/visitor/segment/settings).
+- `cleanup.ts`: E2E-prefixed cleanup flows plus aggregate seed/cleanup helpers.
+- `demoWorkspace.ts`: Full workspace demo seeding for screenshot and high-fidelity fixture flows.
+- `landing.ts`: Landing-page-specific demo cleanup and seeding flows.
+
+## Compatibility
+
+`../testData.ts` remains the compatibility entrypoint for `api.testData.*` while this folder owns module-local fixture definitions.
diff --git a/packages/convex/convex/testData/cleanup.ts b/packages/convex/convex/testData/cleanup.ts
new file mode 100644
index 0000000..40aa9a5
--- /dev/null
+++ b/packages/convex/convex/testData/cleanup.ts
@@ -0,0 +1,644 @@
+import { internalMutation } from "../_generated/server";
+import { v } from "convex/values";
+import { formatReadableVisitorId } from "../visitorReadableId";
+
+const E2E_TEST_PREFIX = "e2e_test_";
+
+function requireTestDataEnabled() {
+ if (process.env.ALLOW_TEST_DATA !== "true") {
+ throw new Error("Test data mutations are disabled");
+ }
+}
+
+const cleanupTestData = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const { workspaceId } = args;
+ let cleaned = {
+ tours: 0,
+ tourSteps: 0,
+ tourProgress: 0,
+ surveys: 0,
+ surveyResponses: 0,
+ surveyImpressions: 0,
+ carousels: 0,
+ carouselImpressions: 0,
+ outboundMessages: 0,
+ outboundMessageImpressions: 0,
+ articles: 0,
+ collections: 0,
+ segments: 0,
+ visitors: 0,
+ checklists: 0,
+ tooltips: 0,
+ snippets: 0,
+ emailCampaigns: 0,
+ tickets: 0,
+ };
+
+ // Clean up tours and related data
+ const tours = await ctx.db
+ .query("tours")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const tour of tours) {
+ if (tour.name.startsWith(E2E_TEST_PREFIX)) {
+ // Delete tour steps
+ const steps = await ctx.db
+ .query("tourSteps")
+ .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
+ .collect();
+ for (const step of steps) {
+ await ctx.db.delete(step._id);
+ cleaned.tourSteps++;
+ }
+
+ // Delete tour progress
+ const progress = await ctx.db
+ .query("tourProgress")
+ .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
+ .collect();
+ for (const p of progress) {
+ await ctx.db.delete(p._id);
+ cleaned.tourProgress++;
+ }
+
+ await ctx.db.delete(tour._id);
+ cleaned.tours++;
+ }
+ }
+
+ // Clean up surveys and related data
+ const surveys = await ctx.db
+ .query("surveys")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const survey of surveys) {
+ if (survey.name.startsWith(E2E_TEST_PREFIX)) {
+ // Delete survey responses
+ const responses = await ctx.db
+ .query("surveyResponses")
+ .withIndex("by_survey", (q) => q.eq("surveyId", survey._id))
+ .collect();
+ for (const response of responses) {
+ await ctx.db.delete(response._id);
+ cleaned.surveyResponses++;
+ }
+
+ // Delete survey impressions
+ const impressions = await ctx.db
+ .query("surveyImpressions")
+ .withIndex("by_survey", (q) => q.eq("surveyId", survey._id))
+ .collect();
+ for (const impression of impressions) {
+ await ctx.db.delete(impression._id);
+ cleaned.surveyImpressions++;
+ }
+
+ await ctx.db.delete(survey._id);
+ cleaned.surveys++;
+ }
+ }
+
+ // Clean up carousels and related data
+ const carousels = await ctx.db
+ .query("carousels")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const carousel of carousels) {
+ if (carousel.name.startsWith(E2E_TEST_PREFIX)) {
+ // Delete carousel impressions
+ const impressions = await ctx.db
+ .query("carouselImpressions")
+ .withIndex("by_carousel", (q) => q.eq("carouselId", carousel._id))
+ .collect();
+ for (const impression of impressions) {
+ await ctx.db.delete(impression._id);
+ cleaned.carouselImpressions++;
+ }
+
+ await ctx.db.delete(carousel._id);
+ cleaned.carousels++;
+ }
+ }
+
+ // Clean up outbound messages and related data
+ const outboundMessages = await ctx.db
+ .query("outboundMessages")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const message of outboundMessages) {
+ if (message.name.startsWith(E2E_TEST_PREFIX)) {
+ // Delete message impressions
+ const impressions = await ctx.db
+ .query("outboundMessageImpressions")
+ .withIndex("by_message", (q) => q.eq("messageId", message._id))
+ .collect();
+ for (const impression of impressions) {
+ await ctx.db.delete(impression._id);
+ cleaned.outboundMessageImpressions++;
+ }
+
+ await ctx.db.delete(message._id);
+ cleaned.outboundMessages++;
+ }
+ }
+
+ // Clean up articles
+ const articles = await ctx.db
+ .query("articles")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const article of articles) {
+ if (article.title.startsWith(E2E_TEST_PREFIX) || article.slug.startsWith(E2E_TEST_PREFIX)) {
+ await ctx.db.delete(article._id);
+ cleaned.articles++;
+ }
+ }
+
+ // Clean up collections
+ const collections = await ctx.db
+ .query("collections")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const collection of collections) {
+ if (
+ collection.name.startsWith(E2E_TEST_PREFIX) ||
+ collection.slug.startsWith(E2E_TEST_PREFIX)
+ ) {
+ await ctx.db.delete(collection._id);
+ cleaned.collections++;
+ }
+ }
+
+ // Clean up segments
+ const segments = await ctx.db
+ .query("segments")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const segment of segments) {
+ if (segment.name.startsWith(E2E_TEST_PREFIX)) {
+ await ctx.db.delete(segment._id);
+ cleaned.segments++;
+ }
+ }
+
+ // Clean up checklists
+ const checklists = await ctx.db
+ .query("checklists")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const checklist of checklists) {
+ if (checklist.name.startsWith(E2E_TEST_PREFIX)) {
+ const progress = await ctx.db
+ .query("checklistProgress")
+ .withIndex("by_checklist", (q) => q.eq("checklistId", checklist._id))
+ .collect();
+ for (const p of progress) {
+ await ctx.db.delete(p._id);
+ }
+ await ctx.db.delete(checklist._id);
+ cleaned.checklists++;
+ }
+ }
+
+ // Clean up tooltips
+ const tooltips = await ctx.db
+ .query("tooltips")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const tooltip of tooltips) {
+ if (tooltip.name.startsWith(E2E_TEST_PREFIX)) {
+ await ctx.db.delete(tooltip._id);
+ cleaned.tooltips++;
+ }
+ }
+
+ // Clean up snippets
+ const snippets = await ctx.db
+ .query("snippets")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const snippet of snippets) {
+ if (snippet.name.startsWith(E2E_TEST_PREFIX)) {
+ await ctx.db.delete(snippet._id);
+ cleaned.snippets++;
+ }
+ }
+
+ // Clean up email campaigns
+ const emailCampaigns = await ctx.db
+ .query("emailCampaigns")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const campaign of emailCampaigns) {
+ if (campaign.name.startsWith(E2E_TEST_PREFIX)) {
+ await ctx.db.delete(campaign._id);
+ cleaned.emailCampaigns++;
+ }
+ }
+
+ // Clean up tickets
+ const tickets = await ctx.db
+ .query("tickets")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const ticket of tickets) {
+ if (ticket.subject.startsWith(E2E_TEST_PREFIX)) {
+ const comments = await ctx.db
+ .query("ticketComments")
+ .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
+ .collect();
+ for (const comment of comments) {
+ await ctx.db.delete(comment._id);
+ }
+ await ctx.db.delete(ticket._id);
+ cleaned.tickets++;
+ }
+ }
+
+ // Clean up visitors with test prefix in session or email
+ const visitors = await ctx.db
+ .query("visitors")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const visitor of visitors) {
+ if (
+ visitor.sessionId.startsWith(E2E_TEST_PREFIX) ||
+ (visitor.email && visitor.email.startsWith(E2E_TEST_PREFIX))
+ ) {
+ // Delete visitor events
+ const events = await ctx.db
+ .query("events")
+ .withIndex("by_visitor", (q) => q.eq("visitorId", visitor._id))
+ .collect();
+ for (const event of events) {
+ await ctx.db.delete(event._id);
+ }
+
+ // Delete visitor conversations
+ const conversations = await ctx.db
+ .query("conversations")
+ .withIndex("by_visitor", (q) => q.eq("visitorId", visitor._id))
+ .collect();
+ for (const conversation of conversations) {
+ const messages = await ctx.db
+ .query("messages")
+ .withIndex("by_conversation", (q) => q.eq("conversationId", conversation._id))
+ .collect();
+ for (const message of messages) {
+ await ctx.db.delete(message._id);
+ }
+ await ctx.db.delete(conversation._id);
+ }
+
+ await ctx.db.delete(visitor._id);
+ cleaned.visitors++;
+ }
+ }
+
+ return { success: true, cleaned };
+ },
+});
+
+/**
+ * Seeds all test data at once for a complete E2E test setup.
+ */
+const seedAll = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const { workspaceId } = args;
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+
+ // Create a test visitor
+ const visitorSessionId = `${E2E_TEST_PREFIX}session_${timestamp}_${randomSuffix}`;
+ const visitorId = await ctx.db.insert("visitors", {
+ sessionId: visitorSessionId,
+ workspaceId,
+ email: `${E2E_TEST_PREFIX}visitor_${randomSuffix}@test.opencom.dev`,
+ name: `E2E Test Visitor`,
+ customAttributes: { plan: "pro", signupDate: new Date().toISOString() },
+ firstSeenAt: timestamp,
+ lastSeenAt: timestamp,
+ createdAt: timestamp,
+ });
+
+ await ctx.db.patch(visitorId, {
+ readableId: formatReadableVisitorId(visitorId),
+ });
+
+ // Create a test tour
+ const tourName = `${E2E_TEST_PREFIX}tour_${randomSuffix}`;
+ const tourId = await ctx.db.insert("tours", {
+ workspaceId,
+ name: tourName,
+ description: "E2E test tour",
+ status: "active",
+ displayMode: "first_time_only",
+ priority: 100,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ await ctx.db.insert("tourSteps", {
+ workspaceId,
+ tourId,
+ type: "post",
+ order: 0,
+ title: "Welcome",
+ content: "Welcome to the test tour!",
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ // Create a test survey
+ const surveyName = `${E2E_TEST_PREFIX}survey_${randomSuffix}`;
+ const surveyId = await ctx.db.insert("surveys", {
+ workspaceId,
+ name: surveyName,
+ format: "small",
+ status: "active",
+ questions: [
+ {
+ id: `q_${randomSuffix}`,
+ type: "nps",
+ title: "How likely are you to recommend us?",
+ required: true,
+ },
+ ],
+ thankYouStep: {
+ title: "Thank you!",
+ description: "Your feedback is appreciated.",
+ },
+ triggers: { type: "immediate" },
+ frequency: "once",
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ // Create test articles
+ const collectionName = `${E2E_TEST_PREFIX}collection_${randomSuffix}`;
+ const collectionId = await ctx.db.insert("collections", {
+ workspaceId,
+ name: collectionName,
+ slug: collectionName.toLowerCase().replace(/\s+/g, "-"),
+ description: "E2E test collection",
+ order: 0,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ const articleId = await ctx.db.insert("articles", {
+ workspaceId,
+ collectionId,
+ title: `${E2E_TEST_PREFIX}Getting Started`,
+ slug: `${E2E_TEST_PREFIX}getting-started-${randomSuffix}`,
+ content: "# Getting Started\n\nWelcome to our platform!",
+ status: "published",
+ order: 0,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ publishedAt: timestamp,
+ });
+
+ // Create test segment
+ const segmentName = `${E2E_TEST_PREFIX}segment_${randomSuffix}`;
+ const segmentId = await ctx.db.insert("segments", {
+ workspaceId,
+ name: segmentName,
+ description: "E2E test segment",
+ audienceRules: {
+ type: "group" as const,
+ operator: "and" as const,
+ conditions: [
+ {
+ type: "condition" as const,
+ property: { source: "system" as const, key: "email" },
+ operator: "is_set" as const,
+ },
+ ],
+ },
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return {
+ visitorId,
+ visitorSessionId,
+ tourId,
+ surveyId,
+ collectionId,
+ articleId,
+ segmentId,
+ };
+ },
+});
+
+/**
+ * Cleans up all E2E test data across all workspaces.
+ */
+const cleanupAll = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ requireTestDataEnabled();
+ let totalCleaned = {
+ workspaces: 0,
+ items: 0,
+ };
+
+ // Get all workspaces
+ const workspaces = await ctx.db.query("workspaces").collect();
+
+ for (const workspace of workspaces) {
+ // Clean up test data in each workspace
+ const tours = await ctx.db
+ .query("tours")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .collect();
+
+ let hasTestData = false;
+ for (const tour of tours) {
+ if (tour.name.startsWith(E2E_TEST_PREFIX)) {
+ hasTestData = true;
+ const steps = await ctx.db
+ .query("tourSteps")
+ .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
+ .collect();
+ for (const step of steps) {
+ await ctx.db.delete(step._id);
+ totalCleaned.items++;
+ }
+ await ctx.db.delete(tour._id);
+ totalCleaned.items++;
+ }
+ }
+
+ const surveys = await ctx.db
+ .query("surveys")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .collect();
+
+ for (const survey of surveys) {
+ if (survey.name.startsWith(E2E_TEST_PREFIX)) {
+ hasTestData = true;
+ await ctx.db.delete(survey._id);
+ totalCleaned.items++;
+ }
+ }
+
+ const articles = await ctx.db
+ .query("articles")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .collect();
+
+ for (const article of articles) {
+ if (article.title.startsWith(E2E_TEST_PREFIX)) {
+ hasTestData = true;
+ await ctx.db.delete(article._id);
+ totalCleaned.items++;
+ }
+ }
+
+ const collections = await ctx.db
+ .query("collections")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .collect();
+
+ for (const collection of collections) {
+ if (collection.name.startsWith(E2E_TEST_PREFIX)) {
+ hasTestData = true;
+ await ctx.db.delete(collection._id);
+ totalCleaned.items++;
+ }
+ }
+
+ const segments = await ctx.db
+ .query("segments")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .collect();
+
+ for (const segment of segments) {
+ if (segment.name.startsWith(E2E_TEST_PREFIX)) {
+ hasTestData = true;
+ await ctx.db.delete(segment._id);
+ totalCleaned.items++;
+ }
+ }
+
+ const visitors = await ctx.db
+ .query("visitors")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .collect();
+
+ for (const visitor of visitors) {
+ if (visitor.sessionId.startsWith(E2E_TEST_PREFIX)) {
+ hasTestData = true;
+ await ctx.db.delete(visitor._id);
+ totalCleaned.items++;
+ }
+ }
+
+ if (hasTestData) {
+ totalCleaned.workspaces++;
+ }
+ }
+
+ return { success: true, totalCleaned };
+ },
+});
+
+/**
+ * Clears all tours for a workspace. Used for testing empty state.
+ * This is safe for parallel tests as it only affects the specified workspace.
+ */
+const clearAllTours = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const { workspaceId } = args;
+ let deletedCount = 0;
+
+ // Get all tours for this workspace
+ const tours = await ctx.db
+ .query("tours")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const tour of tours) {
+ // Delete tour steps first
+ const steps = await ctx.db
+ .query("tourSteps")
+ .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
+ .collect();
+ for (const step of steps) {
+ await ctx.db.delete(step._id);
+ }
+
+ // Delete tour progress
+ const progress = await ctx.db
+ .query("tourProgress")
+ .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
+ .collect();
+ for (const p of progress) {
+ await ctx.db.delete(p._id);
+ }
+
+ // Delete the tour
+ await ctx.db.delete(tour._id);
+ deletedCount++;
+ }
+
+ return { success: true, deletedCount };
+ },
+});
+
+/**
+ * Seeds comprehensive demo data for screenshot automation.
+ * Creates realistic data across all major features so screenshots
+ * show a "full" workspace state.
+ *
+ * Use SEED_DATA=true with the screenshot scripts to invoke this.
+ */
+const getTourCount = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const tours = await ctx.db
+ .query("tours")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .collect();
+ return { count: tours.length };
+ },
+});
+
+export const cleanupMutations: Record> = {
+ cleanupTestData,
+ seedAll,
+ cleanupAll,
+ clearAllTours,
+ getTourCount,
+} as const;
diff --git a/packages/convex/convex/testData/demoWorkspace.ts b/packages/convex/convex/testData/demoWorkspace.ts
new file mode 100644
index 0000000..8eadf74
--- /dev/null
+++ b/packages/convex/convex/testData/demoWorkspace.ts
@@ -0,0 +1,969 @@
+import { internalMutation } from "../_generated/server";
+import { v } from "convex/values";
+import { Id } from "../_generated/dataModel";
+import { formatReadableVisitorId } from "../visitorReadableId";
+
+const E2E_TEST_PREFIX = "e2e_test_";
+
+function requireTestDataEnabled() {
+ if (process.env.ALLOW_TEST_DATA !== "true") {
+ throw new Error("Test data mutations are disabled");
+ }
+}
+
+const seedDemoData = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const { workspaceId } = args;
+ const now = Date.now();
+ const DAY = 86400000;
+
+ // ── Clean up stale e2e_test segments from previous runs ────────
+ const oldSegments = await ctx.db
+ .query("segments")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const seg of oldSegments) {
+ if (seg.name.startsWith(E2E_TEST_PREFIX)) {
+ await ctx.db.delete(seg._id);
+ }
+ }
+
+ // ── Visitors ────────────────────────────────────────────────────
+ const visitors: Id<"visitors">[] = [];
+ const visitorProfiles = [
+ {
+ name: "Sarah Chen",
+ email: "sarah.chen@acme.io",
+ city: "San Francisco",
+ country: "United States",
+ countryCode: "US",
+ browser: "Chrome",
+ os: "macOS",
+ plan: "pro",
+ company: "Acme Inc",
+ },
+ {
+ name: "Marcus Johnson",
+ email: "marcus@techstart.co",
+ city: "Austin",
+ country: "United States",
+ countryCode: "US",
+ browser: "Firefox",
+ os: "Windows",
+ plan: "free",
+ company: "TechStart",
+ },
+ {
+ name: "Priya Sharma",
+ email: "priya@globalcorp.com",
+ city: "London",
+ country: "United Kingdom",
+ countryCode: "GB",
+ browser: "Safari",
+ os: "macOS",
+ plan: "enterprise",
+ company: "GlobalCorp",
+ },
+ {
+ name: "Alex Rivera",
+ email: "alex@designhub.io",
+ city: "New York",
+ country: "United States",
+ countryCode: "US",
+ browser: "Chrome",
+ os: "Windows",
+ plan: "pro",
+ company: "DesignHub",
+ },
+ {
+ name: "Emma Wilson",
+ email: "emma@retailplus.com",
+ city: "Toronto",
+ country: "Canada",
+ countryCode: "CA",
+ browser: "Edge",
+ os: "Windows",
+ plan: "free",
+ company: "RetailPlus",
+ },
+ {
+ name: "Kenji Tanaka",
+ email: "kenji@appsol.jp",
+ city: "Tokyo",
+ country: "Japan",
+ countryCode: "JP",
+ browser: "Chrome",
+ os: "macOS",
+ plan: "pro",
+ company: "AppSolutions",
+ },
+ ];
+
+ for (let i = 0; i < visitorProfiles.length; i++) {
+ const p = visitorProfiles[i];
+ const vid = await ctx.db.insert("visitors", {
+ sessionId: `${E2E_TEST_PREFIX}demo_session_${i}`,
+ workspaceId,
+ email: p.email,
+ name: p.name,
+ customAttributes: {
+ plan: p.plan,
+ company: p.company,
+ signupDate: new Date(now - (i + 1) * 7 * DAY).toISOString(),
+ },
+ location: { city: p.city, country: p.country, countryCode: p.countryCode },
+ device: { browser: p.browser, os: p.os, deviceType: "desktop" },
+ firstSeenAt: now - (i + 1) * 7 * DAY,
+ lastSeenAt: now - i * DAY,
+ createdAt: now - (i + 1) * 7 * DAY,
+ });
+ await ctx.db.patch(vid, {
+ readableId: formatReadableVisitorId(vid),
+ });
+ visitors.push(vid);
+ }
+
+ // ── Conversations + Messages ────────────────────────────────────
+ const conversationData = [
+ {
+ visitorIdx: 0,
+ status: "open" as const,
+ msgs: [
+ {
+ sender: "visitor",
+ content: "Hi! I'm having trouble setting up the API integration. Can you help?",
+ },
+ {
+ sender: "bot",
+ content:
+ "Of course! I'd be happy to help with API integration. Could you tell me which endpoint you're working with?",
+ },
+ { sender: "visitor", content: "The webhooks endpoint — I keep getting 401 errors." },
+ ],
+ },
+ {
+ visitorIdx: 1,
+ status: "open" as const,
+ msgs: [
+ { sender: "visitor", content: "Is there a way to export my analytics data as CSV?" },
+ {
+ sender: "bot",
+ content:
+ "Yes! You can export analytics from the Reports page. Click the export icon in the top right corner.",
+ },
+ ],
+ },
+ {
+ visitorIdx: 2,
+ status: "closed" as const,
+ msgs: [
+ {
+ sender: "visitor",
+ content: "We need to upgrade our plan to Enterprise. Who should I contact?",
+ },
+ {
+ sender: "bot",
+ content:
+ "I'll connect you with our sales team right away. They typically respond within an hour.",
+ },
+ { sender: "visitor", content: "Great, thank you!" },
+ ],
+ },
+ {
+ visitorIdx: 3,
+ status: "open" as const,
+ msgs: [
+ {
+ sender: "visitor",
+ content: "The tooltip builder is not loading on our staging environment.",
+ },
+ ],
+ },
+ {
+ visitorIdx: 4,
+ status: "snoozed" as const,
+ msgs: [
+ { sender: "visitor", content: "Can we customise the widget colours to match our brand?" },
+ {
+ sender: "bot",
+ content:
+ "Absolutely! Go to Settings → Messenger and update the primary colour. Changes apply instantly.",
+ },
+ { sender: "visitor", content: "Perfect, I'll try that after our deploy on Monday." },
+ ],
+ },
+ ];
+
+ const conversationIds: Id<"conversations">[] = [];
+ for (const conv of conversationData) {
+ const createdAt = now - (5 - conv.visitorIdx) * DAY;
+ const cid = await ctx.db.insert("conversations", {
+ workspaceId,
+ visitorId: visitors[conv.visitorIdx],
+ status: conv.status,
+ createdAt,
+ updatedAt: createdAt + conv.msgs.length * 60000,
+ lastMessageAt: createdAt + conv.msgs.length * 60000,
+ unreadByAgent: conv.status === "open" ? 1 : 0,
+ });
+ conversationIds.push(cid);
+
+ for (let j = 0; j < conv.msgs.length; j++) {
+ const m = conv.msgs[j];
+ await ctx.db.insert("messages", {
+ conversationId: cid,
+ senderId:
+ m.sender === "visitor" ? (visitors[conv.visitorIdx] as unknown as string) : "system",
+ senderType: m.sender === "visitor" ? "visitor" : "bot",
+ content: m.content,
+ createdAt: createdAt + j * 60000,
+ });
+ }
+ }
+
+ // ── Tickets ──────────────────────────────────────────────────────
+ const ticketData = [
+ {
+ visitorIdx: 0,
+ subject: "API webhook returns 401 Unauthorized",
+ priority: "high" as const,
+ status: "in_progress" as const,
+ },
+ {
+ visitorIdx: 1,
+ subject: "CSV export missing date column",
+ priority: "normal" as const,
+ status: "submitted" as const,
+ },
+ {
+ visitorIdx: 2,
+ subject: "Enterprise plan upgrade request",
+ priority: "normal" as const,
+ status: "resolved" as const,
+ },
+ {
+ visitorIdx: 3,
+ subject: "Tooltip builder blank on staging",
+ priority: "high" as const,
+ status: "submitted" as const,
+ },
+ {
+ visitorIdx: 4,
+ subject: "Widget colour customisation help",
+ priority: "low" as const,
+ status: "waiting_on_customer" as const,
+ },
+ {
+ visitorIdx: 5,
+ subject: "SDK initialisation error on iOS 17",
+ priority: "urgent" as const,
+ status: "in_progress" as const,
+ },
+ ];
+
+ for (let i = 0; i < ticketData.length; i++) {
+ const t = ticketData[i];
+ await ctx.db.insert("tickets", {
+ workspaceId,
+ visitorId: visitors[t.visitorIdx],
+ subject: `${E2E_TEST_PREFIX}${t.subject}`,
+ description: `Customer reported: ${t.subject}`,
+ status: t.status,
+ priority: t.priority,
+ createdAt: now - (6 - i) * DAY,
+ updatedAt: now - i * DAY,
+ resolvedAt: t.status === "resolved" ? now - i * DAY : undefined,
+ });
+ }
+
+ // ── Articles + Collections ───────────────────────────────────────
+ const collections = [
+ {
+ name: "Getting Started",
+ desc: "Everything you need to begin",
+ articles: [
+ {
+ title: "Quick Start Guide",
+ content:
+ "# Quick Start Guide\n\nWelcome to Opencom! Follow these steps to get started in under 5 minutes.\n\n## Step 1: Install the Widget\n\nAdd the JavaScript snippet to your website's `` tag.\n\n## Step 2: Configure Your Messenger\n\nCustomise colours, welcome message, and team availability.\n\n## Step 3: Start Conversations\n\nYour visitors can now reach you through the widget!",
+ },
+ {
+ title: "Installing the Widget",
+ content:
+ '# Installing the Widget\n\nThe Opencom widget can be installed on any website.\n\n## HTML Installation\n\n```html\n\n```\n\n## React Installation\n\n```bash\nnpm install @opencom/react\n```\n\nSee the SDK documentation for framework-specific guides.',
+ },
+ {
+ title: "Setting Up Your Team",
+ content:
+ "# Setting Up Your Team\n\nInvite your team members and assign roles.\n\n## Roles\n\n- **Owner**: Full access\n- **Admin**: Manage settings and team\n- **Agent**: Handle conversations and tickets",
+ },
+ ],
+ },
+ {
+ name: "Messaging & Inbox",
+ desc: "Managing conversations and messages",
+ articles: [
+ {
+ title: "Using the Inbox",
+ content:
+ "# Using the Inbox\n\nThe inbox is your central hub for all customer conversations.\n\n## Filtering Conversations\n\nUse the sidebar filters to view open, closed, or snoozed conversations.\n\n## Assigning Conversations\n\nClick the assignee dropdown to route conversations to specific agents.",
+ },
+ {
+ title: "Outbound Messages",
+ content:
+ "# Outbound Messages\n\nSend targeted messages to your users based on behaviour and attributes.\n\n## Message Types\n\n- **Chat**: Appears as a chat bubble\n- **Post**: Rich content card\n- **Banner**: Top or bottom bar\n\n## Targeting\n\nUse audience rules to show messages to specific segments.",
+ },
+ ],
+ },
+ {
+ name: "Help Center",
+ desc: "Build a self-service knowledge base",
+ articles: [
+ {
+ title: "Creating Articles",
+ content:
+ "# Creating Articles\n\nWrite help articles with our rich text editor.\n\n## Markdown Support\n\nArticles support full Markdown including code blocks, tables, and images.\n\n## Publishing\n\nSave as draft or publish immediately. Published articles appear in the widget.",
+ },
+ {
+ title: "Organising Collections",
+ content:
+ "# Organising Collections\n\nGroup related articles into collections for easy browsing.\n\n## Collection Icons\n\nChoose an icon for each collection to make navigation intuitive.\n\n## Ordering\n\nDrag and drop to reorder collections and articles.",
+ },
+ ],
+ },
+ ];
+
+ for (let ci = 0; ci < collections.length; ci++) {
+ const c = collections[ci];
+ const slug = `${E2E_TEST_PREFIX}${c.name.toLowerCase().replace(/\s+/g, "-")}`;
+ const collectionId = await ctx.db.insert("collections", {
+ workspaceId,
+ name: `${E2E_TEST_PREFIX}${c.name}`,
+ slug,
+ description: c.desc,
+ order: ci,
+ createdAt: now - 30 * DAY,
+ updatedAt: now,
+ });
+
+ for (let ai = 0; ai < c.articles.length; ai++) {
+ const a = c.articles[ai];
+ await ctx.db.insert("articles", {
+ workspaceId,
+ collectionId,
+ title: `${E2E_TEST_PREFIX}${a.title}`,
+ slug: `${E2E_TEST_PREFIX}${a.title.toLowerCase().replace(/\s+/g, "-")}-${ci}-${ai}`,
+ content: a.content,
+ status: "published",
+ order: ai,
+ createdAt: now - 30 * DAY + ai * DAY,
+ updatedAt: now,
+ publishedAt: now - 30 * DAY + ai * DAY,
+ });
+ }
+ }
+
+ // ── Snippets ─────────────────────────────────────────────────────
+ const snippetData = [
+ {
+ name: "Greeting",
+ shortcut: "hi",
+ content: "Hi there! Thanks for reaching out. How can I help you today?",
+ },
+ {
+ name: "Escalation",
+ shortcut: "esc",
+ content:
+ "I'm going to loop in a specialist who can help with this. They'll follow up shortly.",
+ },
+ {
+ name: "Follow Up",
+ shortcut: "fu",
+ content:
+ "Just checking in — were you able to resolve the issue? Let me know if you need any further help.",
+ },
+ {
+ name: "Closing",
+ shortcut: "close",
+ content: "Glad I could help! Feel free to reach out any time. Have a great day!",
+ },
+ {
+ name: "Bug Report Ack",
+ shortcut: "bug",
+ content:
+ "Thanks for reporting this. I've created a ticket and our engineering team will investigate. I'll keep you posted on progress.",
+ },
+ ];
+
+ for (const s of snippetData) {
+ await ctx.db.insert("snippets", {
+ workspaceId,
+ name: `${E2E_TEST_PREFIX}${s.name}`,
+ shortcut: s.shortcut,
+ content: s.content,
+ createdAt: now - 14 * DAY,
+ updatedAt: now,
+ });
+ }
+
+ // ── Outbound Messages ────────────────────────────────────────────
+ const outboundData = [
+ {
+ name: "Welcome Chat",
+ type: "chat" as const,
+ status: "active" as const,
+ text: "Welcome! Let us know if you need help getting started.",
+ },
+ {
+ name: "Feature Announcement",
+ type: "post" as const,
+ status: "active" as const,
+ title: "New: AI-Powered Suggestions",
+ body: "Our AI agent can now suggest help articles to your visitors automatically.",
+ },
+ {
+ name: "Upgrade Banner",
+ type: "banner" as const,
+ status: "active" as const,
+ text: "Unlock advanced analytics — upgrade to Pro today.",
+ },
+ {
+ name: "Feedback Request",
+ type: "chat" as const,
+ status: "draft" as const,
+ text: "We'd love to hear your feedback! How has your experience been so far?",
+ },
+ ];
+
+ for (let i = 0; i < outboundData.length; i++) {
+ const o = outboundData[i];
+ const content: Record = {};
+ if (o.type === "chat") {
+ content.text = o.text;
+ } else if (o.type === "post") {
+ content.title = o.title;
+ content.body = o.body;
+ } else {
+ content.text = o.text;
+ content.style = "floating";
+ content.dismissible = true;
+ }
+
+ await ctx.db.insert("outboundMessages", {
+ workspaceId,
+ name: `${E2E_TEST_PREFIX}${o.name}`,
+ type: o.type,
+ content: content as any,
+ status: o.status,
+ triggers: { type: "immediate" },
+ frequency: "once",
+ priority: 100 - i * 10,
+ createdAt: now - (10 - i) * DAY,
+ updatedAt: now,
+ });
+ }
+
+ // ── Tours ────────────────────────────────────────────────────────
+ const tourData = [
+ {
+ name: "Product Walkthrough",
+ status: "active" as const,
+ targetPageUrl: undefined as string | undefined,
+ steps: [
+ {
+ type: "post" as const,
+ title: "Welcome to Opencom!",
+ content: "Let us show you around the key features.",
+ },
+ {
+ type: "pointer" as const,
+ title: "Your Inbox",
+ content: "All customer conversations land here.",
+ elementSelector: "[data-testid='nav-inbox']",
+ },
+ {
+ type: "pointer" as const,
+ title: "Knowledge Base",
+ content: "Create help articles for self-service.",
+ elementSelector: "[data-testid='nav-knowledge']",
+ },
+ ],
+ },
+ {
+ name: "Widget Demo Tour",
+ status: "active" as const,
+ targetPageUrl: "*widget-demo*",
+ steps: [
+ {
+ type: "post" as const,
+ title: "Welcome to Opencom!",
+ content: "Let us give you a quick tour of our platform and show you the key features.",
+ },
+ {
+ type: "pointer" as const,
+ title: "Tour Target 1",
+ content: "This is the first interactive element you can explore.",
+ elementSelector: "#tour-target-1",
+ },
+ {
+ type: "pointer" as const,
+ title: "Tour Target 2",
+ content: "Here's another feature worth checking out.",
+ elementSelector: "#tour-target-2",
+ },
+ {
+ type: "pointer" as const,
+ title: "Tour Target 3",
+ content: "And one more thing to discover!",
+ elementSelector: "#tour-target-3",
+ },
+ ],
+ },
+ {
+ name: "Inbox Tour",
+ status: "active" as const,
+ targetPageUrl: undefined as string | undefined,
+ steps: [
+ {
+ type: "post" as const,
+ title: "Master Your Inbox",
+ content: "Learn how to manage conversations efficiently.",
+ },
+ {
+ type: "pointer" as const,
+ title: "Filters",
+ content: "Filter by status, assignee, or channel.",
+ elementSelector: "[data-testid='inbox-filters']",
+ },
+ ],
+ },
+ {
+ name: "Settings Tour",
+ status: "draft" as const,
+ targetPageUrl: undefined as string | undefined,
+ steps: [
+ {
+ type: "post" as const,
+ title: "Customise Your Workspace",
+ content: "Adjust settings to match your workflow.",
+ },
+ ],
+ },
+ ];
+
+ for (let i = 0; i < tourData.length; i++) {
+ const t = tourData[i];
+ const tourId = await ctx.db.insert("tours", {
+ workspaceId,
+ name: `${E2E_TEST_PREFIX}${t.name}`,
+ description: `Demo tour: ${t.name}`,
+ status: t.status,
+ targetingRules: t.targetPageUrl ? { pageUrl: t.targetPageUrl } : undefined,
+ displayMode: "first_time_only",
+ priority: 100 - i * 10,
+ createdAt: now - 20 * DAY,
+ updatedAt: now,
+ });
+
+ for (let si = 0; si < t.steps.length; si++) {
+ const s = t.steps[si];
+ await ctx.db.insert("tourSteps", {
+ workspaceId: args.workspaceId,
+ tourId,
+ type: s.type,
+ order: si,
+ title: s.title,
+ content: s.content,
+ elementSelector: "elementSelector" in s ? s.elementSelector : undefined,
+ position: "auto",
+ advanceOn: "click",
+ createdAt: now - 20 * DAY,
+ updatedAt: now,
+ });
+ }
+ }
+
+ // ── Surveys ──────────────────────────────────────────────────────
+ const surveyData = [
+ {
+ name: "NPS Survey",
+ format: "small" as const,
+ status: "active" as const,
+ qType: "nps" as const,
+ qTitle: "How likely are you to recommend Opencom?",
+ },
+ {
+ name: "Feature Satisfaction",
+ format: "large" as const,
+ status: "active" as const,
+ qType: "star_rating" as const,
+ qTitle: "How would you rate our product tours feature?",
+ },
+ {
+ name: "Onboarding Feedback",
+ format: "small" as const,
+ status: "draft" as const,
+ qType: "multiple_choice" as const,
+ qTitle: "How did you hear about us?",
+ },
+ ];
+
+ for (const s of surveyData) {
+ await ctx.db.insert("surveys", {
+ workspaceId,
+ name: `${E2E_TEST_PREFIX}${s.name}`,
+ description: `Demo survey: ${s.name}`,
+ format: s.format,
+ status: s.status,
+ questions: [
+ {
+ id: `q_demo_${s.name.toLowerCase().replace(/\s+/g, "_")}`,
+ type: s.qType as
+ | "nps"
+ | "numeric_scale"
+ | "star_rating"
+ | "emoji_rating"
+ | "dropdown"
+ | "short_text"
+ | "long_text"
+ | "multiple_choice",
+ title: s.qTitle,
+ required: true,
+ ...(s.qType === "multiple_choice"
+ ? {
+ options: {
+ choices: [
+ "Google Search",
+ "Friend or Colleague",
+ "Social Media",
+ "Blog Post",
+ "Other",
+ ],
+ },
+ }
+ : {}),
+ },
+ ],
+ introStep:
+ s.format === "large"
+ ? {
+ title: s.name,
+ description: "Help us improve by sharing your feedback",
+ buttonText: "Start",
+ }
+ : undefined,
+ thankYouStep: { title: "Thank you!", description: "Your feedback helps us improve." },
+ triggers: { type: "immediate" },
+ frequency: "once",
+ createdAt: now - 15 * DAY,
+ updatedAt: now,
+ });
+ }
+
+ // ── Checklists ───────────────────────────────────────────────────
+ await ctx.db.insert("checklists", {
+ workspaceId,
+ name: `${E2E_TEST_PREFIX}Onboarding Checklist`,
+ description: "Get started with Opencom in 5 easy steps",
+ tasks: [
+ {
+ id: "task_1",
+ title: "Install the widget",
+ description: "Add the snippet to your site",
+ completionType: "manual",
+ },
+ {
+ id: "task_2",
+ title: "Customise your messenger",
+ description: "Set brand colours and welcome message",
+ completionType: "manual",
+ },
+ {
+ id: "task_3",
+ title: "Create your first article",
+ description: "Write a help article for your users",
+ completionType: "manual",
+ },
+ {
+ id: "task_4",
+ title: "Invite a teammate",
+ description: "Add a colleague to your workspace",
+ completionType: "manual",
+ },
+ {
+ id: "task_5",
+ title: "Send your first message",
+ description: "Create an outbound message",
+ completionType: "manual",
+ },
+ ],
+ status: "active",
+ createdAt: now - 14 * DAY,
+ updatedAt: now,
+ });
+
+ await ctx.db.insert("checklists", {
+ workspaceId,
+ name: `${E2E_TEST_PREFIX}Advanced Setup`,
+ description: "Unlock the full power of Opencom",
+ tasks: [
+ {
+ id: "task_a1",
+ title: "Set up audience segments",
+ description: "Target users by attributes",
+ completionType: "manual",
+ },
+ {
+ id: "task_a2",
+ title: "Create a product tour",
+ description: "Guide users through features",
+ completionType: "manual",
+ },
+ {
+ id: "task_a3",
+ title: "Configure AI agent",
+ description: "Enable AI-powered responses",
+ completionType: "manual",
+ },
+ ],
+ status: "draft",
+ createdAt: now - 7 * DAY,
+ updatedAt: now,
+ });
+
+ // ── Tooltips ─────────────────────────────────────────────────────
+ const tooltipData = [
+ {
+ name: "Inbox Filter Tip",
+ selector: "[data-testid='inbox-filters']",
+ content: "Use filters to quickly find conversations by status or assignee.",
+ trigger: "hover" as const,
+ },
+ {
+ name: "New Article Tip",
+ selector: "[data-testid='new-article-btn']",
+ content: "Click here to create a new help article for your knowledge base.",
+ trigger: "hover" as const,
+ },
+ {
+ name: "Export Data Tip",
+ selector: "[data-testid='export-btn']",
+ content: "Export your data as CSV or JSON for reporting.",
+ trigger: "click" as const,
+ },
+ ];
+
+ for (const t of tooltipData) {
+ await ctx.db.insert("tooltips", {
+ workspaceId,
+ name: `${E2E_TEST_PREFIX}${t.name}`,
+ elementSelector: t.selector,
+ content: t.content,
+ triggerType: t.trigger,
+ createdAt: now - 10 * DAY,
+ updatedAt: now,
+ });
+ }
+
+ // ── Segments ─────────────────────────────────────────────────────
+ const segmentData = [
+ {
+ name: "Active Users",
+ desc: "Users seen in the last 7 days",
+ rules: {
+ type: "group" as const,
+ operator: "and" as const,
+ conditions: [
+ {
+ type: "condition" as const,
+ property: { source: "system" as const, key: "lastSeenAt" },
+ operator: "greater_than" as const,
+ value: now - 7 * DAY,
+ },
+ ],
+ },
+ },
+ {
+ name: "Pro Plan Users",
+ desc: "Users on the Pro plan",
+ rules: {
+ type: "group" as const,
+ operator: "and" as const,
+ conditions: [
+ {
+ type: "condition" as const,
+ property: { source: "custom" as const, key: "plan" },
+ operator: "equals" as const,
+ value: "pro",
+ },
+ ],
+ },
+ },
+ {
+ name: "Enterprise Leads",
+ desc: "Enterprise plan users",
+ rules: {
+ type: "group" as const,
+ operator: "and" as const,
+ conditions: [
+ {
+ type: "condition" as const,
+ property: { source: "custom" as const, key: "plan" },
+ operator: "equals" as const,
+ value: "enterprise",
+ },
+ ],
+ },
+ },
+ ];
+
+ for (const s of segmentData) {
+ await ctx.db.insert("segments", {
+ workspaceId,
+ name: `${E2E_TEST_PREFIX}${s.name}`,
+ description: s.desc,
+ audienceRules: s.rules,
+ createdAt: now - 14 * DAY,
+ updatedAt: now,
+ });
+ }
+
+ // ── Email Campaigns ──────────────────────────────────────────────
+ const campaignData = [
+ {
+ name: "Welcome Email",
+ subject: "Welcome to Opencom!",
+ status: "sent" as const,
+ content: "Welcome!
Thanks for signing up. Here's how to get started...
",
+ stats: {
+ pending: 0,
+ sent: 1240,
+ delivered: 1210,
+ opened: 845,
+ clicked: 320,
+ bounced: 12,
+ unsubscribed: 3,
+ },
+ },
+ {
+ name: "Feature Update",
+ subject: "New: AI Agent is here",
+ status: "sent" as const,
+ content: "AI Agent
Your visitors can now get instant answers powered by AI.
",
+ stats: {
+ pending: 0,
+ sent: 980,
+ delivered: 965,
+ opened: 612,
+ clicked: 198,
+ bounced: 5,
+ unsubscribed: 1,
+ },
+ },
+ {
+ name: "Re-engagement",
+ subject: "We miss you!",
+ status: "draft" as const,
+ content: "Come back!
It's been a while. See what's new...
",
+ stats: undefined,
+ },
+ ];
+
+ for (const c of campaignData) {
+ await ctx.db.insert("emailCampaigns", {
+ workspaceId,
+ name: `${E2E_TEST_PREFIX}${c.name}`,
+ subject: c.subject,
+ content: c.content,
+ status: c.status,
+ stats: c.stats,
+ sentAt: c.status === "sent" ? now - 3 * DAY : undefined,
+ createdAt: now - 10 * DAY,
+ updatedAt: now,
+ });
+ }
+
+ // ── Messenger Settings ───────────────────────────────────────────
+ const existingSettings = await ctx.db
+ .query("messengerSettings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .first();
+
+ if (existingSettings) {
+ await ctx.db.patch(existingSettings._id, {
+ primaryColor: "#792cd4",
+ backgroundColor: "#792cd4",
+ welcomeMessage: "Hi there! How can we help you today?",
+ launcherPosition: "right",
+ updatedAt: now,
+ });
+ } else {
+ await ctx.db.insert("messengerSettings", {
+ workspaceId,
+ primaryColor: "#792cd4",
+ backgroundColor: "#792cd4",
+ themeMode: "light",
+ launcherPosition: "right",
+ launcherSideSpacing: 20,
+ launcherBottomSpacing: 20,
+ showLauncher: true,
+ welcomeMessage: "Hi there! How can we help you today?",
+ showTeammateAvatars: true,
+ supportedLanguages: ["en"],
+ defaultLanguage: "en",
+ mobileEnabled: true,
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+
+ // ── AI Agent Settings ────────────────────────────────────────────
+ const existingAI = await ctx.db
+ .query("aiAgentSettings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .first();
+
+ if (!existingAI) {
+ await ctx.db.insert("aiAgentSettings", {
+ workspaceId,
+ enabled: true,
+ knowledgeSources: ["articles"],
+ confidenceThreshold: 0.7,
+ personality: "helpful and friendly",
+ handoffMessage: "Let me connect you with a human agent.",
+ model: "gpt-5-nano",
+ suggestionsEnabled: true,
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+
+ return {
+ visitors: visitors.length,
+ conversations: conversationIds.length,
+ tickets: ticketData.length,
+ articles: collections.reduce((sum, c) => sum + c.articles.length, 0),
+ collections: collections.length,
+ snippets: snippetData.length,
+ outboundMessages: outboundData.length,
+ tours: tourData.length,
+ surveys: surveyData.length,
+ checklists: 2,
+ tooltips: tooltipData.length,
+ segments: segmentData.length,
+ emailCampaigns: campaignData.length,
+ };
+ },
+});
+
+/**
+ * Gets the count of tours for a workspace. Used for testing.
+ */
+
+export const demoWorkspaceMutations: Record> = {
+ seedDemoData,
+} as const;
diff --git a/packages/convex/convex/testData/landing.ts b/packages/convex/convex/testData/landing.ts
new file mode 100644
index 0000000..d76b713
--- /dev/null
+++ b/packages/convex/convex/testData/landing.ts
@@ -0,0 +1,1079 @@
+import { internalMutation } from "../_generated/server";
+import { v } from "convex/values";
+import { Id } from "../_generated/dataModel";
+
+function requireTestDataEnabled() {
+ if (process.env.ALLOW_TEST_DATA !== "true") {
+ throw new Error("Test data mutations are disabled");
+ }
+}
+const LANDING_DEMO_PREFIX = "LANDING_DEMO_";
+const LANDING_DEMO_ARTICLE_MARKER = ``;
+const LANDING_DEMO_ARTICLE_CONTENT_SUFFIX = `\n\n${LANDING_DEMO_ARTICLE_MARKER}`;
+const LANDING_DEMO_SLUG_SUFFIX = "landing-demo";
+
+function isLandingDemoArticle(article: { title: string; content: string }): boolean {
+ return (
+ article.title.startsWith(LANDING_DEMO_PREFIX) ||
+ article.content.trimEnd().endsWith(LANDING_DEMO_ARTICLE_MARKER)
+ );
+}
+
+/**
+ * Cleans up all landing demo data from a workspace.
+ */
+
+const cleanupLandingDemo = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const { workspaceId } = args;
+ let cleaned = {
+ tours: 0,
+ tourSteps: 0,
+ checklists: 0,
+ articles: 0,
+ collections: 0,
+ outboundMessages: 0,
+ surveys: 0,
+ tooltips: 0,
+ };
+
+ // Clean up tours and steps
+ const tours = await ctx.db
+ .query("tours")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const tour of tours) {
+ if (tour.name.startsWith(LANDING_DEMO_PREFIX)) {
+ const steps = await ctx.db
+ .query("tourSteps")
+ .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
+ .collect();
+ for (const step of steps) {
+ await ctx.db.delete(step._id);
+ cleaned.tourSteps++;
+ }
+ const progress = await ctx.db
+ .query("tourProgress")
+ .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
+ .collect();
+ for (const p of progress) {
+ await ctx.db.delete(p._id);
+ }
+ await ctx.db.delete(tour._id);
+ cleaned.tours++;
+ }
+ }
+
+ // Clean up checklists
+ const checklists = await ctx.db
+ .query("checklists")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const checklist of checklists) {
+ if (checklist.name.startsWith(LANDING_DEMO_PREFIX)) {
+ const progress = await ctx.db
+ .query("checklistProgress")
+ .withIndex("by_checklist", (q) => q.eq("checklistId", checklist._id))
+ .collect();
+ for (const p of progress) {
+ await ctx.db.delete(p._id);
+ }
+ await ctx.db.delete(checklist._id);
+ cleaned.checklists++;
+ }
+ }
+
+ // Clean up articles
+ const articles = await ctx.db
+ .query("articles")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ const demoCollectionIds = new Set>();
+ for (const article of articles) {
+ if (isLandingDemoArticle(article)) {
+ if (article.collectionId) {
+ demoCollectionIds.add(article.collectionId);
+ }
+ await ctx.db.delete(article._id);
+ cleaned.articles++;
+ }
+ }
+
+ // Clean up collections
+ const collections = await ctx.db
+ .query("collections")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const collection of collections) {
+ if (
+ collection.name.startsWith(LANDING_DEMO_PREFIX) ||
+ collection.slug.endsWith(`-${LANDING_DEMO_SLUG_SUFFIX}`) ||
+ demoCollectionIds.has(collection._id)
+ ) {
+ await ctx.db.delete(collection._id);
+ cleaned.collections++;
+ }
+ }
+
+ // Clean up outbound messages
+ const outboundMessages = await ctx.db
+ .query("outboundMessages")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const message of outboundMessages) {
+ if (message.name.startsWith(LANDING_DEMO_PREFIX)) {
+ const impressions = await ctx.db
+ .query("outboundMessageImpressions")
+ .withIndex("by_message", (q) => q.eq("messageId", message._id))
+ .collect();
+ for (const imp of impressions) {
+ await ctx.db.delete(imp._id);
+ }
+ await ctx.db.delete(message._id);
+ cleaned.outboundMessages++;
+ }
+ }
+
+ // Clean up surveys
+ const surveys = await ctx.db
+ .query("surveys")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const survey of surveys) {
+ if (survey.name.startsWith(LANDING_DEMO_PREFIX)) {
+ const responses = await ctx.db
+ .query("surveyResponses")
+ .withIndex("by_survey", (q) => q.eq("surveyId", survey._id))
+ .collect();
+ for (const r of responses) {
+ await ctx.db.delete(r._id);
+ }
+ const impressions = await ctx.db
+ .query("surveyImpressions")
+ .withIndex("by_survey", (q) => q.eq("surveyId", survey._id))
+ .collect();
+ for (const imp of impressions) {
+ await ctx.db.delete(imp._id);
+ }
+ await ctx.db.delete(survey._id);
+ cleaned.surveys++;
+ }
+ }
+
+ // Clean up tooltips
+ const tooltips = await ctx.db
+ .query("tooltips")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const tooltip of tooltips) {
+ if (tooltip.name.startsWith(LANDING_DEMO_PREFIX)) {
+ await ctx.db.delete(tooltip._id);
+ cleaned.tooltips++;
+ }
+ }
+
+ return { success: true, cleaned };
+ },
+});
+
+/**
+ * Seeds curated demo content for the landing page workspace.
+ * Idempotent — cleans up previous landing demo data before re-seeding.
+ */
+const seedLandingDemo = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const { workspaceId } = args;
+ const now = Date.now();
+ const DAY = 86400000;
+
+ // ── Idempotent: clean up previous landing demo data ──────────
+ const oldTours = await ctx.db
+ .query("tours")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const tour of oldTours) {
+ if (tour.name.startsWith(LANDING_DEMO_PREFIX)) {
+ const steps = await ctx.db
+ .query("tourSteps")
+ .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
+ .collect();
+ for (const s of steps) await ctx.db.delete(s._id);
+ const prog = await ctx.db
+ .query("tourProgress")
+ .withIndex("by_tour", (q) => q.eq("tourId", tour._id))
+ .collect();
+ for (const p of prog) await ctx.db.delete(p._id);
+ await ctx.db.delete(tour._id);
+ }
+ }
+ const oldChecklists = await ctx.db
+ .query("checklists")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const c of oldChecklists) {
+ if (c.name.startsWith(LANDING_DEMO_PREFIX)) {
+ const prog = await ctx.db
+ .query("checklistProgress")
+ .withIndex("by_checklist", (q) => q.eq("checklistId", c._id))
+ .collect();
+ for (const p of prog) await ctx.db.delete(p._id);
+ await ctx.db.delete(c._id);
+ }
+ }
+ const oldArticles = await ctx.db
+ .query("articles")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ const oldDemoCollectionIds = new Set>();
+ for (const a of oldArticles) {
+ if (isLandingDemoArticle(a)) {
+ if (a.collectionId) {
+ oldDemoCollectionIds.add(a.collectionId);
+ }
+ await ctx.db.delete(a._id);
+ }
+ }
+ const oldCollections = await ctx.db
+ .query("collections")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const c of oldCollections) {
+ if (
+ c.name.startsWith(LANDING_DEMO_PREFIX) ||
+ c.slug.endsWith(`-${LANDING_DEMO_SLUG_SUFFIX}`) ||
+ oldDemoCollectionIds.has(c._id)
+ ) {
+ await ctx.db.delete(c._id);
+ }
+ }
+ const oldOutbound = await ctx.db
+ .query("outboundMessages")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const m of oldOutbound) {
+ if (m.name.startsWith(LANDING_DEMO_PREFIX)) {
+ const imps = await ctx.db
+ .query("outboundMessageImpressions")
+ .withIndex("by_message", (q) => q.eq("messageId", m._id))
+ .collect();
+ for (const i of imps) await ctx.db.delete(i._id);
+ await ctx.db.delete(m._id);
+ }
+ }
+ const oldSurveys = await ctx.db
+ .query("surveys")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const s of oldSurveys) {
+ if (s.name.startsWith(LANDING_DEMO_PREFIX)) {
+ const resp = await ctx.db
+ .query("surveyResponses")
+ .withIndex("by_survey", (q) => q.eq("surveyId", s._id))
+ .collect();
+ for (const r of resp) await ctx.db.delete(r._id);
+ const imps = await ctx.db
+ .query("surveyImpressions")
+ .withIndex("by_survey", (q) => q.eq("surveyId", s._id))
+ .collect();
+ for (const i of imps) await ctx.db.delete(i._id);
+ await ctx.db.delete(s._id);
+ }
+ }
+ const oldTooltips = await ctx.db
+ .query("tooltips")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const t of oldTooltips) {
+ if (t.name.startsWith(LANDING_DEMO_PREFIX)) await ctx.db.delete(t._id);
+ }
+
+ // ── Product Tour ─────────────────────────────────────────────
+ const tourId = await ctx.db.insert("tours", {
+ workspaceId,
+ name: `${LANDING_DEMO_PREFIX}Landing Page Tour`,
+ description: "Interactive tour of the Opencom landing page",
+ status: "active",
+ targetingRules: undefined,
+ displayMode: "first_time_only",
+ priority: 100,
+ createdAt: now - 7 * DAY,
+ updatedAt: now,
+ });
+
+ const tourSteps = [
+ {
+ type: "post" as const,
+ title: "Welcome to Opencom!",
+ content: "Let us give you a quick tour of the open-source customer messaging platform.",
+ },
+ {
+ type: "pointer" as const,
+ title: "Launch the Hosted Demo",
+ content: "Start here to open a live Opencom workspace and explore the product in minutes.",
+ elementSelector: "[data-tour-target='hero-primary-cta']",
+ },
+ {
+ type: "pointer" as const,
+ title: "Explore the Platform",
+ content:
+ "Shared inbox, product tours, tickets, outbound messages, and AI agent workflows run on one real-time stack.",
+ elementSelector: "[data-tour-target='features-section']",
+ },
+ {
+ type: "pointer" as const,
+ title: "Native Product Tours",
+ content:
+ "Opencom tours attach to real UI elements, so onboarding remains fast and resilient as your app evolves.",
+ elementSelector: "[data-tour-target='showcase-product-tour']",
+ },
+ {
+ type: "pointer" as const,
+ title: "Ready to Build",
+ content:
+ "Jump into the hosted onboarding flow and start shipping your own customer messaging stack.",
+ elementSelector: "[data-tour-target='final-cta-primary']",
+ },
+ ];
+
+ const stepIds: Id<"tourSteps">[] = [];
+ for (let i = 0; i < tourSteps.length; i++) {
+ const s = tourSteps[i];
+ const stepId = await ctx.db.insert("tourSteps", {
+ workspaceId: workspaceId,
+ tourId,
+ type: s.type,
+ order: i,
+ title: s.title,
+ content: s.content,
+ elementSelector: s.elementSelector,
+ position: "auto",
+ advanceOn: "click",
+ createdAt: now - 7 * DAY,
+ updatedAt: now,
+ });
+ stepIds.push(stepId);
+ }
+
+ // ── Checklist ────────────────────────────────────────────────
+ const checklistId = await ctx.db.insert("checklists", {
+ workspaceId,
+ name: `${LANDING_DEMO_PREFIX}Explore Opencom`,
+ description: "Discover the key features of the open-source customer messaging platform",
+ tasks: [
+ {
+ id: "task_1",
+ title: "Take the guided tour",
+ description: "Walk through the landing page highlights",
+ completionType: "manual",
+ },
+ {
+ id: "task_2",
+ title: "Browse the knowledge base",
+ description: "Read help articles in the widget Help tab",
+ completionType: "manual",
+ },
+ {
+ id: "task_3",
+ title: "Start a conversation",
+ description: "Send a message through the chat widget",
+ completionType: "manual",
+ },
+ {
+ id: "task_4",
+ title: "Check out the docs",
+ description: "Visit the documentation to learn about deployment",
+ completionType: "manual",
+ },
+ {
+ id: "task_5",
+ title: "Star us on GitHub",
+ description: "Show your support by starring the repository",
+ completionType: "manual",
+ },
+ ],
+ status: "active",
+ createdAt: now - 7 * DAY,
+ updatedAt: now,
+ });
+
+ // ── Knowledge Base ───────────────────────────────────────────
+ const repoDocsBase = "https://github.com/opencom-org/opencom/blob/main";
+ const docsBase = `${repoDocsBase}/docs`;
+ const ossDocsBase = `${docsBase}/open-source`;
+ const collectionsData = [
+ {
+ name: "Hosted Onboarding",
+ desc: "Fastest path to evaluate Opencom before running your own backend",
+ articles: [
+ {
+ title: "Hosted Quick Start",
+ content: `# Hosted Quick Start
+
+Hosted mode is the fastest way to evaluate Opencom without managing infrastructure first.
+
+## Recommended path
+1. Sign up at https://app.opencom.dev and create a workspace.
+2. Invite teammates and verify inbox access.
+3. Copy the widget snippet from Settings -> Widget Installation.
+4. Add the snippet to your site and confirm the launcher opens.
+5. Validate core flows: conversations, help center, tours, outbound, and surveys.
+
+## When to move off hosted
+Switch to your own backend when you need stricter data controls, custom deployment workflows, or isolated environments.
+
+## Canonical docs
+- [Setup and deployment guide](${ossDocsBase}/setup-self-host-and-deploy.md)
+- [README deployment options](${repoDocsBase}/README.md#deployment-options)`,
+ },
+ {
+ title: "Hosted Workspace Setup Checklist",
+ content: `# Hosted Workspace Setup Checklist
+
+Use this checklist after creating your workspace:
+
+1. Configure workspace profile and teammate access.
+2. Review Signup Settings and authentication methods.
+3. Configure Security settings: allowed origins and identity verification mode.
+4. Install the widget and run a test conversation.
+5. Publish at least one help center collection for self service support.
+
+## Canonical docs
+- [Root README workspace and auth settings](${repoDocsBase}/README.md#workspace-settings)
+- [Security reference](${docsBase}/security.md)
+- [Widget SDK reference](${docsBase}/widget-sdk.md)`,
+ },
+ {
+ title: "Move from Hosted to Custom Backend",
+ content: `# Move from Hosted to Custom Backend
+
+You can start hosted and then migrate to your own Convex backend.
+
+## Migration outline
+1. Deploy packages/convex to your Convex project.
+2. Configure required backend environment variables.
+3. Connect web and mobile apps to your backend URL.
+4. Reinstall your site widget with your backend URL and workspace ID.
+5. Re-test identity verification, events, and messaging workflows.
+
+## Canonical docs
+- [Setup and self host guide](${ossDocsBase}/setup-self-host-and-deploy.md)
+- [Connecting to a self hosted backend](${repoDocsBase}/README.md#connecting-to-a-self-hosted-backend)
+- [Security and operations](${ossDocsBase}/security-and-operations.md)`,
+ },
+ {
+ title: "Hosted FAQs and Next Steps",
+ content: `# Hosted FAQs and Next Steps
+
+## Common questions
+- Where should I start? Hosted onboarding is best for fast evaluation.
+- Can I self host later? Yes. Deployment profiles support gradual migration.
+- Where do I find implementation docs? GitHub docs are the source of truth.
+- Where can I ask product and setup questions? Use GitHub Discussions.
+
+## Next steps
+1. Choose a deployment profile.
+2. Complete security setup before production traffic.
+3. Run the verification checklist before launch.
+
+## Canonical docs
+- [OSS docs hub](${ossDocsBase}/README.md)
+- [Testing and verification](${ossDocsBase}/testing-and-verification.md)
+- [GitHub discussions](https://github.com/opencom-org/opencom/discussions)`,
+ },
+ ],
+ },
+ {
+ name: "Self Hosting and Deployment",
+ desc: "Canonical setup and deployment paths for custom infrastructure",
+ articles: [
+ {
+ title: "Self Host Fast Path",
+ content: `# Self Host Fast Path
+
+The quickest self hosted setup uses scripts/setup.sh.
+
+## Prerequisites
+- Node.js 18+
+- PNPM 9+
+- Convex account
+
+## Fast path flow
+1. Clone the repo.
+2. Run scripts/setup.sh.
+3. Complete prompts for auth and workspace setup.
+4. Start local apps and verify widget connectivity.
+
+## Canonical docs
+- [Setup and self host guide](${ossDocsBase}/setup-self-host-and-deploy.md)
+- [Root README quick start](${repoDocsBase}/README.md#quick-start-self-hosters)
+- [Scripts reference](${docsBase}/scripts-reference.md)`,
+ },
+ {
+ title: "Manual Setup and Local Development",
+ content: `# Manual Setup and Local Development
+
+Use the manual path if you want full control over each setup step.
+
+## Typical sequence
+1. Install dependencies at repo root.
+2. Start Convex from packages/convex.
+3. Start web and widget apps.
+4. Optionally run landing and mobile apps.
+
+## Why use manual setup
+- Better visibility into environment wiring.
+- Easier to debug auth and configuration issues.
+- Useful for advanced CI or custom deployment pipelines.
+
+## Canonical docs
+- [Manual setup flow](${ossDocsBase}/setup-self-host-and-deploy.md#manual-setup-step-by-step)
+- [Architecture and repo map](${ossDocsBase}/architecture-and-repo-map.md)
+- [Testing guide](${docsBase}/testing.md)`,
+ },
+ {
+ title: "Deployment Profiles Explained",
+ content: `# Deployment Profiles Explained
+
+Opencom supports multiple deployment profiles:
+
+1. Hosted apps plus custom backend.
+2. Self hosted web plus custom backend.
+3. Full self host of apps plus backend.
+4. Optional widget CDN publishing workflow.
+
+Choose based on infrastructure ownership, compliance needs, and release control.
+
+## Canonical docs
+- [Deployment profiles](${ossDocsBase}/setup-self-host-and-deploy.md#deployment-profiles)
+- [Architecture deployment topology](${docsBase}/architecture.md#deployment-topology)
+- [Root README deployment options](${repoDocsBase}/README.md#deployment-options)`,
+ },
+ {
+ title: "Environment Variables by Surface",
+ content: `# Environment Variables by Surface
+
+The most important variables are grouped by runtime surface:
+
+- Convex backend: auth, email, security, CORS, AI, and test-data gates.
+- Web app: default backend URL and widget demo overrides.
+- Mobile app: default backend URL for operator workflows.
+- Landing app: widget URL and workspace-specific demo wiring.
+- Widget app: local development convex URL and workspace ID.
+
+Set secrets in deployment environments and never commit them to source control.
+
+## Canonical docs
+- [Environment variable matrix](${ossDocsBase}/setup-self-host-and-deploy.md#environment-variables)
+- [Security critical variables](${docsBase}/security.md#security-critical-env-vars)
+- [Root README env reference](${repoDocsBase}/README.md#environment-variables-reference)`,
+ },
+ ],
+ },
+ {
+ name: "Widget Integration",
+ desc: "Install, configure, and harden the website widget and help center",
+ articles: [
+ {
+ title: "Widget Installation Patterns",
+ content: `# Widget Installation Patterns
+
+Opencom supports declarative script-tag install and manual SDK initialization.
+
+## Common patterns
+1. Static or multi page websites.
+2. SPA frameworks that load script once at app boot.
+3. Next.js App Router integration using runtime environment variables.
+4. Consent managed script injection after user opt-in.
+5. Self hosted widget loader URL for infrastructure ownership.
+
+## Canonical docs
+- [Widget SDK installation and scenarios](${docsBase}/widget-sdk.md)
+- [README widget installation](${repoDocsBase}/README.md#widget-installation)`,
+ },
+ {
+ title: "Identify Users and Track Events",
+ content: `# Identify Users and Track Events
+
+Call identify after login so conversations and history map to known users.
+
+Track product events to power targeting, automation, and reporting.
+
+Recommended event model:
+- stable event names
+- consistent property shapes
+- clear ownership between frontend and backend teams
+
+## Canonical docs
+- [Widget identify and track APIs](${docsBase}/widget-sdk.md#api-reference)
+- [Backend events and analytics API](${docsBase}/api-reference.md)
+- [Data model events table](${docsBase}/data-model.md)`,
+ },
+ {
+ title: "Identity Verification with HMAC",
+ content: `# Identity Verification with HMAC
+
+Identity verification prevents impersonation by requiring a server generated hash for identified users.
+
+## Implementation outline
+1. Enable identity verification in workspace security settings.
+2. Generate user hash on your server using the shared secret.
+3. Pass userHash when identifying users in the widget or SDK.
+4. Choose optional vs required verification mode.
+
+## Canonical docs
+- [Security identity verification guide](${docsBase}/security.md#identity-verification-hmac)
+- [Widget identity verification section](${docsBase}/widget-sdk.md#identity-verification)
+- [Mobile SDK identity verification](${docsBase}/mobile-sdks.md#identity-verification)`,
+ },
+ {
+ title: "Widget Troubleshooting Checklist",
+ content: `# Widget Troubleshooting Checklist
+
+If the widget is not behaving as expected, check:
+
+1. convexUrl and workspaceId values.
+2. Allowed origins and CSP directives.
+3. Session and identity verification state.
+4. Script load timing in your framework lifecycle.
+5. Network access to your Convex deployment.
+
+## Canonical docs
+- [Widget troubleshooting](${docsBase}/widget-sdk.md#troubleshooting)
+- [Security CORS guidance](${docsBase}/security.md#cors-configuration)
+- [Setup common failures](${ossDocsBase}/setup-self-host-and-deploy.md#common-setup-failures)`,
+ },
+ ],
+ },
+ {
+ name: "Product and Engagement Guides",
+ desc: "Practical guidance for inbox, help center, campaigns, and automation",
+ articles: [
+ {
+ title: "Conversation and Inbox Workflow",
+ content: `# Conversation and Inbox Workflow
+
+Opencom inbox operations center on conversation ownership, response speed, and clean routing.
+
+## Core practices
+1. Assign and triage quickly.
+2. Use snippets and tags for repeatable responses.
+3. Monitor unread and SLA indicators.
+4. Apply role based permissions for team safety.
+
+## Canonical docs
+- [Backend API conversations and messages](${docsBase}/api-reference.md)
+- [Architecture visitor interaction flow](${docsBase}/architecture.md#data-flow)
+- [Security authorization model](${docsBase}/security.md#authorization-model)`,
+ },
+ {
+ title: "Help Center and Article Strategy",
+ content: `# Help Center and Article Strategy
+
+A useful help center balances findability and depth.
+
+## Recommended structure
+1. Separate hosted onboarding from self hosting.
+2. Group articles by implementation phase, not internal teams.
+3. Keep short operational checklists in each article.
+4. Link each article to canonical source documents.
+5. Publish only reviewed content and keep drafts private.
+
+## Canonical docs
+- [API reference for articles and collections](${docsBase}/api-reference.md)
+- [Data model help center tables](${docsBase}/data-model.md#help-center-tables)
+- [Documentation source of truth contract](${ossDocsBase}/source-of-truth.md)`,
+ },
+ {
+ title: "Tours Surveys Outbound and Checklists",
+ content: `# Tours Surveys Outbound and Checklists
+
+Use engagement features together, not in isolation.
+
+## Suggested lifecycle
+1. Product tour to onboard first time users.
+2. Outbound message for contextual prompts.
+3. Survey for product or support feedback.
+4. Checklist for adoption milestones.
+
+Use targeting rules and frequency controls to avoid fatigue.
+
+## Canonical docs
+- [API reference for tours surveys outbound and checklists](${docsBase}/api-reference.md)
+- [Data model engagement tables](${docsBase}/data-model.md)
+- [Architecture campaign delivery flow](${docsBase}/architecture.md#data-flow)`,
+ },
+ {
+ title: "Tickets Segments and Automation Basics",
+ content: `# Tickets Segments and Automation Basics
+
+Ticket workflows pair well with segmentation and automation settings.
+
+## Foundations
+1. Define ticket forms for consistent intake.
+2. Build segments from visitor and event attributes.
+3. Use assignment and notification rules for routing.
+4. Track outcomes in reporting snapshots.
+
+## Canonical docs
+- [API reference tickets segments automation](${docsBase}/api-reference.md)
+- [Data model ticket and automation tables](${docsBase}/data-model.md)
+- [Architecture integration boundaries](${ossDocsBase}/architecture-and-repo-map.md)`,
+ },
+ ],
+ },
+ {
+ name: "SDKs and API",
+ desc: "Implementation paths for backend APIs and mobile SDK surfaces",
+ articles: [
+ {
+ title: "Backend API Surface Overview",
+ content: `# Backend API Surface Overview
+
+The backend exposes modules for conversations, content, campaigns, automation, reporting, and AI features.
+
+## Start here
+1. Identify the table or workflow you need.
+2. Map it to the corresponding API module.
+3. Validate permissions and workspace boundaries before integrating.
+
+## Canonical docs
+- [Backend API reference](${docsBase}/api-reference.md)
+- [Architecture and repository map](${ossDocsBase}/architecture-and-repo-map.md)
+- [Data model reference](${docsBase}/data-model.md)`,
+ },
+ {
+ title: "React Native SDK Quick Start",
+ content: `# React Native SDK Quick Start
+
+The React Native SDK provides a full messaging surface with hooks and components.
+
+## Typical flow
+1. Install the package.
+2. Wrap app with OpencomProvider.
+3. Initialize SDK with workspaceId and convexUrl.
+4. Identify logged in users.
+5. Register push tokens when needed.
+
+## Canonical docs
+- [Mobile SDK reference React Native section](${docsBase}/mobile-sdks.md#react-native-sdk)
+- [React Native SDK package README](${repoDocsBase}/packages/react-native-sdk/README.md)
+- [Push architecture](${docsBase}/mobile-sdks.md#push-notification-architecture)`,
+ },
+ {
+ title: "iOS and Android SDK Quick Start",
+ content: `# iOS and Android SDK Quick Start
+
+Opencom ships native SDKs for Swift and Kotlin.
+
+## Shared flow
+1. Initialize with workspaceId and convexUrl.
+2. Identify users after login.
+3. Track events for analytics and targeting.
+4. Present messenger or help center UI.
+5. Register push tokens with platform transport credentials.
+
+## Canonical docs
+- [Mobile SDK reference iOS and Android](${docsBase}/mobile-sdks.md)
+- [iOS SDK README](${repoDocsBase}/packages/ios-sdk/README.md)
+- [Android SDK README](${repoDocsBase}/packages/android-sdk/README.md)`,
+ },
+ {
+ title: "Data Model for Integrations",
+ content: `# Data Model for Integrations
+
+Use the data model reference when designing analytics exports, integrations, or migration tooling.
+
+## Priority tables to understand
+1. visitors and widgetSessions
+2. conversations and messages
+3. collections and articles
+4. campaigns and notification delivery
+5. automation and audit logs
+
+## Canonical docs
+- [Data model reference](${docsBase}/data-model.md)
+- [API module map](${docsBase}/api-reference.md)
+- [Architecture overview](${docsBase}/architecture.md)`,
+ },
+ ],
+ },
+ {
+ name: "Security Testing and Operations",
+ desc: "Production hardening, verification workflows, and operational readiness",
+ articles: [
+ {
+ title: "Security Boundaries and Authorization",
+ content: `# Security Boundaries and Authorization
+
+Opencom enforces separate trust boundaries for agents and visitors.
+
+## Key controls
+1. Role and permission checks for agent actions.
+2. Signed visitor sessions for visitor facing APIs.
+3. Workspace isolation across all core resources.
+4. Audit log coverage for high risk actions.
+
+## Canonical docs
+- [Platform security guide](${docsBase}/security.md)
+- [Security and operations guide](${ossDocsBase}/security-and-operations.md)
+- [Architecture authorization model](${docsBase}/architecture.md#authorization-model)`,
+ },
+ {
+ title: "Webhook CORS and Discovery Route Security",
+ content: `# Webhook CORS and Discovery Route Security
+
+Production deployments should harden both inbound webhooks and public metadata routes.
+
+## Must-have controls
+1. Verify webhook signatures.
+2. Keep signature enforcement fail closed.
+3. Configure explicit CORS origins for public discovery.
+4. Keep test data gateways disabled outside test deployments.
+
+## Canonical docs
+- [Webhook security details](${docsBase}/security.md#webhook-security)
+- [CORS and discovery guidance](${ossDocsBase}/security-and-operations.md#cors-and-public-discovery-route)
+- [Setup env variable requirements](${ossDocsBase}/setup-self-host-and-deploy.md#environment-variables)`,
+ },
+ {
+ title: "Testing Workflow from Local to CI",
+ content: `# Testing Workflow from Local to CI
+
+Use focused checks first, then run broader verification before merge or release.
+
+## Practical sequence
+1. Run package-level typecheck and tests for touched areas.
+2. Run targeted E2E specs when behavior spans app boundaries.
+3. Run CI-equivalent lint, typecheck, security gates, convex tests, and web E2E.
+4. Capture failures with reliability tooling before retries.
+
+## Canonical docs
+- [Testing and verification guide](${ossDocsBase}/testing-and-verification.md)
+- [Detailed testing guide](${docsBase}/testing.md)
+- [Scripts reference for test utilities](${docsBase}/scripts-reference.md)`,
+ },
+ {
+ title: "Release Verification and Incident Readiness",
+ content: `# Release Verification and Incident Readiness
+
+Release readiness combines functional quality checks with security and operational validation.
+
+## Release baseline
+1. Lint and typecheck.
+2. Security gate scripts.
+3. Convex package tests and web E2E.
+4. Review incident and vulnerability reporting workflow.
+
+## Incident readiness
+- Ensure auditability for critical events.
+- Keep rollback and communication paths documented.
+
+## Canonical docs
+- [Security and operations release baseline](${ossDocsBase}/security-and-operations.md)
+- [Source of truth contract](${ossDocsBase}/source-of-truth.md)
+- [Repository security policy](${repoDocsBase}/SECURITY.md)`,
+ },
+ ],
+ },
+ ];
+
+ const collectionIds: Id<"collections">[] = [];
+ const articleIds: Id<"articles">[] = [];
+ for (let ci = 0; ci < collectionsData.length; ci++) {
+ const c = collectionsData[ci];
+ const slug = `${c.name.toLowerCase().replace(/\s+/g, "-")}-${LANDING_DEMO_SLUG_SUFFIX}`;
+ const collectionId = await ctx.db.insert("collections", {
+ workspaceId,
+ name: c.name,
+ slug,
+ description: c.desc,
+ order: ci,
+ createdAt: now - 14 * DAY,
+ updatedAt: now,
+ });
+ collectionIds.push(collectionId);
+
+ for (let ai = 0; ai < c.articles.length; ai++) {
+ const a = c.articles[ai];
+ const articleId = await ctx.db.insert("articles", {
+ workspaceId,
+ collectionId,
+ title: a.title,
+ slug: `${a.title.toLowerCase().replace(/\s+/g, "-")}-${ci}-${ai}-${LANDING_DEMO_SLUG_SUFFIX}`.toLowerCase(),
+ content: `${a.content}${LANDING_DEMO_ARTICLE_CONTENT_SUFFIX}`,
+ status: "published",
+ order: ai,
+ createdAt: now - 14 * DAY + ai * DAY,
+ updatedAt: now,
+ publishedAt: now - 14 * DAY + ai * DAY,
+ });
+ articleIds.push(articleId);
+ }
+ }
+
+ // ── Outbound Messages ────────────────────────────────────────
+ const postMessageId = await ctx.db.insert("outboundMessages", {
+ workspaceId,
+ name: `${LANDING_DEMO_PREFIX}Welcome Post`,
+ type: "post",
+ content: {
+ title: "Welcome to Opencom!",
+ body: "The open-source customer messaging platform. Explore live chat, product tours, surveys, and a full knowledge base — all self-hosted.",
+ buttons: [
+ { text: "Start a Conversation", action: "open_new_conversation" as const },
+ { text: "Dismiss", action: "dismiss" as const },
+ ],
+ clickAction: {
+ type: "open_new_conversation" as const,
+ },
+ },
+ status: "active",
+ triggers: { type: "time_on_page", delaySeconds: 10 },
+ frequency: "once",
+ priority: 100,
+ createdAt: now - 7 * DAY,
+ updatedAt: now,
+ });
+
+ const bannerMessageId = await ctx.db.insert("outboundMessages", {
+ workspaceId,
+ name: `${LANDING_DEMO_PREFIX}Docs Banner`,
+ type: "banner",
+ content: {
+ text: "Read the docs to deploy Opencom on your own infrastructure in minutes.",
+ style: "floating" as const,
+ dismissible: true,
+ buttons: [{ text: "View Docs", action: "url" as const, url: "https://opencom.dev/docs" }],
+ clickAction: {
+ type: "open_url" as const,
+ url: "https://opencom.dev/docs",
+ },
+ },
+ status: "active",
+ triggers: { type: "time_on_page", delaySeconds: 30 },
+ frequency: "once",
+ priority: 90,
+ createdAt: now - 7 * DAY,
+ updatedAt: now,
+ });
+
+ // ── Survey ───────────────────────────────────────────────────
+ const surveyId = await ctx.db.insert("surveys", {
+ workspaceId,
+ name: `${LANDING_DEMO_PREFIX}Landing NPS`,
+ description: "Quick NPS survey for landing page visitors",
+ format: "small",
+ status: "active",
+ questions: [
+ {
+ id: "q_landing_nps",
+ type: "nps",
+ title: "How likely are you to recommend Opencom to a colleague?",
+ required: true,
+ },
+ ],
+ thankYouStep: {
+ title: "Thank you!",
+ description: "Your feedback helps us improve Opencom.",
+ },
+ triggers: { type: "time_on_page", delaySeconds: 60 },
+ frequency: "once",
+ createdAt: now - 7 * DAY,
+ updatedAt: now,
+ });
+
+ // ── Tooltips ─────────────────────────────────────────────────
+ const tooltipData = [
+ {
+ name: "Hero CTA Tooltip",
+ selector: "[data-tour-target='hero-primary-cta']",
+ content: "Open the hosted onboarding flow to get a live Opencom workspace running quickly.",
+ },
+ {
+ name: "Tour Showcase Tooltip",
+ selector: "[data-tour-target='showcase-product-tour']",
+ content:
+ "Preview how native product tours look when attached directly to your app interface.",
+ },
+ {
+ name: "GitHub Nav Tooltip",
+ selector: "[data-tour-target='nav-github']",
+ content: "Star us on GitHub to show your support and stay updated on new releases.",
+ },
+ ];
+
+ const tooltipIds: Id<"tooltips">[] = [];
+ for (const t of tooltipData) {
+ const tooltipId = await ctx.db.insert("tooltips", {
+ workspaceId,
+ name: `${LANDING_DEMO_PREFIX}${t.name}`,
+ elementSelector: t.selector,
+ content: t.content,
+ triggerType: "hover",
+ createdAt: now - 7 * DAY,
+ updatedAt: now,
+ });
+ tooltipIds.push(tooltipId);
+ }
+
+ // ── Messenger Settings ───────────────────────────────────────
+ const existingSettings = await ctx.db
+ .query("messengerSettings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .first();
+
+ if (existingSettings) {
+ await ctx.db.patch(existingSettings._id, {
+ primaryColor: "#792cd4",
+ backgroundColor: "#792cd4",
+ welcomeMessage:
+ "Hey there! Welcome to Opencom — the open-source customer messaging platform. Ask us anything or explore the widget features.",
+ launcherPosition: "right",
+ updatedAt: now,
+ });
+ } else {
+ await ctx.db.insert("messengerSettings", {
+ workspaceId,
+ primaryColor: "#792cd4",
+ backgroundColor: "#792cd4",
+ themeMode: "light",
+ launcherPosition: "right",
+ launcherSideSpacing: 20,
+ launcherBottomSpacing: 20,
+ showLauncher: true,
+ welcomeMessage:
+ "Hey there! Welcome to Opencom — the open-source customer messaging platform. Ask us anything or explore the widget features.",
+ showTeammateAvatars: true,
+ supportedLanguages: ["en"],
+ defaultLanguage: "en",
+ mobileEnabled: true,
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+
+ return {
+ tourId,
+ tourSteps: stepIds.length,
+ checklistId,
+ collections: collectionIds.length,
+ articles: articleIds.length,
+ outboundMessages: { postMessageId, bannerMessageId },
+ surveyId,
+ tooltips: tooltipIds.length,
+ };
+ },
+});
+
+export const landingDemoMutations: Record> = {
+ cleanupLandingDemo,
+ seedLandingDemo,
+} as const;
diff --git a/packages/convex/convex/testData/seeds.ts b/packages/convex/convex/testData/seeds.ts
new file mode 100644
index 0000000..2135d81
--- /dev/null
+++ b/packages/convex/convex/testData/seeds.ts
@@ -0,0 +1,723 @@
+import { internalMutation } from "../_generated/server";
+import { v } from "convex/values";
+import { Id } from "../_generated/dataModel";
+import { formatReadableVisitorId } from "../visitorReadableId";
+
+const E2E_TEST_PREFIX = "e2e_test_";
+
+function requireTestDataEnabled() {
+ if (process.env.ALLOW_TEST_DATA !== "true") {
+ throw new Error("Test data mutations are disabled");
+ }
+}
+
+const seedTour = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.optional(v.string()),
+ status: v.optional(v.union(v.literal("draft"), v.literal("active"), v.literal("archived"))),
+ targetPageUrl: v.optional(v.string()),
+ steps: v.optional(
+ v.array(
+ v.object({
+ type: v.union(v.literal("pointer"), v.literal("post"), v.literal("video")),
+ title: v.optional(v.string()),
+ content: v.string(),
+ elementSelector: v.optional(v.string()),
+ routePath: v.optional(v.string()),
+ advanceOn: v.optional(
+ v.union(v.literal("click"), v.literal("elementClick"), v.literal("fieldFill"))
+ ),
+ position: v.optional(
+ v.union(
+ v.literal("auto"),
+ v.literal("left"),
+ v.literal("right"),
+ v.literal("above"),
+ v.literal("below")
+ )
+ ),
+ size: v.optional(v.union(v.literal("small"), v.literal("large"))),
+ })
+ )
+ ),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const name = args.name || `${E2E_TEST_PREFIX}tour_${randomSuffix}`;
+
+ const tourId = await ctx.db.insert("tours", {
+ workspaceId: args.workspaceId,
+ name,
+ description: "E2E test tour",
+ status: args.status || "active",
+ targetingRules: args.targetPageUrl
+ ? {
+ pageUrl: args.targetPageUrl,
+ }
+ : undefined,
+ displayMode: "first_time_only",
+ priority: 100,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ const steps = args.steps || [
+ {
+ type: "post" as const,
+ title: "Welcome",
+ content: "Welcome to the E2E test tour!",
+ },
+ {
+ type: "pointer" as const,
+ title: "Step 1",
+ content: "This is the first step",
+ elementSelector: "[data-testid='tour-target-1']",
+ },
+ {
+ type: "pointer" as const,
+ title: "Step 2",
+ content: "This is the second step",
+ elementSelector: "[data-testid='tour-target-2']",
+ },
+ ];
+
+ const stepIds: Id<"tourSteps">[] = [];
+ for (let i = 0; i < steps.length; i++) {
+ const step = steps[i];
+ const stepId = await ctx.db.insert("tourSteps", {
+ workspaceId: args.workspaceId,
+ tourId,
+ type: step.type,
+ order: i,
+ title: step.title,
+ content: step.content,
+ elementSelector: step.elementSelector,
+ routePath: step.routePath,
+ position: step.position ?? "auto",
+ size: step.size ?? "small",
+ advanceOn: step.advanceOn ?? "click",
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+ stepIds.push(stepId);
+ }
+
+ return { tourId, stepIds, name };
+ },
+});
+
+/**
+ * Seeds a test survey with questions for E2E testing.
+ */
+const seedSurvey = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.optional(v.string()),
+ format: v.optional(v.union(v.literal("small"), v.literal("large"))),
+ status: v.optional(
+ v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))
+ ),
+ questionType: v.optional(
+ v.union(
+ v.literal("nps"),
+ v.literal("numeric_scale"),
+ v.literal("star_rating"),
+ v.literal("emoji_rating"),
+ v.literal("short_text"),
+ v.literal("multiple_choice")
+ )
+ ),
+ triggerType: v.optional(
+ v.union(
+ v.literal("immediate"),
+ v.literal("page_visit"),
+ v.literal("time_on_page"),
+ v.literal("event")
+ )
+ ),
+ triggerPageUrl: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const name = args.name || `${E2E_TEST_PREFIX}survey_${randomSuffix}`;
+ const format = args.format || "small";
+ const questionType = args.questionType || "nps";
+
+ const questionId = `q_${randomSuffix}`;
+ const questions = [
+ {
+ id: questionId,
+ type: questionType,
+ title:
+ questionType === "nps"
+ ? "How likely are you to recommend us?"
+ : "What do you think of our product?",
+ required: true,
+ ...(questionType === "multiple_choice"
+ ? {
+ options: {
+ choices: ["Excellent", "Good", "Average", "Poor"],
+ },
+ }
+ : {}),
+ ...(questionType === "numeric_scale"
+ ? {
+ options: {
+ scaleStart: 1,
+ scaleEnd: 5,
+ startLabel: "Poor",
+ endLabel: "Excellent",
+ },
+ }
+ : {}),
+ },
+ ];
+
+ const surveyId = await ctx.db.insert("surveys", {
+ workspaceId: args.workspaceId,
+ name,
+ description: "E2E test survey",
+ format,
+ status: args.status || "active",
+ questions,
+ introStep:
+ format === "large"
+ ? {
+ title: "Quick Survey",
+ description: "Help us improve by answering a quick question",
+ buttonText: "Start",
+ }
+ : undefined,
+ thankYouStep: {
+ title: "Thank you!",
+ description: "Your feedback has been recorded",
+ buttonText: "Done",
+ },
+ showProgressBar: true,
+ showDismissButton: true,
+ triggers: args.triggerType
+ ? {
+ type: args.triggerType,
+ pageUrl: args.triggerPageUrl,
+ pageUrlMatch: args.triggerPageUrl ? "contains" : undefined,
+ delaySeconds: args.triggerType === "time_on_page" ? 5 : undefined,
+ }
+ : {
+ type: "immediate",
+ },
+ frequency: "once",
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { surveyId, name, questionId };
+ },
+});
+
+/**
+ * Seeds a test carousel with slides for E2E testing.
+ */
+const seedCarousel = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.optional(v.string()),
+ status: v.optional(
+ v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))
+ ),
+ screens: v.optional(
+ v.array(
+ v.object({
+ title: v.optional(v.string()),
+ body: v.optional(v.string()),
+ imageUrl: v.optional(v.string()),
+ })
+ )
+ ),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const name = args.name || `${E2E_TEST_PREFIX}carousel_${randomSuffix}`;
+
+ type CarouselScreen = {
+ id: string;
+ title?: string;
+ body?: string;
+ imageUrl?: string;
+ buttons?: Array<{
+ text: string;
+ action: "url" | "dismiss" | "next" | "deeplink";
+ url?: string;
+ deepLink?: string;
+ }>;
+ };
+
+ let screens: CarouselScreen[];
+
+ if (args.screens) {
+ screens = args.screens.map((s, i) => ({
+ id: `screen_${i}_${randomSuffix}`,
+ title: s.title,
+ body: s.body,
+ imageUrl: s.imageUrl,
+ }));
+ } else {
+ screens = [
+ {
+ id: `screen_1_${randomSuffix}`,
+ title: "Welcome!",
+ body: "This is the first slide of the E2E test carousel",
+ buttons: [
+ { text: "Next", action: "next" },
+ { text: "Dismiss", action: "dismiss" },
+ ],
+ },
+ {
+ id: `screen_2_${randomSuffix}`,
+ title: "Feature Highlight",
+ body: "Check out our amazing features",
+ buttons: [{ text: "Next", action: "next" }],
+ },
+ {
+ id: `screen_3_${randomSuffix}`,
+ title: "Get Started",
+ body: "Ready to begin?",
+ buttons: [
+ { text: "Done", action: "dismiss" },
+ { text: "Learn More", action: "url", url: "https://example.com" },
+ ],
+ },
+ ];
+ }
+
+ const carouselId = await ctx.db.insert("carousels", {
+ workspaceId: args.workspaceId,
+ name,
+ screens,
+ status: args.status || "active",
+ priority: 100,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { carouselId, name };
+ },
+});
+
+/**
+ * Seeds a test outbound message for E2E testing.
+ */
+const seedOutboundMessage = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.optional(v.string()),
+ type: v.optional(v.union(v.literal("chat"), v.literal("post"), v.literal("banner"))),
+ status: v.optional(
+ v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))
+ ),
+ triggerType: v.optional(
+ v.union(
+ v.literal("immediate"),
+ v.literal("page_visit"),
+ v.literal("time_on_page"),
+ v.literal("scroll_depth"),
+ v.literal("event")
+ )
+ ),
+ triggerPageUrl: v.optional(v.string()),
+ senderId: v.optional(v.id("users")),
+ clickAction: v.optional(
+ v.object({
+ type: v.union(
+ v.literal("open_messenger"),
+ v.literal("open_new_conversation"),
+ v.literal("open_widget_tab"),
+ v.literal("open_help_article"),
+ v.literal("open_url"),
+ v.literal("dismiss")
+ ),
+ tabId: v.optional(v.string()),
+ articleId: v.optional(v.id("articles")),
+ url: v.optional(v.string()),
+ prefillMessage: v.optional(v.string()),
+ })
+ ),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const name = args.name || `${E2E_TEST_PREFIX}message_${randomSuffix}`;
+ const type = args.type || "chat";
+
+ const content: {
+ text?: string;
+ senderId?: Id<"users">;
+ title?: string;
+ body?: string;
+ style?: "inline" | "floating";
+ dismissible?: boolean;
+ buttons?: Array<{
+ text: string;
+ action:
+ | "url"
+ | "dismiss"
+ | "tour"
+ | "open_new_conversation"
+ | "open_help_article"
+ | "open_widget_tab";
+ url?: string;
+ }>;
+ clickAction?: {
+ type:
+ | "open_messenger"
+ | "open_new_conversation"
+ | "open_widget_tab"
+ | "open_help_article"
+ | "open_url"
+ | "dismiss";
+ tabId?: string;
+ articleId?: Id<"articles">;
+ url?: string;
+ prefillMessage?: string;
+ };
+ } = {};
+
+ if (type === "chat") {
+ content.text = "Hello! This is an E2E test message. How can we help you today?";
+ content.senderId = args.senderId;
+ } else if (type === "post") {
+ content.title = "E2E Test Announcement";
+ content.body = "This is a test post message for E2E testing.";
+ content.buttons = [
+ { text: "Learn More", action: "url", url: "https://example.com" },
+ { text: "Dismiss", action: "dismiss" },
+ ];
+ } else if (type === "banner") {
+ content.text = "E2E Test Banner - Limited time offer!";
+ content.style = "floating";
+ content.dismissible = true;
+ content.buttons = [{ text: "View Offer", action: "url", url: "https://example.com" }];
+ }
+
+ if (args.clickAction) {
+ content.clickAction = args.clickAction;
+ }
+
+ const messageId = await ctx.db.insert("outboundMessages", {
+ workspaceId: args.workspaceId,
+ name,
+ type,
+ content,
+ status: args.status || "active",
+ triggers: {
+ type: args.triggerType || "immediate",
+ pageUrl: args.triggerPageUrl,
+ pageUrlMatch: args.triggerPageUrl ? "contains" : undefined,
+ delaySeconds: args.triggerType === "time_on_page" ? 3 : undefined,
+ scrollPercent: args.triggerType === "scroll_depth" ? 50 : undefined,
+ },
+ frequency: "once",
+ priority: 100,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { messageId, name };
+ },
+});
+
+/**
+ * Seeds test articles in a collection for E2E testing.
+ */
+const seedArticles = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ collectionName: v.optional(v.string()),
+ articleCount: v.optional(v.number()),
+ includesDraft: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const collectionName = args.collectionName || `${E2E_TEST_PREFIX}collection_${randomSuffix}`;
+ const articleCount = args.articleCount || 3;
+
+ // Create collection
+ const collectionId = await ctx.db.insert("collections", {
+ workspaceId: args.workspaceId,
+ name: collectionName,
+ slug: collectionName.toLowerCase().replace(/\s+/g, "-"),
+ description: "E2E test article collection",
+ order: 0,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ // Create articles
+ const articleIds: Id<"articles">[] = [];
+ const articles = [
+ {
+ title: "Getting Started Guide",
+ content:
+ "# Getting Started\n\nWelcome to our platform! This guide will help you get started quickly.\n\n## Step 1: Create an Account\n\nFirst, sign up for an account...\n\n## Step 2: Configure Settings\n\nNext, configure your settings...",
+ },
+ {
+ title: "FAQ",
+ content:
+ "# Frequently Asked Questions\n\n## How do I reset my password?\n\nYou can reset your password by clicking the forgot password link.\n\n## How do I contact support?\n\nReach out to us through the chat widget.",
+ },
+ {
+ title: "Troubleshooting Common Issues",
+ content:
+ "# Troubleshooting\n\n## Login Issues\n\nIf you can't log in, try clearing your browser cache.\n\n## Performance Issues\n\nMake sure you have a stable internet connection.",
+ },
+ ];
+
+ for (let i = 0; i < articleCount; i++) {
+ const article = articles[i % articles.length];
+ const isDraft = args.includesDraft && i === articleCount - 1;
+
+ const articleId = await ctx.db.insert("articles", {
+ workspaceId: args.workspaceId,
+ collectionId,
+ title: `${E2E_TEST_PREFIX}${article.title}`,
+ slug: `${E2E_TEST_PREFIX}${article.title.toLowerCase().replace(/\s+/g, "-")}-${randomSuffix}-${i}`,
+ content: article.content,
+ status: isDraft ? "draft" : "published",
+ order: i,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ publishedAt: isDraft ? undefined : timestamp,
+ });
+ articleIds.push(articleId);
+ }
+
+ return { collectionId, collectionName, articleIds };
+ },
+});
+
+/**
+ * Seeds a test visitor with custom attributes for E2E testing.
+ */
+const seedVisitor = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ email: v.optional(v.string()),
+ name: v.optional(v.string()),
+ externalUserId: v.optional(v.string()),
+ customAttributes: v.optional(v.any()),
+ location: v.optional(
+ v.object({
+ city: v.optional(v.string()),
+ region: v.optional(v.string()),
+ country: v.optional(v.string()),
+ countryCode: v.optional(v.string()),
+ })
+ ),
+ device: v.optional(
+ v.object({
+ browser: v.optional(v.string()),
+ os: v.optional(v.string()),
+ deviceType: v.optional(v.string()),
+ })
+ ),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const sessionId = `${E2E_TEST_PREFIX}session_${timestamp}_${randomSuffix}`;
+
+ const visitorId = await ctx.db.insert("visitors", {
+ sessionId,
+ workspaceId: args.workspaceId,
+ email: args.email || `${E2E_TEST_PREFIX}visitor_${randomSuffix}@test.opencom.dev`,
+ name: args.name || `E2E Test Visitor ${randomSuffix}`,
+ externalUserId: args.externalUserId,
+ customAttributes: args.customAttributes || {
+ plan: "free",
+ signupDate: new Date().toISOString(),
+ },
+ location: args.location || {
+ city: "San Francisco",
+ region: "California",
+ country: "United States",
+ countryCode: "US",
+ },
+ device: args.device || {
+ browser: "Chrome",
+ os: "macOS",
+ deviceType: "desktop",
+ },
+ firstSeenAt: timestamp,
+ lastSeenAt: timestamp,
+ createdAt: timestamp,
+ });
+
+ await ctx.db.patch(visitorId, {
+ readableId: formatReadableVisitorId(visitorId),
+ });
+
+ return { visitorId, sessionId };
+ },
+});
+
+/**
+ * Seeds a test segment for E2E testing.
+ */
+const seedSegment = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.optional(v.string()),
+ audienceRules: v.optional(v.any()),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const name = args.name || `${E2E_TEST_PREFIX}segment_${randomSuffix}`;
+
+ const defaultRules = {
+ type: "group" as const,
+ operator: "and" as const,
+ conditions: [
+ {
+ type: "condition" as const,
+ property: { source: "system" as const, key: "email" },
+ operator: "is_set" as const,
+ },
+ ],
+ };
+
+ const segmentId = await ctx.db.insert("segments", {
+ workspaceId: args.workspaceId,
+ name,
+ description: "E2E test segment",
+ audienceRules: args.audienceRules || defaultRules,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { segmentId, name };
+ },
+});
+
+/**
+ * Seeds messenger settings for E2E testing.
+ */
+const seedMessengerSettings = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ primaryColor: v.optional(v.string()),
+ welcomeMessage: v.optional(v.string()),
+ launcherPosition: v.optional(v.union(v.literal("right"), v.literal("left"))),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const timestamp = Date.now();
+
+ // Check if settings exist, update or create
+ const existing = await ctx.db
+ .query("messengerSettings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .first();
+
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ primaryColor: args.primaryColor || "#792cd4",
+ backgroundColor: "#792cd4",
+ welcomeMessage: args.welcomeMessage || "Hello! How can we help you today?",
+ launcherPosition: args.launcherPosition || "right",
+ updatedAt: timestamp,
+ });
+ return { settingsId: existing._id };
+ }
+
+ const settingsId = await ctx.db.insert("messengerSettings", {
+ workspaceId: args.workspaceId,
+ primaryColor: args.primaryColor || "#792cd4",
+ backgroundColor: "#792cd4",
+ themeMode: "light",
+ launcherPosition: args.launcherPosition || "right",
+ launcherSideSpacing: 20,
+ launcherBottomSpacing: 20,
+ showLauncher: true,
+ welcomeMessage: args.welcomeMessage || "Hello! How can we help you today?",
+ showTeammateAvatars: true,
+ supportedLanguages: ["en"],
+ defaultLanguage: "en",
+ mobileEnabled: true,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { settingsId };
+ },
+});
+
+/**
+ * Seeds AI agent settings for E2E testing.
+ */
+const seedAIAgentSettings = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ enabled: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ requireTestDataEnabled();
+ const timestamp = Date.now();
+
+ // Check if settings exist, update or create
+ const existing = await ctx.db
+ .query("aiAgentSettings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .first();
+
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ enabled: args.enabled ?? true,
+ updatedAt: timestamp,
+ });
+ return { settingsId: existing._id };
+ }
+
+ const settingsId = await ctx.db.insert("aiAgentSettings", {
+ workspaceId: args.workspaceId,
+ enabled: args.enabled ?? true,
+ knowledgeSources: ["articles"],
+ confidenceThreshold: 0.7,
+ personality: "helpful and friendly",
+ handoffMessage: "Let me connect you with a human agent.",
+ model: "gpt-5-nano",
+ suggestionsEnabled: true,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { settingsId };
+ },
+});
+
+/**
+ * Cleans up all test data with the e2e_test_ prefix from a workspace.
+ */
+
+export const seedMutations: Record> = {
+ seedTour,
+ seedSurvey,
+ seedCarousel,
+ seedOutboundMessage,
+ seedArticles,
+ seedVisitor,
+ seedSegment,
+ seedMessengerSettings,
+ seedAIAgentSettings,
+} as const;
diff --git a/packages/convex/convex/testing/helpers.ts b/packages/convex/convex/testing/helpers.ts
index 4804635..4467231 100644
--- a/packages/convex/convex/testing/helpers.ts
+++ b/packages/convex/convex/testing/helpers.ts
@@ -1,2624 +1,94 @@
import { internalMutation } from "../_generated/server";
-import { internal } from "../_generated/api";
-import { v } from "convex/values";
-import { Id } from "../_generated/dataModel";
-import { formatReadableVisitorId } from "../visitorReadableId";
-import {
- runSeriesEvaluateEnrollmentForVisitor as runSeriesEvaluateEnrollmentForVisitorInternal,
- runSeriesProcessWaitingProgress as runSeriesProcessWaitingProgressInternal,
- runSeriesResumeWaitingForEvent as runSeriesResumeWaitingForEventInternal,
-} from "../series/scheduler";
-
-const seriesEntryTriggerTestValidator = v.object({
- source: v.union(
- v.literal("event"),
- v.literal("auto_event"),
- v.literal("visitor_attribute_changed"),
- v.literal("visitor_state_changed")
- ),
- eventName: v.optional(v.string()),
- attributeKey: v.optional(v.string()),
- fromValue: v.optional(v.string()),
- toValue: v.optional(v.string()),
-});
-
-const seriesProgressStatusValidator = v.union(
- v.literal("active"),
- v.literal("waiting"),
- v.literal("completed"),
- v.literal("exited"),
- v.literal("goal_reached"),
- v.literal("failed")
-);
-
-/**
- * Creates an isolated test workspace with a unique name.
- * Use this at the start of each test suite to ensure data isolation.
- */
-export const createTestWorkspace = internalMutation({
- args: {
- name: v.optional(v.string()),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const workspaceName = args.name || `test-workspace-${timestamp}-${randomSuffix}`;
-
- const workspaceId = await ctx.db.insert("workspaces", {
- name: workspaceName,
- createdAt: timestamp,
- helpCenterAccessPolicy: "public",
- signupMode: "invite-only",
- authMethods: ["password", "otp"],
- });
-
- // Create a default admin user for the workspace
- const email = `admin-${randomSuffix}@test.opencom.dev`;
- const userId = await ctx.db.insert("users", {
- email,
- name: "Test Admin",
- workspaceId,
- role: "admin",
- createdAt: timestamp,
- });
-
- await ctx.db.insert("workspaceMembers", {
- userId,
- workspaceId,
- role: "admin",
- createdAt: timestamp,
- });
-
- return { workspaceId, userId, name: workspaceName };
- },
-});
-
-/**
- * Updates workspace help-center access policy directly (bypasses auth).
- */
-export const updateTestHelpCenterAccessPolicy = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- policy: v.union(v.literal("public"), v.literal("restricted")),
- },
- handler: async (ctx, args) => {
- await ctx.db.patch(args.workspaceId, {
- helpCenterAccessPolicy: args.policy,
- });
- },
-});
-
-/**
- * Runs series enrollment evaluation for a visitor with explicit trigger context.
- */
-export const runSeriesEvaluateEnrollmentForVisitor: ReturnType =
- internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- visitorId: v.id("visitors"),
- triggerContext: seriesEntryTriggerTestValidator,
- },
- handler: async (ctx, args): Promise => {
- return await runSeriesEvaluateEnrollmentForVisitorInternal(ctx, args);
- },
- });
-
-/**
- * Runs event-based wait resumption for waiting series progress records.
- */
-export const runSeriesResumeWaitingForEvent: ReturnType = internalMutation(
- {
- args: {
- workspaceId: v.id("workspaces"),
- visitorId: v.id("visitors"),
- eventName: v.string(),
- },
- handler: async (ctx, args): Promise => {
- return await runSeriesResumeWaitingForEventInternal(ctx, args);
- },
- }
-);
-
-/**
- * Runs wait backstop processing for active series.
- */
-export const runSeriesProcessWaitingProgress: ReturnType =
- internalMutation({
- args: {
- seriesLimit: v.optional(v.number()),
- waitingLimitPerSeries: v.optional(v.number()),
- },
- handler: async (ctx, args): Promise => {
- return await runSeriesProcessWaitingProgressInternal(ctx, args);
- },
- });
-
-/**
- * Returns the current progress record for a visitor in a series.
- */
-export const getSeriesProgressForVisitorSeries = internalMutation({
- args: {
- visitorId: v.id("visitors"),
- seriesId: v.id("series"),
- },
- handler: async (ctx, args) => {
- return await ctx.db
- .query("seriesProgress")
- .withIndex("by_visitor_series", (q) =>
- q.eq("visitorId", args.visitorId).eq("seriesId", args.seriesId)
- )
- .first();
- },
-});
-
-/**
- * Patches series progress fields for deterministic runtime retry/backstop tests.
- */
-export const updateSeriesProgressForTest = internalMutation({
- args: {
- progressId: v.id("seriesProgress"),
- status: v.optional(seriesProgressStatusValidator),
- waitUntil: v.optional(v.number()),
- waitEventName: v.optional(v.string()),
- attemptCount: v.optional(v.number()),
- lastExecutionError: v.optional(v.string()),
- clearWaitUntil: v.optional(v.boolean()),
- clearWaitEventName: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const progress = await ctx.db.get(args.progressId);
- if (!progress) {
- throw new Error("Progress not found");
- }
-
- await ctx.db.patch(args.progressId, {
- ...(args.status !== undefined ? { status: args.status } : {}),
- ...(args.waitUntil !== undefined ? { waitUntil: args.waitUntil } : {}),
- ...(args.waitEventName !== undefined ? { waitEventName: args.waitEventName } : {}),
- ...(args.attemptCount !== undefined ? { attemptCount: args.attemptCount } : {}),
- ...(args.lastExecutionError !== undefined
- ? { lastExecutionError: args.lastExecutionError }
- : {}),
- ...(args.clearWaitUntil ? { waitUntil: undefined } : {}),
- ...(args.clearWaitEventName ? { waitEventName: undefined } : {}),
- });
-
- return await ctx.db.get(args.progressId);
- },
-});
-
-/**
- * Creates a test audit log entry directly (bypasses auth) for deterministic audit E2E flows.
- */
-export const createTestAuditLog = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- action: v.string(),
- actorType: v.optional(v.union(v.literal("user"), v.literal("system"), v.literal("api"))),
- actorId: v.optional(v.id("users")),
- resourceType: v.optional(v.string()),
- resourceId: v.optional(v.string()),
- metadata: v.optional(v.any()),
- timestamp: v.optional(v.number()),
- },
- handler: async (ctx, args) => {
- const now = args.timestamp ?? Date.now();
- const logId = await ctx.db.insert("auditLogs", {
- workspaceId: args.workspaceId,
- actorId: args.actorId,
- actorType: args.actorType ?? "system",
- action: args.action,
- resourceType: args.resourceType ?? "test",
- resourceId: args.resourceId,
- metadata: args.metadata,
- timestamp: now,
- });
-
- return { logId, timestamp: now };
- },
-});
-
-/**
- * Creates a test user in the specified workspace.
- */
-export const createTestUser = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- email: v.optional(v.string()),
- name: v.optional(v.string()),
- role: v.optional(v.union(v.literal("admin"), v.literal("agent"))),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const email = args.email || `test-${randomSuffix}@test.opencom.dev`;
- const name = args.name || `Test User ${randomSuffix}`;
- const role = args.role || "agent";
-
- const userId = await ctx.db.insert("users", {
- email,
- name,
- workspaceId: args.workspaceId,
- role,
- createdAt: timestamp,
- });
-
- await ctx.db.insert("workspaceMembers", {
- userId,
- workspaceId: args.workspaceId,
- role,
- createdAt: timestamp,
- });
-
- return { userId, email, name };
- },
-});
-
-/**
- * Creates a test session token for a visitor (used by tests that call visitor-facing endpoints).
- */
-export const createTestSessionToken = internalMutation({
- args: {
- visitorId: v.id("visitors"),
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const randomHex = Array.from({ length: 32 }, () =>
- Math.floor(Math.random() * 256)
- .toString(16)
- .padStart(2, "0")
- ).join("");
- const token = `wst_test_${randomHex}`;
-
- await ctx.db.insert("widgetSessions", {
- token,
- visitorId: args.visitorId,
- workspaceId: args.workspaceId,
- identityVerified: false,
- expiresAt: now + 24 * 60 * 60 * 1000, // 24 hours
- createdAt: now,
- });
-
- return { sessionToken: token };
- },
-});
-
-/**
- * Creates a test visitor in the specified workspace.
- */
-export const createTestVisitor = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- email: v.optional(v.string()),
- name: v.optional(v.string()),
- externalUserId: v.optional(v.string()),
- customAttributes: v.optional(v.any()),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const sessionId = `test-session-${timestamp}-${randomSuffix}`;
-
- const visitorId = await ctx.db.insert("visitors", {
- sessionId,
- workspaceId: args.workspaceId,
- email: args.email,
- name: args.name,
- externalUserId: args.externalUserId,
- customAttributes: args.customAttributes,
- createdAt: timestamp,
- firstSeenAt: timestamp,
- lastSeenAt: timestamp,
- });
-
- await ctx.db.patch(visitorId, {
- readableId: formatReadableVisitorId(visitorId),
- });
-
- return { visitorId, sessionId };
- },
-});
-
-/**
- * Creates a test conversation in the specified workspace.
- */
-export const createTestConversation = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- visitorId: v.optional(v.id("visitors")),
- userId: v.optional(v.id("users")),
- assignedAgentId: v.optional(v.id("users")),
- status: v.optional(v.union(v.literal("open"), v.literal("closed"), v.literal("snoozed"))),
- firstResponseAt: v.optional(v.number()),
- resolvedAt: v.optional(v.number()),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
-
- const conversationId = await ctx.db.insert("conversations", {
- workspaceId: args.workspaceId,
- visitorId: args.visitorId,
- userId: args.userId,
- assignedAgentId: args.assignedAgentId,
- status: args.status || "open",
- createdAt: timestamp,
- updatedAt: timestamp,
- unreadByAgent: 0,
- unreadByVisitor: 0,
- firstResponseAt: args.firstResponseAt,
- resolvedAt: args.resolvedAt,
- aiWorkflowState: "none",
- });
-
- return { conversationId };
- },
-});
-
-/**
- * Creates a test survey directly (bypasses auth on surveys.create).
- */
-export const createTestSurvey = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.optional(v.string()),
- status: v.optional(
- v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))
- ),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const randomSuffix = Math.random().toString(36).slice(2, 8);
- const surveyId = await ctx.db.insert("surveys", {
- workspaceId: args.workspaceId,
- name: args.name ?? `Test Survey ${randomSuffix}`,
- format: "small",
- status: args.status ?? "active",
- questions: [
- {
- id: "q1",
- type: "nps" as const,
- title: "How likely are you to recommend us?",
- required: true,
- },
- ],
- frequency: "once",
- showProgressBar: true,
- showDismissButton: true,
- triggers: { type: "immediate" as const },
- createdAt: now,
- updatedAt: now,
- });
-
- return { surveyId };
- },
-});
-
-/**
- * Forces a tooltip authoring session to expire for deterministic test scenarios.
- */
-export const expireTooltipAuthoringSession = internalMutation({
- args: {
- token: v.string(),
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- const session = await ctx.db
- .query("tooltipAuthoringSessions")
- .withIndex("by_token", (q) => q.eq("token", args.token))
- .first();
-
- if (!session) {
- throw new Error("Session not found");
- }
- if (session.workspaceId !== args.workspaceId) {
- throw new Error("Session workspace mismatch");
- }
-
- await ctx.db.patch(session._id, {
- expiresAt: Date.now() - 1000,
- status: "active",
- });
-
- return { sessionId: session._id };
- },
-});
-
-/**
- * Completes a tooltip authoring session for deterministic E2E flows.
- */
-export const completeTooltipAuthoringSession = internalMutation({
- args: {
- token: v.string(),
- workspaceId: v.id("workspaces"),
- elementSelector: v.string(),
- },
- handler: async (ctx, args) => {
- const session = await ctx.db
- .query("tooltipAuthoringSessions")
- .withIndex("by_token", (q) => q.eq("token", args.token))
- .first();
-
- if (!session) {
- throw new Error("Session not found");
- }
- if (session.workspaceId !== args.workspaceId) {
- throw new Error("Session workspace mismatch");
- }
-
- const quality = {
- score: 90,
- grade: "good" as const,
- warnings: [] as string[],
- signals: {
- matchCount: 1,
- depth: 1,
- usesNth: false,
- hasId: args.elementSelector.includes("#"),
- hasDataAttribute: args.elementSelector.includes("[data-"),
- classCount: (args.elementSelector.match(/\.[A-Za-z0-9_-]+/g) ?? []).length,
- usesWildcard: args.elementSelector.includes("*"),
- },
- };
-
- await ctx.db.patch(session._id, {
- selectedSelector: args.elementSelector,
- selectedSelectorQuality: quality,
- status: "completed",
- });
-
- if (session.tooltipId) {
- await ctx.db.patch(session.tooltipId, {
- elementSelector: args.elementSelector,
- selectorQuality: quality,
- updatedAt: Date.now(),
- });
- }
-
- return { sessionId: session._id };
- },
-});
-
-/**
- * Creates a test series directly (bypasses auth on series.create).
- */
-export const createTestSeries = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.optional(v.string()),
- status: v.optional(
- v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))
- ),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const randomSuffix = Math.random().toString(36).slice(2, 8);
-
- const seriesId = await ctx.db.insert("series", {
- workspaceId: args.workspaceId,
- name: args.name ?? `Test Series ${randomSuffix}`,
- status: args.status ?? "active",
- createdAt: now,
- updatedAt: now,
- });
-
- // Add a minimal entry block so evaluateEntry can traverse the series.
- await ctx.db.insert("seriesBlocks", {
- seriesId,
- type: "wait",
- position: { x: 0, y: 0 },
- config: {
- waitType: "duration",
- waitDuration: 1,
- waitUnit: "minutes",
- },
- createdAt: now,
- updatedAt: now,
- });
-
- return { seriesId };
- },
-});
-
-/**
- * Creates a test push campaign directly (bypasses auth on pushCampaigns.create).
- */
-export const createTestPushCampaign = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.optional(v.string()),
- title: v.optional(v.string()),
- body: v.optional(v.string()),
- targeting: v.optional(v.any()),
- audienceRules: v.optional(v.any()),
- status: v.optional(
- v.union(
- v.literal("draft"),
- v.literal("scheduled"),
- v.literal("sending"),
- v.literal("sent"),
- v.literal("paused")
- )
- ),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const randomSuffix = Math.random().toString(36).slice(2, 8);
-
- const campaignId = await ctx.db.insert("pushCampaigns", {
- workspaceId: args.workspaceId,
- name: args.name ?? `Test Push Campaign ${randomSuffix}`,
- title: args.title ?? "Test push title",
- body: args.body ?? "Test push body",
- targeting: args.targeting,
- audienceRules: args.audienceRules,
- status: args.status ?? "draft",
- createdAt: now,
- updatedAt: now,
- });
-
- return { campaignId };
- },
-});
-
-export const sendTestPushCampaign: ReturnType = internalMutation({
- args: {
- campaignId: v.id("pushCampaigns"),
- },
- handler: async (ctx, args): Promise => {
- return await ctx.runMutation((internal as any).pushCampaigns.sendForTesting, {
- id: args.campaignId,
- });
- },
-});
-
-export const getTestPendingPushCampaignRecipients: ReturnType =
- internalMutation({
- args: {
- campaignId: v.id("pushCampaigns"),
- limit: v.optional(v.number()),
- },
- handler: async (ctx, args): Promise => {
- return await ctx.runQuery((internal as any).pushCampaigns.getPendingRecipients, {
- campaignId: args.campaignId,
- limit: args.limit,
- });
- },
- });
-
-/**
- * Creates a test message in the specified conversation.
- */
-export const createTestMessage = internalMutation({
- args: {
- conversationId: v.id("conversations"),
- content: v.string(),
- senderType: v.union(
- v.literal("user"),
- v.literal("visitor"),
- v.literal("agent"),
- v.literal("bot")
- ),
- senderId: v.optional(v.string()),
- emailMessageId: v.optional(v.string()),
- externalEmailId: v.optional(v.string()),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
- const senderId = args.senderId || `test-sender-${timestamp}`;
-
- const emailId = args.emailMessageId || args.externalEmailId;
- const messageId = await ctx.db.insert("messages", {
- conversationId: args.conversationId,
- senderId,
- senderType: args.senderType,
- content: args.content,
- createdAt: timestamp,
- ...(emailId && {
- channel: "email" as const,
- emailMetadata: { messageId: emailId },
- deliveryStatus: "pending" as const,
- }),
- });
-
- await ctx.db.patch(args.conversationId, {
- lastMessageAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { messageId };
- },
-});
-
-export const createTestPushToken = internalMutation({
- args: {
- userId: v.id("users"),
- token: v.optional(v.string()),
- platform: v.optional(v.union(v.literal("ios"), v.literal("android"))),
- notificationsEnabled: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const token =
- args.token ??
- `ExponentPushToken[test-${args.userId}-${Math.random().toString(36).slice(2, 10)}]`;
- const tokenId = await ctx.db.insert("pushTokens", {
- userId: args.userId,
- token,
- platform: args.platform ?? "ios",
- notificationsEnabled: args.notificationsEnabled ?? true,
- createdAt: now,
- });
- return { tokenId, token };
- },
-});
-
-export const createTestVisitorPushToken = internalMutation({
- args: {
- visitorId: v.id("visitors"),
- token: v.optional(v.string()),
- platform: v.optional(v.union(v.literal("ios"), v.literal("android"))),
- notificationsEnabled: v.optional(v.boolean()),
- workspaceId: v.optional(v.id("workspaces")),
- },
- handler: async (ctx, args) => {
- const visitor = await ctx.db.get(args.visitorId);
- if (!visitor) {
- throw new Error("Visitor not found");
- }
-
- const now = Date.now();
- const token =
- args.token ??
- `ExponentPushToken[visitor-${args.visitorId}-${Math.random().toString(36).slice(2, 10)}]`;
- const tokenId = await ctx.db.insert("visitorPushTokens", {
- visitorId: args.visitorId,
- workspaceId: args.workspaceId ?? visitor.workspaceId,
- token,
- platform: args.platform ?? "ios",
- notificationsEnabled: args.notificationsEnabled ?? true,
- createdAt: now,
- updatedAt: now,
- });
- return { tokenId, token };
- },
-});
-
-export const upsertTestNotificationPreference = internalMutation({
- args: {
- userId: v.id("users"),
- workspaceId: v.id("workspaces"),
- muted: v.optional(v.boolean()),
- newVisitorMessageEmail: v.optional(v.boolean()),
- newVisitorMessagePush: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const existing = await ctx.db
- .query("notificationPreferences")
- .withIndex("by_user_workspace", (q) =>
- q.eq("userId", args.userId).eq("workspaceId", args.workspaceId)
- )
- .first();
-
- const now = Date.now();
- const nextNewVisitorMessage = {
- ...(existing?.events?.newVisitorMessage ?? {}),
- ...(args.newVisitorMessageEmail !== undefined ? { email: args.newVisitorMessageEmail } : {}),
- ...(args.newVisitorMessagePush !== undefined ? { push: args.newVisitorMessagePush } : {}),
- };
-
- const hasEventOverrides =
- nextNewVisitorMessage.email !== undefined || nextNewVisitorMessage.push !== undefined;
-
- if (existing) {
- await ctx.db.patch(existing._id, {
- ...(args.muted !== undefined ? { muted: args.muted } : {}),
- ...(hasEventOverrides
- ? {
- events: {
- ...(existing.events ?? {}),
- newVisitorMessage: nextNewVisitorMessage,
- },
- }
- : {}),
- updatedAt: now,
- });
- return { preferenceId: existing._id };
- }
-
- const preferenceId = await ctx.db.insert("notificationPreferences", {
- userId: args.userId,
- workspaceId: args.workspaceId,
- muted: args.muted ?? false,
- ...(hasEventOverrides
- ? {
- events: {
- newVisitorMessage: nextNewVisitorMessage,
- },
- }
- : {}),
- createdAt: now,
- updatedAt: now,
- });
- return { preferenceId };
- },
-});
-
-export const upsertTestWorkspaceNotificationDefaults = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- newVisitorMessageEmail: v.optional(v.boolean()),
- newVisitorMessagePush: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const existing = await ctx.db
- .query("workspaceNotificationDefaults")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .first();
-
- const now = Date.now();
- const nextNewVisitorMessage = {
- ...(existing?.events?.newVisitorMessage ?? {}),
- ...(args.newVisitorMessageEmail !== undefined ? { email: args.newVisitorMessageEmail } : {}),
- ...(args.newVisitorMessagePush !== undefined ? { push: args.newVisitorMessagePush } : {}),
- };
-
- const hasEventDefaults =
- nextNewVisitorMessage.email !== undefined || nextNewVisitorMessage.push !== undefined;
-
- if (existing) {
- await ctx.db.patch(existing._id, {
- ...(hasEventDefaults
- ? {
- events: {
- ...(existing.events ?? {}),
- newVisitorMessage: nextNewVisitorMessage,
- },
- }
- : {}),
- updatedAt: now,
- });
- return { defaultsId: existing._id };
- }
-
- const defaultsId = await ctx.db.insert("workspaceNotificationDefaults", {
- workspaceId: args.workspaceId,
- ...(hasEventDefaults
- ? {
- events: {
- newVisitorMessage: nextNewVisitorMessage,
- },
- }
- : {}),
- createdAt: now,
- updatedAt: now,
- });
- return { defaultsId };
- },
-});
-
-export const getTestMemberRecipientsForNewVisitorMessage: ReturnType =
- internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args): Promise => {
- return await ctx.runQuery(
- (internal as any).notifications.getMemberRecipientsForNewVisitorMessage,
- {
- workspaceId: args.workspaceId,
- }
- );
- },
- });
-
-export const getTestVisitorRecipientsForSupportReply: ReturnType =
- internalMutation({
- args: {
- conversationId: v.id("conversations"),
- channel: v.optional(v.union(v.literal("chat"), v.literal("email"))),
- },
- handler: async (ctx, args): Promise => {
- return await ctx.runQuery(
- (internal as any).notifications.getVisitorRecipientsForSupportReply,
- {
- conversationId: args.conversationId,
- channel: args.channel,
- }
- );
- },
- });
-
-/**
- * Creates a test invitation in the specified workspace.
- * This bypasses the email sending action for testing purposes.
- */
-export const createTestInvitation = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- email: v.string(),
- role: v.union(v.literal("admin"), v.literal("agent")),
- invitedBy: v.id("users"),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
- const normalizedEmail = args.email.toLowerCase();
-
- const invitationId = await ctx.db.insert("workspaceInvitations", {
- workspaceId: args.workspaceId,
- email: normalizedEmail,
- role: args.role,
- invitedBy: args.invitedBy,
- status: "pending",
- createdAt: timestamp,
- });
-
- return { invitationId };
- },
-});
-
-/**
- * Updates workspace settings for testing (e.g. identity verification).
- */
-export const updateWorkspaceSettings = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- identityVerificationEnabled: v.optional(v.boolean()),
- identityVerificationMode: v.optional(v.union(v.literal("optional"), v.literal("required"))),
- identitySecret: v.optional(v.string()),
- },
- handler: async (ctx, args) => {
- const updates: Record = {};
- if (args.identityVerificationEnabled !== undefined) {
- updates.identityVerificationEnabled = args.identityVerificationEnabled;
- }
- if (args.identityVerificationMode !== undefined) {
- updates.identityVerificationMode = args.identityVerificationMode;
- }
- if (args.identitySecret !== undefined) {
- updates.identitySecret = args.identitySecret;
- }
- await ctx.db.patch(args.workspaceId, updates);
- },
-});
-
-/**
- * Upserts automation settings for deterministic tests.
- */
-export const upsertTestAutomationSettings = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- suggestArticlesEnabled: v.optional(v.boolean()),
- showReplyTimeEnabled: v.optional(v.boolean()),
- collectEmailEnabled: v.optional(v.boolean()),
- askForRatingEnabled: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const existing = await ctx.db
- .query("automationSettings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .first();
-
- if (existing) {
- await ctx.db.patch(existing._id, {
- ...(args.suggestArticlesEnabled !== undefined && {
- suggestArticlesEnabled: args.suggestArticlesEnabled,
- }),
- ...(args.showReplyTimeEnabled !== undefined && {
- showReplyTimeEnabled: args.showReplyTimeEnabled,
- }),
- ...(args.collectEmailEnabled !== undefined && {
- collectEmailEnabled: args.collectEmailEnabled,
- }),
- ...(args.askForRatingEnabled !== undefined && {
- askForRatingEnabled: args.askForRatingEnabled,
- }),
- updatedAt: now,
- });
- return existing._id;
- }
-
- return await ctx.db.insert("automationSettings", {
- workspaceId: args.workspaceId,
- suggestArticlesEnabled: args.suggestArticlesEnabled ?? false,
- showReplyTimeEnabled: args.showReplyTimeEnabled ?? false,
- collectEmailEnabled: args.collectEmailEnabled ?? true,
- askForRatingEnabled: args.askForRatingEnabled ?? false,
- createdAt: now,
- updatedAt: now,
- });
- },
-});
-
-/**
- * Cleans up all test data for a workspace.
- * Call this after each test suite to remove test data.
- */
-export const cleanupTestData = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- },
- handler: async (ctx, args) => {
- const { workspaceId } = args;
-
- const conversations = await ctx.db
- .query("conversations")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const conversation of conversations) {
- const messages = await ctx.db
- .query("messages")
- .withIndex("by_conversation", (q) => q.eq("conversationId", conversation._id))
- .collect();
- for (const message of messages) {
- await ctx.db.delete(message._id);
- }
- await ctx.db.delete(conversation._id);
- }
-
- const visitors = await ctx.db
- .query("visitors")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- const visitorPushTokens = await ctx.db
- .query("visitorPushTokens")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const token of visitorPushTokens) {
- await ctx.db.delete(token._id);
- }
-
- for (const visitor of visitors) {
- await ctx.db.delete(visitor._id);
- }
-
- const members = await ctx.db
- .query("workspaceMembers")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const member of members) {
- await ctx.db.delete(member._id);
- }
-
- const users = await ctx.db
- .query("users")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const user of users) {
- const pushTokens = await ctx.db
- .query("pushTokens")
- .withIndex("by_user", (q) => q.eq("userId", user._id))
- .collect();
- for (const token of pushTokens) {
- await ctx.db.delete(token._id);
- }
-
- const notifPrefs = await ctx.db
- .query("notificationPreferences")
- .withIndex("by_user", (q) => q.eq("userId", user._id))
- .collect();
- for (const pref of notifPrefs) {
- await ctx.db.delete(pref._id);
- }
-
- await ctx.db.delete(user._id);
- }
-
- const invitations = await ctx.db
- .query("workspaceInvitations")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const invitation of invitations) {
- await ctx.db.delete(invitation._id);
- }
-
- const workspaceNotificationDefaults = await ctx.db
- .query("workspaceNotificationDefaults")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const defaults of workspaceNotificationDefaults) {
- await ctx.db.delete(defaults._id);
- }
-
- // Clean up content folders
- const contentFolders = await ctx.db
- .query("contentFolders")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const folder of contentFolders) {
- await ctx.db.delete(folder._id);
- }
-
- // Clean up internal articles
- const internalArticles = await ctx.db
- .query("internalArticles")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const article of internalArticles) {
- await ctx.db.delete(article._id);
- }
-
- // Clean up recent content access for users in this workspace
- for (const user of users) {
- const recentAccess = await ctx.db
- .query("recentContentAccess")
- .withIndex("by_user_workspace", (q) =>
- q.eq("userId", user._id).eq("workspaceId", workspaceId)
- )
- .collect();
- for (const access of recentAccess) {
- await ctx.db.delete(access._id);
- }
- }
-
- // Clean up articles
- const articles = await ctx.db
- .query("articles")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const article of articles) {
- await ctx.db.delete(article._id);
- }
-
- // Clean up collections
- const collections = await ctx.db
- .query("collections")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const collection of collections) {
- await ctx.db.delete(collection._id);
- }
-
- // Clean up help center import archives
- const importArchives = await ctx.db
- .query("helpCenterImportArchives")
- .withIndex("by_workspace_deleted_at", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const archive of importArchives) {
- await ctx.db.delete(archive._id);
- }
-
- // Clean up help center import sources
- const importSources = await ctx.db
- .query("helpCenterImportSources")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const source of importSources) {
- await ctx.db.delete(source._id);
- }
-
- // Clean up snippets
- const snippets = await ctx.db
- .query("snippets")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const snippet of snippets) {
- await ctx.db.delete(snippet._id);
- }
-
- // Clean up content embeddings
- const contentEmbeddings = await ctx.db
- .query("contentEmbeddings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const embedding of contentEmbeddings) {
- await ctx.db.delete(embedding._id);
- }
-
- // Clean up suggestion feedback
- const suggestionFeedback = await ctx.db
- .query("suggestionFeedback")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const feedback of suggestionFeedback) {
- await ctx.db.delete(feedback._id);
- }
-
- // Clean up AI agent settings
- const aiSettings = await ctx.db
- .query("aiAgentSettings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const setting of aiSettings) {
- await ctx.db.delete(setting._id);
- }
-
- // Clean up automation settings
- const automationSettings = await ctx.db
- .query("automationSettings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const setting of automationSettings) {
- await ctx.db.delete(setting._id);
- }
-
- // Clean up CSAT responses
- const csatResponses = await ctx.db
- .query("csatResponses")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const response of csatResponses) {
- await ctx.db.delete(response._id);
- }
-
- // Clean up report snapshots
- const reportSnapshots = await ctx.db
- .query("reportSnapshots")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const snapshot of reportSnapshots) {
- await ctx.db.delete(snapshot._id);
- }
-
- // Clean up email configs
- const emailConfigs = await ctx.db
- .query("emailConfigs")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const config of emailConfigs) {
- await ctx.db.delete(config._id);
- }
-
- // Clean up email threads
- for (const conversation of conversations) {
- const threads = await ctx.db
- .query("emailThreads")
- .withIndex("by_conversation", (q) => q.eq("conversationId", conversation._id))
- .collect();
- for (const thread of threads) {
- await ctx.db.delete(thread._id);
- }
- }
-
- // Clean up tickets and ticket comments
- const tickets = await ctx.db
- .query("tickets")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const ticket of tickets) {
- const comments = await ctx.db
- .query("ticketComments")
- .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
- .collect();
- for (const comment of comments) {
- await ctx.db.delete(comment._id);
- }
- await ctx.db.delete(ticket._id);
- }
-
- // Clean up ticket forms
- const ticketForms = await ctx.db
- .query("ticketForms")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const form of ticketForms) {
- await ctx.db.delete(form._id);
- }
-
- await ctx.db.delete(workspaceId);
-
- return { success: true };
- },
-});
-
-/**
- * Cleans up all E2E test data from the database.
- * This removes all users with emails matching *@test.opencom.dev pattern
- * and their associated workspaces, conversations, etc.
- *
- * Can be run manually or at the start/end of E2E test runs.
- */
-export const cleanupE2ETestData = internalMutation({
- args: {},
- handler: async (ctx) => {
- let deletedUsers = 0;
- let deletedWorkspaces = 0;
- let deletedConversations = 0;
- let deletedMessages = 0;
- let deletedVisitors = 0;
- let deletedMembers = 0;
- let deletedInvitations = 0;
-
- // Find all test users (emails ending with @test.opencom.dev)
- const allUsers = await ctx.db.query("users").collect();
- const testUsers = allUsers.filter(
- (user) => user.email && user.email.endsWith("@test.opencom.dev")
- );
-
- // Collect unique workspace IDs from test users
- const testWorkspaceIds = new Set>();
- for (const user of testUsers) {
- if (user.workspaceId) {
- testWorkspaceIds.add(user.workspaceId);
- }
- }
-
- // Clean up each test workspace
- for (const workspaceId of testWorkspaceIds) {
- // Delete conversations and messages
- const conversations = await ctx.db
- .query("conversations")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- for (const conversation of conversations) {
- const messages = await ctx.db
- .query("messages")
- .withIndex("by_conversation", (q) => q.eq("conversationId", conversation._id))
- .collect();
- for (const message of messages) {
- await ctx.db.delete(message._id);
- deletedMessages++;
- }
- await ctx.db.delete(conversation._id);
- deletedConversations++;
- }
-
- // Delete visitors
- const visitors = await ctx.db
- .query("visitors")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
-
- const visitorPushTokens = await ctx.db
- .query("visitorPushTokens")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const token of visitorPushTokens) {
- await ctx.db.delete(token._id);
- }
-
- for (const visitor of visitors) {
- await ctx.db.delete(visitor._id);
- deletedVisitors++;
- }
-
- // Delete workspace members
- const members = await ctx.db
- .query("workspaceMembers")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const member of members) {
- await ctx.db.delete(member._id);
- deletedMembers++;
- }
-
- // Delete invitations
- const invitations = await ctx.db
- .query("workspaceInvitations")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const invitation of invitations) {
- await ctx.db.delete(invitation._id);
- deletedInvitations++;
- }
-
- const workspaceNotificationDefaults = await ctx.db
- .query("workspaceNotificationDefaults")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
- .collect();
- for (const defaults of workspaceNotificationDefaults) {
- await ctx.db.delete(defaults._id);
- }
-
- // Delete the workspace
- try {
- await ctx.db.delete(workspaceId);
- deletedWorkspaces++;
- } catch (e) {
- // Workspace might already be deleted
- }
- }
-
- // Delete test users and their data
- for (const user of testUsers) {
- // Delete push tokens
- const pushTokens = await ctx.db
- .query("pushTokens")
- .withIndex("by_user", (q) => q.eq("userId", user._id))
- .collect();
- for (const token of pushTokens) {
- await ctx.db.delete(token._id);
- }
-
- // Delete notification preferences
- const notifPrefs = await ctx.db
- .query("notificationPreferences")
- .withIndex("by_user", (q) => q.eq("userId", user._id))
- .collect();
- for (const pref of notifPrefs) {
- await ctx.db.delete(pref._id);
- }
-
- await ctx.db.delete(user._id);
- deletedUsers++;
- }
-
- return {
- success: true,
- deleted: {
- users: deletedUsers,
- workspaces: deletedWorkspaces,
- conversations: deletedConversations,
- messages: deletedMessages,
- visitors: deletedVisitors,
- members: deletedMembers,
- invitations: deletedInvitations,
- },
- };
- },
-});
-
-/**
- * Creates a test email config for a workspace.
- */
-export const createTestEmailConfig = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- enabled: v.optional(v.boolean()),
- fromName: v.optional(v.string()),
- fromEmail: v.optional(v.string()),
- signature: v.optional(v.string()),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
- const randomSuffix = Math.random().toString(36).substring(2, 8);
- const forwardingAddress = `test-inbox-${randomSuffix}@mail.opencom.app`;
-
- const emailConfigId = await ctx.db.insert("emailConfigs", {
- workspaceId: args.workspaceId,
- forwardingAddress,
- fromName: args.fromName,
- fromEmail: args.fromEmail,
- signature: args.signature,
- enabled: args.enabled ?? true,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { emailConfigId, forwardingAddress };
- },
-});
-
-/**
- * Creates a test email conversation with email-specific fields.
- */
-export const createTestEmailConversation = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- visitorId: v.optional(v.id("visitors")),
- subject: v.string(),
- status: v.optional(v.union(v.literal("open"), v.literal("closed"), v.literal("snoozed"))),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
-
- const conversationId = await ctx.db.insert("conversations", {
- workspaceId: args.workspaceId,
- visitorId: args.visitorId,
- status: args.status || "open",
- channel: "email",
- subject: args.subject,
- createdAt: timestamp,
- updatedAt: timestamp,
- unreadByAgent: 0,
- unreadByVisitor: 0,
- });
-
- return { conversationId };
- },
-});
-
-/**
- * Creates a test email message with email metadata.
- */
-export const createTestEmailMessage = internalMutation({
- args: {
- conversationId: v.id("conversations"),
- content: v.string(),
- senderType: v.union(v.literal("visitor"), v.literal("agent")),
- senderId: v.optional(v.string()),
- subject: v.string(),
- from: v.string(),
- to: v.array(v.string()),
- messageId: v.string(),
- inReplyTo: v.optional(v.string()),
- references: v.optional(v.array(v.string())),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
- const senderId = args.senderId || `test-sender-${timestamp}`;
-
- const dbMessageId = await ctx.db.insert("messages", {
- conversationId: args.conversationId,
- senderId,
- senderType: args.senderType,
- content: args.content,
- channel: "email",
- emailMetadata: {
- subject: args.subject,
- from: args.from,
- to: args.to,
- messageId: args.messageId,
- inReplyTo: args.inReplyTo,
- references: args.references,
- },
- createdAt: timestamp,
- });
-
- await ctx.db.patch(args.conversationId, {
- lastMessageAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { messageId: dbMessageId };
- },
-});
-
-/**
- * Creates a test email thread record for thread matching tests.
- */
-export const createTestEmailThread = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- conversationId: v.id("conversations"),
- messageId: v.string(),
- subject: v.string(),
- senderEmail: v.string(),
- inReplyTo: v.optional(v.string()),
- references: v.optional(v.array(v.string())),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
- const normalizedSubject = args.subject
- .replace(/^(re|fwd|fw):\s*/gi, "")
- .replace(/\s+/g, " ")
- .trim()
- .toLowerCase();
-
- const threadId = await ctx.db.insert("emailThreads", {
- workspaceId: args.workspaceId,
- conversationId: args.conversationId,
- messageId: args.messageId,
- inReplyTo: args.inReplyTo,
- references: args.references,
- subject: args.subject,
- normalizedSubject,
- senderEmail: args.senderEmail.toLowerCase(),
- createdAt: timestamp,
- });
-
- return { threadId };
- },
-});
-
-/**
- * Creates a test ticket in the specified workspace.
- */
-export const createTestTicket = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- visitorId: v.optional(v.id("visitors")),
- conversationId: v.optional(v.id("conversations")),
- subject: v.string(),
- description: v.optional(v.string()),
- status: v.optional(
- v.union(
- v.literal("submitted"),
- v.literal("in_progress"),
- v.literal("waiting_on_customer"),
- v.literal("resolved")
- )
- ),
- priority: v.optional(
- v.union(v.literal("low"), v.literal("normal"), v.literal("high"), v.literal("urgent"))
- ),
- assigneeId: v.optional(v.id("users")),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
-
- const ticketId = await ctx.db.insert("tickets", {
- workspaceId: args.workspaceId,
- visitorId: args.visitorId,
- conversationId: args.conversationId,
- subject: args.subject,
- description: args.description,
- status: args.status || "submitted",
- priority: args.priority || "normal",
- assigneeId: args.assigneeId,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { ticketId };
- },
-});
-
-/**
- * Creates a test ticket form in the specified workspace.
- */
-export const createTestTicketForm = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.string(),
- description: v.optional(v.string()),
- isDefault: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const timestamp = Date.now();
-
- const ticketFormId = await ctx.db.insert("ticketForms", {
- workspaceId: args.workspaceId,
- name: args.name,
- description: args.description,
- fields: [
- {
- id: "subject",
- type: "text",
- label: "Subject",
- required: true,
- },
- {
- id: "description",
- type: "textarea",
- label: "Description",
- required: false,
- },
- ],
- isDefault: args.isDefault || false,
- createdAt: timestamp,
- updatedAt: timestamp,
- });
-
- return { ticketFormId };
- },
-});
-
-// ============================================================================
-// Auth-bypassing operation helpers for tests
-// These mirror auth-protected API functions but skip auth checks.
-// Only available via api.testing.helpers.* for test environments.
-// ============================================================================
-
-/**
- * Creates a collection directly (bypasses auth on collections.create).
- */
-export const createTestCollection = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.string(),
- description: v.optional(v.string()),
- icon: v.optional(v.string()),
- parentId: v.optional(v.id("collections")),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const baseSlug = args.name
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-|-$/g, "");
- const randomSuffix = Math.random().toString(36).substring(2, 8);
-
- return await ctx.db.insert("collections", {
- workspaceId: args.workspaceId,
- name: args.name,
- slug: `${baseSlug}-${randomSuffix}`,
- description: args.description,
- icon: args.icon,
- parentId: args.parentId,
- order: now,
- createdAt: now,
- updatedAt: now,
- });
- },
-});
-
-/**
- * Creates an article directly (bypasses auth on articles.create).
- */
-export const createTestArticle = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- title: v.string(),
- content: v.string(),
- collectionId: v.optional(v.id("collections")),
- widgetLargeScreen: v.optional(v.boolean()),
- status: v.optional(v.union(v.literal("draft"), v.literal("published"))),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const slug = args.title
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-|-$/g, "");
- const randomSuffix = Math.random().toString(36).substring(2, 8);
-
- const articleId = await ctx.db.insert("articles", {
- workspaceId: args.workspaceId,
- collectionId: args.collectionId,
- title: args.title,
- slug: `${slug}-${randomSuffix}`,
- content: args.content,
- widgetLargeScreen: args.widgetLargeScreen ?? false,
- status: args.status || "draft",
- order: 0,
- createdAt: now,
- updatedAt: now,
- ...(args.status === "published" ? { publishedAt: now } : {}),
- });
-
- return articleId;
- },
-});
-
-/**
- * Publishes an article directly (bypasses auth on articles.publish).
- */
-export const publishTestArticle = internalMutation({
- args: { id: v.id("articles") },
- handler: async (ctx, args) => {
- await ctx.db.patch(args.id, {
- status: "published",
- publishedAt: Date.now(),
- updatedAt: Date.now(),
- });
- },
-});
-
-/**
- * Updates an article directly (bypasses auth on articles.update).
- */
-export const updateTestArticle = internalMutation({
- args: {
- id: v.id("articles"),
- title: v.optional(v.string()),
- content: v.optional(v.string()),
- audienceRules: v.optional(v.any()),
- widgetLargeScreen: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const { id, ...updates } = args;
- await ctx.db.patch(id, { ...updates, updatedAt: Date.now() });
- },
-});
-
-/**
- * Removes an article directly (bypasses auth on articles.remove).
- */
-export const removeTestArticle = internalMutation({
- args: { id: v.id("articles") },
- handler: async (ctx, args) => {
- await ctx.db.delete(args.id);
- },
-});
-
-/**
- * Creates an internal article directly (bypasses auth).
- */
-export const createTestInternalArticle = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- title: v.string(),
- content: v.string(),
- tags: v.optional(v.array(v.string())),
- folderId: v.optional(v.id("contentFolders")),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const articleId = await ctx.db.insert("internalArticles", {
- workspaceId: args.workspaceId,
- title: args.title,
- content: args.content,
- tags: args.tags || [],
- folderId: args.folderId,
- status: "draft",
- createdAt: now,
- updatedAt: now,
- });
- return articleId;
- },
-});
-
-/**
- * Publishes an internal article directly (bypasses auth).
- */
-export const publishTestInternalArticle = internalMutation({
- args: { id: v.id("internalArticles") },
- handler: async (ctx, args) => {
- await ctx.db.patch(args.id, {
- status: "published",
- publishedAt: Date.now(),
- updatedAt: Date.now(),
- });
- },
-});
-
-/**
- * Creates a snippet directly (bypasses auth).
- */
-export const createTestSnippet = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.string(),
- content: v.string(),
- shortcut: v.optional(v.string()),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const snippetId = await ctx.db.insert("snippets", {
- workspaceId: args.workspaceId,
- name: args.name,
- content: args.content,
- shortcut: args.shortcut,
- createdAt: now,
- updatedAt: now,
- });
- return snippetId;
- },
-});
-
-/**
- * Creates a content folder directly (bypasses auth).
- */
-export const createTestContentFolder = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.string(),
- parentId: v.optional(v.id("contentFolders")),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const folderId = await ctx.db.insert("contentFolders", {
- workspaceId: args.workspaceId,
- name: args.name,
- parentId: args.parentId,
- order: 0,
- createdAt: now,
- updatedAt: now,
- });
- return folderId;
- },
-});
-
-/**
- * Creates a tour directly (bypasses auth on tours.create).
- */
-export const createTestTour = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- name: v.string(),
- description: v.optional(v.string()),
- status: v.optional(v.union(v.literal("draft"), v.literal("active"), v.literal("archived"))),
- targetingRules: v.optional(v.any()),
- audienceRules: v.optional(v.any()),
- displayMode: v.optional(v.union(v.literal("first_time_only"), v.literal("until_dismissed"))),
- priority: v.optional(v.number()),
- buttonColor: v.optional(v.string()),
- senderId: v.optional(v.id("users")),
- showConfetti: v.optional(v.boolean()),
- allowSnooze: v.optional(v.boolean()),
- allowRestart: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const tourId = await ctx.db.insert("tours", {
- workspaceId: args.workspaceId,
- name: args.name,
- description: args.description,
- status: args.status || "draft",
- targetingRules: args.targetingRules,
- audienceRules: args.audienceRules,
- displayMode: args.displayMode ?? "first_time_only",
- priority: args.priority ?? 0,
- buttonColor: args.buttonColor,
- senderId: args.senderId,
- showConfetti: args.showConfetti ?? true,
- allowSnooze: args.allowSnooze ?? true,
- allowRestart: args.allowRestart ?? true,
- createdAt: now,
- updatedAt: now,
- });
- return tourId;
- },
-});
-
-/**
- * Updates a tour directly (bypasses auth on tours.update).
- */
-export const updateTestTour = internalMutation({
- args: {
- id: v.id("tours"),
- name: v.optional(v.string()),
- description: v.optional(v.string()),
- targetingRules: v.optional(v.any()),
- audienceRules: v.optional(v.any()),
- displayMode: v.optional(v.union(v.literal("first_time_only"), v.literal("until_dismissed"))),
- priority: v.optional(v.number()),
- buttonColor: v.optional(v.string()),
- showConfetti: v.optional(v.boolean()),
- allowSnooze: v.optional(v.boolean()),
- allowRestart: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const { id, ...updates } = args;
- await ctx.db.patch(id, { ...updates, updatedAt: Date.now() });
- },
-});
-
-/**
- * Removes a tour and its steps directly (bypasses auth on tours.remove).
- */
-export const removeTestTour = internalMutation({
- args: { id: v.id("tours") },
- handler: async (ctx, args) => {
- const steps = await ctx.db
- .query("tourSteps")
- .withIndex("by_tour", (q) => q.eq("tourId", args.id))
- .collect();
- for (const step of steps) {
- await ctx.db.delete(step._id);
- }
- await ctx.db.delete(args.id);
- return { success: true };
- },
-});
-
-/**
- * Activates a tour directly (bypasses auth).
- */
-export const activateTestTour = internalMutation({
- args: { id: v.id("tours") },
- handler: async (ctx, args) => {
- await ctx.db.patch(args.id, { status: "active", updatedAt: Date.now() });
- },
-});
-
-/**
- * Deactivates a tour directly (bypasses auth).
- */
-export const deactivateTestTour = internalMutation({
- args: { id: v.id("tours") },
- handler: async (ctx, args) => {
- await ctx.db.patch(args.id, { status: "draft", updatedAt: Date.now() });
- },
-});
-
-/**
- * Gets a tour by ID directly (bypasses auth).
- */
-export const getTestTour = internalMutation({
- args: { id: v.id("tours") },
- handler: async (ctx, args) => {
- return await ctx.db.get(args.id);
- },
-});
-
-/**
- * Lists tours for a workspace directly (bypasses auth).
- */
-export const listTestTours = internalMutation({
- args: { workspaceId: v.id("workspaces") },
- handler: async (ctx, args) => {
- return await ctx.db
- .query("tours")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
- },
-});
-
-/**
- * Duplicates a tour directly (bypasses auth).
- */
-export const duplicateTestTour = internalMutation({
- args: {
- id: v.id("tours"),
- name: v.optional(v.string()),
- },
- handler: async (ctx, args) => {
- const tour = await ctx.db.get(args.id);
- if (!tour) throw new Error("Tour not found");
- const now = Date.now();
- const { _id: _tourId, _creationTime: _tourCt, ...tourData } = tour;
- const newTourId = await ctx.db.insert("tours", {
- ...tourData,
- name: args.name || `${tour.name} (Copy)`,
- status: "draft",
- createdAt: now,
- updatedAt: now,
- });
- const steps = await ctx.db
- .query("tourSteps")
- .withIndex("by_tour", (q) => q.eq("tourId", args.id))
- .collect();
- for (const step of steps) {
- const { _id: _stepId, _creationTime: _stepCt, ...stepData } = step;
- await ctx.db.insert("tourSteps", {
- ...stepData,
- workspaceId: tour.workspaceId,
- tourId: newTourId,
- createdAt: now,
- updatedAt: now,
- });
- }
- return newTourId;
- },
-});
-
-/**
- * Gets a conversation by ID directly (bypasses auth).
- */
-export const getTestConversation = internalMutation({
- args: { id: v.id("conversations") },
- handler: async (ctx, args) => {
- return await ctx.db.get(args.id);
- },
-});
-
-/**
- * Lists conversations for a workspace directly (bypasses auth).
- */
-export const listTestConversations = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- status: v.optional(v.union(v.literal("open"), v.literal("closed"), v.literal("snoozed"))),
- },
- handler: async (ctx, args) => {
- if (args.status) {
- return await ctx.db
- .query("conversations")
- .withIndex("by_status", (q) =>
- q.eq("workspaceId", args.workspaceId).eq("status", args.status!)
- )
- .order("desc")
- .collect();
- }
- return await ctx.db
- .query("conversations")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .order("desc")
- .collect();
- },
-});
-
-/**
- * Updates conversation status directly (bypasses auth on conversations.updateStatus).
- */
-export const updateTestConversationStatus = internalMutation({
- args: {
- id: v.id("conversations"),
- status: v.union(v.literal("open"), v.literal("closed"), v.literal("snoozed")),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const patch: Record = {
- status: args.status,
- updatedAt: now,
- };
- if (args.status === "closed") {
- patch.resolvedAt = now;
- } else if (args.status === "open") {
- patch.resolvedAt = undefined;
- }
- await ctx.db.patch(args.id, patch);
- },
-});
-
-/**
- * Assigns a conversation directly (bypasses auth on conversations.assign).
- */
-export const assignTestConversation = internalMutation({
- args: {
- id: v.id("conversations"),
- agentId: v.id("users"),
- },
- handler: async (ctx, args) => {
- await ctx.db.patch(args.id, {
- assignedAgentId: args.agentId,
- updatedAt: Date.now(),
- });
- },
-});
-
-/**
- * Marks a conversation as read directly (bypasses auth).
- */
-export const markTestConversationAsRead = internalMutation({
- args: { id: v.id("conversations") },
- handler: async (ctx, args) => {
- await ctx.db.patch(args.id, {
- unreadByAgent: 0,
- updatedAt: Date.now(),
- });
- },
-});
-
-/**
- * Lists messages for a conversation directly (bypasses auth).
- */
-export const listTestMessages = internalMutation({
- args: { conversationId: v.id("conversations") },
- handler: async (ctx, args) => {
- return await ctx.db
- .query("messages")
- .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId))
- .order("asc")
- .collect();
- },
-});
-
-/**
- * Sends a message directly (bypasses auth on messages.send, including bot restriction).
- */
-export const sendTestMessageDirect = internalMutation({
- args: {
- conversationId: v.id("conversations"),
- senderId: v.string(),
- senderType: v.union(
- v.literal("user"),
- v.literal("visitor"),
- v.literal("agent"),
- v.literal("bot")
- ),
- content: v.string(),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const messageId = await ctx.db.insert("messages", {
- conversationId: args.conversationId,
- senderId: args.senderId,
- senderType: args.senderType,
- content: args.content,
- createdAt: now,
- });
-
- const conversation = await ctx.db.get(args.conversationId);
- const unreadUpdate: Record = {};
- if (args.senderType === "visitor") {
- unreadUpdate.unreadByAgent = (conversation?.unreadByAgent || 0) + 1;
- } else if (args.senderType === "agent" || args.senderType === "user") {
- unreadUpdate.unreadByVisitor = (conversation?.unreadByVisitor || 0) + 1;
- }
-
- await ctx.db.patch(args.conversationId, {
- updatedAt: now,
- lastMessageAt: now,
- ...unreadUpdate,
- });
- return messageId;
- },
-});
-
-/**
- * Seeds an AI response record and updates conversation workflow fields for deterministic tests.
- */
-export const seedTestAIResponse = internalMutation({
- args: {
- conversationId: v.id("conversations"),
- query: v.string(),
- response: v.string(),
- generatedCandidateResponse: v.optional(v.string()),
- generatedCandidateSources: v.optional(
- v.array(
- v.object({
- type: v.string(),
- id: v.string(),
- title: v.string(),
- })
- )
- ),
- generatedCandidateConfidence: v.optional(v.number()),
- confidence: v.optional(v.number()),
- handedOff: v.optional(v.boolean()),
- handoffReason: v.optional(v.string()),
- feedback: v.optional(v.union(v.literal("helpful"), v.literal("not_helpful"))),
- sources: v.optional(
- v.array(
- v.object({
- type: v.string(),
- id: v.string(),
- title: v.string(),
- })
- )
- ),
- model: v.optional(v.string()),
- provider: v.optional(v.string()),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const confidence = args.confidence ?? 0.75;
- const handedOff = args.handedOff ?? false;
- const handoffReason = handedOff
- ? (args.handoffReason ?? "AI requested human handoff")
- : undefined;
-
- const messageId = await ctx.db.insert("messages", {
- conversationId: args.conversationId,
- senderId: "ai-agent",
- senderType: "bot",
- content: args.response,
- createdAt: now,
- });
-
- const responseId = await ctx.db.insert("aiResponses", {
- conversationId: args.conversationId,
- messageId,
- query: args.query,
- response: args.response,
- generatedCandidateResponse: args.generatedCandidateResponse,
- generatedCandidateSources: args.generatedCandidateSources,
- generatedCandidateConfidence: args.generatedCandidateConfidence,
- sources: args.sources ?? [],
- confidence,
- feedback: args.feedback,
- handedOff,
- handoffReason,
- generationTimeMs: 120,
- tokensUsed: 96,
- model: args.model ?? "openai/gpt-5-nano",
- provider: args.provider ?? "openai",
- createdAt: now,
- });
-
- await ctx.db.patch(args.conversationId, {
- status: "open",
- updatedAt: now,
- lastMessageAt: now,
- aiWorkflowState: handedOff ? "handoff" : "ai_handled",
- aiHandoffReason: handoffReason,
- aiLastConfidence: confidence,
- aiLastResponseAt: now,
- });
-
- return { responseId, messageId };
- },
-});
-
-/**
- * Gets a workspace with full data directly (bypasses auth-limited fields).
- */
-export const getTestWorkspaceFull = internalMutation({
- args: { id: v.id("workspaces") },
- handler: async (ctx, args) => {
- return await ctx.db.get(args.id);
- },
-});
-
-/**
- * Updates workspace allowed origins directly (bypasses auth).
- */
-export const updateTestAllowedOrigins = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- allowedOrigins: v.array(v.string()),
- },
- handler: async (ctx, args) => {
- await ctx.db.patch(args.workspaceId, {
- allowedOrigins: args.allowedOrigins,
- });
- },
-});
-
-/**
- * Updates workspace signup settings directly (bypasses auth).
- */
-export const updateTestSignupSettings = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- signupMode: v.optional(
- v.union(v.literal("invite-only"), v.literal("domain-allowlist"), v.literal("open"))
- ),
- allowedDomains: v.optional(v.array(v.string())),
- authMethods: v.optional(v.array(v.union(v.literal("password"), v.literal("otp")))),
- },
- handler: async (ctx, args) => {
- const updates: Record = {};
- if (args.signupMode !== undefined) updates.signupMode = args.signupMode;
- if (args.allowedDomains !== undefined) updates.allowedDomains = args.allowedDomains;
- if (args.authMethods !== undefined) updates.authMethods = args.authMethods;
- // Clear domains when switching to invite-only
- if (args.signupMode === "invite-only" && args.allowedDomains === undefined) {
- updates.allowedDomains = [];
- }
- await ctx.db.patch(args.workspaceId, updates);
- },
-});
-
-/**
- * Gets AI agent settings directly (bypasses auth).
- */
-export const getTestAISettings = internalMutation({
- args: { workspaceId: v.id("workspaces") },
- handler: async (ctx, args) => {
- const settings = await ctx.db
- .query("aiAgentSettings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .first();
-
- if (!settings) {
- return {
- enabled: false,
- knowledgeSources: ["articles"],
- confidenceThreshold: 0.6,
- personality: null,
- handoffMessage: "Let me connect you with a human agent who can help you better.",
- workingHours: null,
- model: "openai/gpt-5-nano",
- suggestionsEnabled: false,
- embeddingModel: "text-embedding-3-small",
- };
- }
-
- return {
- ...settings,
- suggestionsEnabled: settings.suggestionsEnabled ?? false,
- embeddingModel: settings.embeddingModel ?? "text-embedding-3-small",
- };
- },
-});
-
-/**
- * Updates AI agent settings directly (bypasses auth).
- */
-export const updateTestAISettings = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- enabled: v.optional(v.boolean()),
- model: v.optional(v.string()),
- confidenceThreshold: v.optional(v.number()),
- knowledgeSources: v.optional(v.array(v.string())),
- personality: v.optional(v.string()),
- handoffMessage: v.optional(v.string()),
- suggestionsEnabled: v.optional(v.boolean()),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const existing = await ctx.db
- .query("aiAgentSettings")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .first();
-
- const { workspaceId, ...updates } = args;
-
- if (existing) {
- const patchData: Record = { updatedAt: now };
- if (updates.enabled !== undefined) patchData.enabled = updates.enabled;
- if (updates.model !== undefined) patchData.model = updates.model;
- if (updates.confidenceThreshold !== undefined)
- patchData.confidenceThreshold = updates.confidenceThreshold;
- if (updates.knowledgeSources !== undefined)
- patchData.knowledgeSources = updates.knowledgeSources;
- if (updates.personality !== undefined) patchData.personality = updates.personality;
- if (updates.handoffMessage !== undefined) patchData.handoffMessage = updates.handoffMessage;
- if (updates.suggestionsEnabled !== undefined)
- patchData.suggestionsEnabled = updates.suggestionsEnabled;
- await ctx.db.patch(existing._id, patchData as any);
- return existing._id;
- }
-
- return await ctx.db.insert("aiAgentSettings", {
- workspaceId,
- enabled: args.enabled ?? false,
- knowledgeSources: (args.knowledgeSources as any) ?? ["articles"],
- confidenceThreshold: args.confidenceThreshold ?? 0.6,
- personality: args.personality,
- handoffMessage:
- args.handoffMessage ?? "Let me connect you with a human agent who can help you better.",
- model: args.model ?? "openai/gpt-5-nano",
- suggestionsEnabled: args.suggestionsEnabled ?? false,
- createdAt: now,
- updatedAt: now,
- });
- },
-});
-
-/**
- * Gets a visitor by ID directly (bypasses auth).
- */
-export const getTestVisitor = internalMutation({
- args: { id: v.id("visitors") },
- handler: async (ctx, args) => {
- return await ctx.db.get(args.id);
- },
-});
-
-/**
- * Adds a workspace member directly (bypasses auth on workspaceMembers.add).
- */
-export const addTestWorkspaceMember = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- userId: v.id("users"),
- role: v.union(v.literal("owner"), v.literal("admin"), v.literal("agent"), v.literal("viewer")),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const memberId = await ctx.db.insert("workspaceMembers", {
- workspaceId: args.workspaceId,
- userId: args.userId,
- role: args.role,
- createdAt: now,
- });
- return memberId;
- },
-});
-
-/**
- * Lists workspace members directly (bypasses auth).
- */
-export const listTestWorkspaceMembers = internalMutation({
- args: { workspaceId: v.id("workspaces") },
- handler: async (ctx, args) => {
- const members = await ctx.db
- .query("workspaceMembers")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
-
- return await Promise.all(
- members.map(async (m) => {
- const user = await ctx.db.get(m.userId);
- return { ...m, user };
- })
- );
- },
-});
-
-/**
- * Updates workspace-member custom permissions directly (bypasses auth).
- * Passing an empty array clears custom permissions and falls back to role defaults.
- */
-export const updateTestMemberPermissions = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- userEmail: v.string(),
- permissions: v.optional(v.array(v.string())),
- },
- handler: async (ctx, args) => {
- const user = await ctx.db
- .query("users")
- .withIndex("by_email", (q) => q.eq("email", args.userEmail))
- .first();
- if (!user) {
- throw new Error("User not found");
- }
-
- const membership = await ctx.db
- .query("workspaceMembers")
- .withIndex("by_user_workspace", (q) =>
- q.eq("userId", user._id).eq("workspaceId", args.workspaceId)
- )
- .first();
- if (!membership) {
- throw new Error("Workspace member not found");
- }
-
- const normalized =
- args.permissions && args.permissions.length > 0 ? args.permissions : undefined;
- await ctx.db.patch(membership._id, { permissions: normalized });
-
- return { membershipId: membership._id };
- },
-});
-
-/**
- * Updates a workspace member role directly (bypasses auth).
- * Includes last-admin validation to match production behavior.
- */
-export const updateTestMemberRole = internalMutation({
- args: {
- membershipId: v.id("workspaceMembers"),
- role: v.union(v.literal("owner"), v.literal("admin"), v.literal("agent"), v.literal("viewer")),
- },
- handler: async (ctx, args) => {
- const member = await ctx.db.get(args.membershipId);
- if (!member) throw new Error("Member not found");
-
- // If demoting from admin, check there's at least one other admin
- if (member.role === "admin" && args.role !== "admin") {
- const admins = await ctx.db
- .query("workspaceMembers")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", member.workspaceId))
- .filter((q) => q.eq(q.field("role"), "admin"))
- .collect();
- if (admins.length <= 1) {
- throw new Error("Cannot demote: workspace must have at least one admin");
- }
- }
-
- await ctx.db.patch(args.membershipId, { role: args.role });
- },
-});
-
-/**
- * Removes a workspace member directly (bypasses auth).
- * Includes last-admin validation to match production behavior.
- */
-export const removeTestMember = internalMutation({
- args: { membershipId: v.id("workspaceMembers") },
- handler: async (ctx, args) => {
- const member = await ctx.db.get(args.membershipId);
- if (!member) throw new Error("Member not found");
-
- // If removing an admin, check there's at least one other admin
- if (member.role === "admin") {
- const admins = await ctx.db
- .query("workspaceMembers")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", member.workspaceId))
- .filter((q) => q.eq(q.field("role"), "admin"))
- .collect();
- if (admins.length <= 1) {
- throw new Error("Cannot remove: workspace must have at least one admin");
- }
- }
-
- await ctx.db.delete(args.membershipId);
- },
-});
-
-/**
- * Removes a workspace member directly (bypasses auth). Alias without validation.
- */
-export const removeTestWorkspaceMember = internalMutation({
- args: { membershipId: v.id("workspaceMembers") },
- handler: async (ctx, args) => {
- await ctx.db.delete(args.membershipId);
- },
-});
-
-/**
- * Cancels a test invitation directly (bypasses auth).
- */
-export const cancelTestInvitation = internalMutation({
- args: { invitationId: v.id("workspaceInvitations") },
- handler: async (ctx, args) => {
- await ctx.db.delete(args.invitationId);
- },
-});
-
-/**
- * Accepts a test invitation directly (bypasses auth).
- */
-export const acceptTestInvitation = internalMutation({
- args: { invitationId: v.id("workspaceInvitations") },
- handler: async (ctx, args) => {
- const invitation = await ctx.db.get(args.invitationId);
- if (!invitation) throw new Error("Invitation not found");
- await ctx.db.patch(args.invitationId, { status: "accepted" });
- },
-});
-
-/**
- * Lists pending invitations for a workspace directly (bypasses auth).
- */
-export const listTestPendingInvitations = internalMutation({
- args: { workspaceId: v.id("workspaces") },
- handler: async (ctx, args) => {
- return await ctx.db
- .query("workspaceInvitations")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .filter((q) => q.eq(q.field("status"), "pending"))
- .collect();
- },
-});
-
-/**
- * Simulates an email webhook event for testing delivery status updates.
- */
-export const simulateEmailWebhook = internalMutation({
- args: {
- eventType: v.string(),
- emailId: v.string(),
- },
- handler: async (ctx, args) => {
- // Map event type to delivery status (only schema-valid values)
- const statusMap: Record = {
- "email.sent": "sent",
- "email.delivered": "delivered",
- "email.opened": "delivered",
- "email.clicked": "delivered",
- "email.bounced": "bounced",
- "email.complained": "failed",
- "email.delivery_delayed": "pending",
- };
-
- const deliveryStatus = statusMap[args.eventType];
- if (!deliveryStatus) return;
-
- // Find message by emailMetadata.messageId
- const message = await ctx.db
- .query("messages")
- .withIndex("by_email_message_id", (q) => q.eq("emailMetadata.messageId", args.emailId))
- .first();
-
- if (message) {
- await ctx.db.patch(message._id, { deliveryStatus });
- }
- },
-});
-
-/**
- * Gets a message by ID directly (bypasses auth).
- */
-export const getTestMessage = internalMutation({
- args: { id: v.id("messages") },
- handler: async (ctx, args) => {
- return await ctx.db.get(args.id);
- },
-});
-
-/**
- * Updates workspace allowed origins directly (bypasses auth).
- */
-export const updateWorkspaceOrigins = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- allowedOrigins: v.array(v.string()),
- },
- handler: async (ctx, args) => {
- await ctx.db.patch(args.workspaceId, { allowedOrigins: args.allowedOrigins });
- },
-});
-
-/**
- * Creates a conversation for a visitor directly (bypasses auth, like createForVisitor).
- */
-export const createTestConversationForVisitor = internalMutation({
- args: {
- workspaceId: v.id("workspaces"),
- visitorId: v.id("visitors"),
- },
- handler: async (ctx, args) => {
- const now = Date.now();
- const id = await ctx.db.insert("conversations", {
- workspaceId: args.workspaceId,
- visitorId: args.visitorId,
- status: "open",
- createdAt: now,
- updatedAt: now,
- lastMessageAt: now,
- aiWorkflowState: "none",
- });
-
- await ctx.db.insert("messages", {
- conversationId: id,
- senderId: "system",
- senderType: "bot",
- content: "Hi! How can we help you today?",
- createdAt: now,
- });
-
- return await ctx.db.get(id);
- },
-});
-
-/**
- * Looks up a user by email using the by_email index.
- * Mirrors the typed query pattern used in authConvex createOrUpdateUser.
- */
-export const lookupUserByEmail = internalMutation({
- args: { email: v.string() },
- handler: async (ctx, args) => {
- return await ctx.db
- .query("users")
- .withIndex("by_email", (q) => q.eq("email", args.email.toLowerCase()))
- .first();
- },
-});
-
-/**
- * Looks up pending workspace invitations by email using the by_email index.
- * Mirrors the typed query pattern used in authConvex createOrUpdateUser.
- */
-export const lookupPendingInvitationsByEmail = internalMutation({
- args: { email: v.string() },
- handler: async (ctx, args) => {
- return await ctx.db
- .query("workspaceInvitations")
- .withIndex("by_email", (q) => q.eq("email", args.email.toLowerCase()))
- .filter((q) => q.eq(q.field("status"), "pending"))
- .collect();
- },
-});
+import { seriesTestHelpers } from "./helpers/series";
+import { workspaceTestHelpers } from "./helpers/workspace";
+import { conversationTestHelpers } from "./helpers/conversations";
+import { notificationTestHelpers } from "./helpers/notifications";
+import { contentTestHelpers } from "./helpers/content";
+import { emailTestHelpers } from "./helpers/email";
+import { ticketTestHelpers } from "./helpers/tickets";
+import { aiTestHelpers } from "./helpers/ai";
+import { cleanupTestHelpers } from "./helpers/cleanup";
+
+export const createTestWorkspace: ReturnType = workspaceTestHelpers.createTestWorkspace;
+export const updateTestHelpCenterAccessPolicy: ReturnType = workspaceTestHelpers.updateTestHelpCenterAccessPolicy;
+export const runSeriesEvaluateEnrollmentForVisitor: ReturnType = seriesTestHelpers.runSeriesEvaluateEnrollmentForVisitor;
+export const runSeriesResumeWaitingForEvent: ReturnType = seriesTestHelpers.runSeriesResumeWaitingForEvent;
+export const runSeriesProcessWaitingProgress: ReturnType = seriesTestHelpers.runSeriesProcessWaitingProgress;
+export const getSeriesProgressForVisitorSeries: ReturnType = seriesTestHelpers.getSeriesProgressForVisitorSeries;
+export const updateSeriesProgressForTest: ReturnType = seriesTestHelpers.updateSeriesProgressForTest;
+export const createTestAuditLog: ReturnType = workspaceTestHelpers.createTestAuditLog;
+export const createTestUser: ReturnType = workspaceTestHelpers.createTestUser;
+export const createTestSessionToken: ReturnType = workspaceTestHelpers.createTestSessionToken;
+export const createTestVisitor: ReturnType = conversationTestHelpers.createTestVisitor;
+export const createTestConversation: ReturnType = conversationTestHelpers.createTestConversation;
+export const createTestSurvey: ReturnType = contentTestHelpers.createTestSurvey;
+export const expireTooltipAuthoringSession: ReturnType = contentTestHelpers.expireTooltipAuthoringSession;
+export const completeTooltipAuthoringSession: ReturnType = contentTestHelpers.completeTooltipAuthoringSession;
+export const createTestSeries: ReturnType = seriesTestHelpers.createTestSeries;
+export const createTestPushCampaign: ReturnType = notificationTestHelpers.createTestPushCampaign;
+export const sendTestPushCampaign: ReturnType = notificationTestHelpers.sendTestPushCampaign;
+export const getTestPendingPushCampaignRecipients: ReturnType = notificationTestHelpers.getTestPendingPushCampaignRecipients;
+export const createTestMessage: ReturnType = conversationTestHelpers.createTestMessage;
+export const createTestPushToken: ReturnType = notificationTestHelpers.createTestPushToken;
+export const createTestVisitorPushToken: ReturnType = notificationTestHelpers.createTestVisitorPushToken;
+export const upsertTestNotificationPreference: ReturnType = notificationTestHelpers.upsertTestNotificationPreference;
+export const upsertTestWorkspaceNotificationDefaults: ReturnType = notificationTestHelpers.upsertTestWorkspaceNotificationDefaults;
+export const getTestMemberRecipientsForNewVisitorMessage: ReturnType = notificationTestHelpers.getTestMemberRecipientsForNewVisitorMessage;
+export const getTestVisitorRecipientsForSupportReply: ReturnType = notificationTestHelpers.getTestVisitorRecipientsForSupportReply;
+export const createTestInvitation: ReturnType = workspaceTestHelpers.createTestInvitation;
+export const updateWorkspaceSettings: ReturnType = workspaceTestHelpers.updateWorkspaceSettings;
+export const upsertTestAutomationSettings: ReturnType = workspaceTestHelpers.upsertTestAutomationSettings;
+export const cleanupTestData: ReturnType = cleanupTestHelpers.cleanupTestData;
+export const cleanupE2ETestData: ReturnType = cleanupTestHelpers.cleanupE2ETestData;
+export const createTestEmailConfig: ReturnType = emailTestHelpers.createTestEmailConfig;
+export const createTestEmailConversation: ReturnType = emailTestHelpers.createTestEmailConversation;
+export const createTestEmailMessage: ReturnType = emailTestHelpers.createTestEmailMessage;
+export const createTestEmailThread: ReturnType = emailTestHelpers.createTestEmailThread;
+export const createTestTicket: ReturnType = ticketTestHelpers.createTestTicket;
+export const createTestTicketForm: ReturnType = ticketTestHelpers.createTestTicketForm;
+export const createTestCollection: ReturnType = contentTestHelpers.createTestCollection;
+export const createTestArticle: ReturnType = contentTestHelpers.createTestArticle;
+export const publishTestArticle: ReturnType = contentTestHelpers.publishTestArticle;
+export const updateTestArticle: ReturnType = contentTestHelpers.updateTestArticle;
+export const removeTestArticle: ReturnType = contentTestHelpers.removeTestArticle;
+export const createTestInternalArticle: ReturnType = contentTestHelpers.createTestInternalArticle;
+export const publishTestInternalArticle: ReturnType = contentTestHelpers.publishTestInternalArticle;
+export const createTestSnippet: ReturnType = contentTestHelpers.createTestSnippet;
+export const createTestContentFolder: ReturnType = contentTestHelpers.createTestContentFolder;
+export const createTestTour: ReturnType = contentTestHelpers.createTestTour;
+export const updateTestTour: ReturnType = contentTestHelpers.updateTestTour;
+export const removeTestTour: ReturnType = contentTestHelpers.removeTestTour;
+export const activateTestTour: ReturnType = contentTestHelpers.activateTestTour;
+export const deactivateTestTour: ReturnType = contentTestHelpers.deactivateTestTour;
+export const getTestTour: ReturnType = contentTestHelpers.getTestTour;
+export const listTestTours: ReturnType = contentTestHelpers.listTestTours;
+export const duplicateTestTour: ReturnType = contentTestHelpers.duplicateTestTour;
+export const getTestConversation: ReturnType = conversationTestHelpers.getTestConversation;
+export const listTestConversations: ReturnType = conversationTestHelpers.listTestConversations;
+export const updateTestConversationStatus: ReturnType = conversationTestHelpers.updateTestConversationStatus;
+export const assignTestConversation: ReturnType = conversationTestHelpers.assignTestConversation;
+export const markTestConversationAsRead: ReturnType = conversationTestHelpers.markTestConversationAsRead;
+export const listTestMessages: ReturnType = conversationTestHelpers.listTestMessages;
+export const sendTestMessageDirect: ReturnType = conversationTestHelpers.sendTestMessageDirect;
+export const seedTestAIResponse: ReturnType = aiTestHelpers.seedTestAIResponse;
+export const getTestWorkspaceFull: ReturnType = workspaceTestHelpers.getTestWorkspaceFull;
+export const updateTestAllowedOrigins: ReturnType = workspaceTestHelpers.updateTestAllowedOrigins;
+export const updateTestSignupSettings: ReturnType = workspaceTestHelpers.updateTestSignupSettings;
+export const getTestAISettings: ReturnType = aiTestHelpers.getTestAISettings;
+export const updateTestAISettings: ReturnType = aiTestHelpers.updateTestAISettings;
+export const getTestVisitor: ReturnType = conversationTestHelpers.getTestVisitor;
+export const addTestWorkspaceMember: ReturnType = workspaceTestHelpers.addTestWorkspaceMember;
+export const listTestWorkspaceMembers: ReturnType = workspaceTestHelpers.listTestWorkspaceMembers;
+export const updateTestMemberPermissions: ReturnType = workspaceTestHelpers.updateTestMemberPermissions;
+export const updateTestMemberRole: ReturnType = workspaceTestHelpers.updateTestMemberRole;
+export const removeTestMember: ReturnType = workspaceTestHelpers.removeTestMember;
+export const removeTestWorkspaceMember: ReturnType = workspaceTestHelpers.removeTestWorkspaceMember;
+export const cancelTestInvitation: ReturnType = workspaceTestHelpers.cancelTestInvitation;
+export const acceptTestInvitation: ReturnType = workspaceTestHelpers.acceptTestInvitation;
+export const listTestPendingInvitations: ReturnType = workspaceTestHelpers.listTestPendingInvitations;
+export const simulateEmailWebhook: ReturnType = emailTestHelpers.simulateEmailWebhook;
+export const getTestMessage: ReturnType = conversationTestHelpers.getTestMessage;
+export const updateWorkspaceOrigins: ReturnType = workspaceTestHelpers.updateWorkspaceOrigins;
+export const createTestConversationForVisitor: ReturnType = conversationTestHelpers.createTestConversationForVisitor;
+export const lookupUserByEmail: ReturnType = workspaceTestHelpers.lookupUserByEmail;
+export const lookupPendingInvitationsByEmail: ReturnType = workspaceTestHelpers.lookupPendingInvitationsByEmail;
diff --git a/packages/convex/convex/testing/helpers/README.md b/packages/convex/convex/testing/helpers/README.md
new file mode 100644
index 0000000..2a6fe64
--- /dev/null
+++ b/packages/convex/convex/testing/helpers/README.md
@@ -0,0 +1,19 @@
+# Convex Testing Helper Modules
+
+This directory owns internal test-only helper definitions grouped by domain.
+
+## Ownership
+
+- `series.ts`: Series runtime test hooks and progress fixtures.
+- `workspace.ts`: Workspace, users, invitations, membership, and workspace setting fixtures.
+- `conversations.ts`: Visitor/conversation/message fixtures and conversation operation helpers.
+- `notifications.ts`: Push campaign and notification-recipient fixture helpers.
+- `content.ts`: Survey, tooltip session, article/collection/snippet, and tour fixtures.
+- `email.ts`: Email config/conversation/message/thread fixtures and webhook simulation.
+- `tickets.ts`: Ticket and ticket-form fixtures.
+- `ai.ts`: AI response and AI settings fixtures.
+- `cleanup.ts`: Workspace and E2E cleanup helpers.
+
+## Compatibility
+
+`../helpers.ts` remains the compatibility entrypoint for `api.testing.helpers.*` while this folder owns module-local helper definitions.
diff --git a/packages/convex/convex/testing/helpers/ai.ts b/packages/convex/convex/testing/helpers/ai.ts
new file mode 100644
index 0000000..f1db74d
--- /dev/null
+++ b/packages/convex/convex/testing/helpers/ai.ts
@@ -0,0 +1,182 @@
+import { internalMutation } from "../../_generated/server";
+import { v } from "convex/values";
+
+const seedTestAIResponse = internalMutation({
+ args: {
+ conversationId: v.id("conversations"),
+ query: v.string(),
+ response: v.string(),
+ generatedCandidateResponse: v.optional(v.string()),
+ generatedCandidateSources: v.optional(
+ v.array(
+ v.object({
+ type: v.string(),
+ id: v.string(),
+ title: v.string(),
+ })
+ )
+ ),
+ generatedCandidateConfidence: v.optional(v.number()),
+ confidence: v.optional(v.number()),
+ handedOff: v.optional(v.boolean()),
+ handoffReason: v.optional(v.string()),
+ feedback: v.optional(v.union(v.literal("helpful"), v.literal("not_helpful"))),
+ sources: v.optional(
+ v.array(
+ v.object({
+ type: v.string(),
+ id: v.string(),
+ title: v.string(),
+ })
+ )
+ ),
+ model: v.optional(v.string()),
+ provider: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const confidence = args.confidence ?? 0.75;
+ const handedOff = args.handedOff ?? false;
+ const handoffReason = handedOff
+ ? (args.handoffReason ?? "AI requested human handoff")
+ : undefined;
+
+ const messageId = await ctx.db.insert("messages", {
+ conversationId: args.conversationId,
+ senderId: "ai-agent",
+ senderType: "bot",
+ content: args.response,
+ createdAt: now,
+ });
+
+ const responseId = await ctx.db.insert("aiResponses", {
+ conversationId: args.conversationId,
+ messageId,
+ query: args.query,
+ response: args.response,
+ generatedCandidateResponse: args.generatedCandidateResponse,
+ generatedCandidateSources: args.generatedCandidateSources,
+ generatedCandidateConfidence: args.generatedCandidateConfidence,
+ sources: args.sources ?? [],
+ confidence,
+ feedback: args.feedback,
+ handedOff,
+ handoffReason,
+ generationTimeMs: 120,
+ tokensUsed: 96,
+ model: args.model ?? "openai/gpt-5-nano",
+ provider: args.provider ?? "openai",
+ createdAt: now,
+ });
+
+ await ctx.db.patch(args.conversationId, {
+ status: "open",
+ updatedAt: now,
+ lastMessageAt: now,
+ aiWorkflowState: handedOff ? "handoff" : "ai_handled",
+ aiHandoffReason: handoffReason,
+ aiLastConfidence: confidence,
+ aiLastResponseAt: now,
+ });
+
+ return { responseId, messageId };
+ },
+});
+
+/**
+ * Gets a workspace with full data directly (bypasses auth-limited fields).
+ */
+const getTestAISettings = internalMutation({
+ args: { workspaceId: v.id("workspaces") },
+ handler: async (ctx, args) => {
+ const settings = await ctx.db
+ .query("aiAgentSettings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .first();
+
+ if (!settings) {
+ return {
+ enabled: false,
+ knowledgeSources: ["articles"],
+ confidenceThreshold: 0.6,
+ personality: null,
+ handoffMessage: "Let me connect you with a human agent who can help you better.",
+ workingHours: null,
+ model: "openai/gpt-5-nano",
+ suggestionsEnabled: false,
+ embeddingModel: "text-embedding-3-small",
+ };
+ }
+
+ return {
+ ...settings,
+ suggestionsEnabled: settings.suggestionsEnabled ?? false,
+ embeddingModel: settings.embeddingModel ?? "text-embedding-3-small",
+ };
+ },
+});
+
+/**
+ * Updates AI agent settings directly (bypasses auth).
+ */
+const updateTestAISettings = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ enabled: v.optional(v.boolean()),
+ model: v.optional(v.string()),
+ confidenceThreshold: v.optional(v.number()),
+ knowledgeSources: v.optional(v.array(v.string())),
+ personality: v.optional(v.string()),
+ handoffMessage: v.optional(v.string()),
+ suggestionsEnabled: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const existing = await ctx.db
+ .query("aiAgentSettings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .first();
+
+ const { workspaceId, ...updates } = args;
+
+ if (existing) {
+ const patchData: Record = { updatedAt: now };
+ if (updates.enabled !== undefined) patchData.enabled = updates.enabled;
+ if (updates.model !== undefined) patchData.model = updates.model;
+ if (updates.confidenceThreshold !== undefined)
+ patchData.confidenceThreshold = updates.confidenceThreshold;
+ if (updates.knowledgeSources !== undefined)
+ patchData.knowledgeSources = updates.knowledgeSources;
+ if (updates.personality !== undefined) patchData.personality = updates.personality;
+ if (updates.handoffMessage !== undefined) patchData.handoffMessage = updates.handoffMessage;
+ if (updates.suggestionsEnabled !== undefined)
+ patchData.suggestionsEnabled = updates.suggestionsEnabled;
+ await ctx.db.patch(existing._id, patchData as any);
+ return existing._id;
+ }
+
+ return await ctx.db.insert("aiAgentSettings", {
+ workspaceId,
+ enabled: args.enabled ?? false,
+ knowledgeSources: (args.knowledgeSources as any) ?? ["articles"],
+ confidenceThreshold: args.confidenceThreshold ?? 0.6,
+ personality: args.personality,
+ handoffMessage:
+ args.handoffMessage ?? "Let me connect you with a human agent who can help you better.",
+ model: args.model ?? "openai/gpt-5-nano",
+ suggestionsEnabled: args.suggestionsEnabled ?? false,
+ createdAt: now,
+ updatedAt: now,
+ });
+ },
+});
+
+/**
+ * Gets a visitor by ID directly (bypasses auth).
+ */
+
+export const aiTestHelpers: Record> = {
+ seedTestAIResponse,
+ getTestAISettings,
+ updateTestAISettings,
+} as const;
diff --git a/packages/convex/convex/testing/helpers/cleanup.ts b/packages/convex/convex/testing/helpers/cleanup.ts
new file mode 100644
index 0000000..7b44b3f
--- /dev/null
+++ b/packages/convex/convex/testing/helpers/cleanup.ts
@@ -0,0 +1,429 @@
+import { internalMutation } from "../../_generated/server";
+import { v } from "convex/values";
+import { Id } from "../../_generated/dataModel";
+
+const cleanupTestData = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args) => {
+ const { workspaceId } = args;
+
+ const conversations = await ctx.db
+ .query("conversations")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const conversation of conversations) {
+ const messages = await ctx.db
+ .query("messages")
+ .withIndex("by_conversation", (q) => q.eq("conversationId", conversation._id))
+ .collect();
+ for (const message of messages) {
+ await ctx.db.delete(message._id);
+ }
+ await ctx.db.delete(conversation._id);
+ }
+
+ const visitors = await ctx.db
+ .query("visitors")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ const visitorPushTokens = await ctx.db
+ .query("visitorPushTokens")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const token of visitorPushTokens) {
+ await ctx.db.delete(token._id);
+ }
+
+ for (const visitor of visitors) {
+ await ctx.db.delete(visitor._id);
+ }
+
+ const members = await ctx.db
+ .query("workspaceMembers")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const member of members) {
+ await ctx.db.delete(member._id);
+ }
+
+ const users = await ctx.db
+ .query("users")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const user of users) {
+ const pushTokens = await ctx.db
+ .query("pushTokens")
+ .withIndex("by_user", (q) => q.eq("userId", user._id))
+ .collect();
+ for (const token of pushTokens) {
+ await ctx.db.delete(token._id);
+ }
+
+ const notifPrefs = await ctx.db
+ .query("notificationPreferences")
+ .withIndex("by_user", (q) => q.eq("userId", user._id))
+ .collect();
+ for (const pref of notifPrefs) {
+ await ctx.db.delete(pref._id);
+ }
+
+ await ctx.db.delete(user._id);
+ }
+
+ const invitations = await ctx.db
+ .query("workspaceInvitations")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const invitation of invitations) {
+ await ctx.db.delete(invitation._id);
+ }
+
+ const workspaceNotificationDefaults = await ctx.db
+ .query("workspaceNotificationDefaults")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const defaults of workspaceNotificationDefaults) {
+ await ctx.db.delete(defaults._id);
+ }
+
+ // Clean up content folders
+ const contentFolders = await ctx.db
+ .query("contentFolders")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const folder of contentFolders) {
+ await ctx.db.delete(folder._id);
+ }
+
+ // Clean up internal articles
+ const internalArticles = await ctx.db
+ .query("internalArticles")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const article of internalArticles) {
+ await ctx.db.delete(article._id);
+ }
+
+ // Clean up recent content access for users in this workspace
+ for (const user of users) {
+ const recentAccess = await ctx.db
+ .query("recentContentAccess")
+ .withIndex("by_user_workspace", (q) =>
+ q.eq("userId", user._id).eq("workspaceId", workspaceId)
+ )
+ .collect();
+ for (const access of recentAccess) {
+ await ctx.db.delete(access._id);
+ }
+ }
+
+ // Clean up articles
+ const articles = await ctx.db
+ .query("articles")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const article of articles) {
+ await ctx.db.delete(article._id);
+ }
+
+ // Clean up collections
+ const collections = await ctx.db
+ .query("collections")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const collection of collections) {
+ await ctx.db.delete(collection._id);
+ }
+
+ // Clean up help center import archives
+ const importArchives = await ctx.db
+ .query("helpCenterImportArchives")
+ .withIndex("by_workspace_deleted_at", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const archive of importArchives) {
+ await ctx.db.delete(archive._id);
+ }
+
+ // Clean up help center import sources
+ const importSources = await ctx.db
+ .query("helpCenterImportSources")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const source of importSources) {
+ await ctx.db.delete(source._id);
+ }
+
+ // Clean up snippets
+ const snippets = await ctx.db
+ .query("snippets")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const snippet of snippets) {
+ await ctx.db.delete(snippet._id);
+ }
+
+ // Clean up content embeddings
+ const contentEmbeddings = await ctx.db
+ .query("contentEmbeddings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const embedding of contentEmbeddings) {
+ await ctx.db.delete(embedding._id);
+ }
+
+ // Clean up suggestion feedback
+ const suggestionFeedback = await ctx.db
+ .query("suggestionFeedback")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const feedback of suggestionFeedback) {
+ await ctx.db.delete(feedback._id);
+ }
+
+ // Clean up AI agent settings
+ const aiSettings = await ctx.db
+ .query("aiAgentSettings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const setting of aiSettings) {
+ await ctx.db.delete(setting._id);
+ }
+
+ // Clean up automation settings
+ const automationSettings = await ctx.db
+ .query("automationSettings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const setting of automationSettings) {
+ await ctx.db.delete(setting._id);
+ }
+
+ // Clean up CSAT responses
+ const csatResponses = await ctx.db
+ .query("csatResponses")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const response of csatResponses) {
+ await ctx.db.delete(response._id);
+ }
+
+ // Clean up report snapshots
+ const reportSnapshots = await ctx.db
+ .query("reportSnapshots")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const snapshot of reportSnapshots) {
+ await ctx.db.delete(snapshot._id);
+ }
+
+ // Clean up email configs
+ const emailConfigs = await ctx.db
+ .query("emailConfigs")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const config of emailConfigs) {
+ await ctx.db.delete(config._id);
+ }
+
+ // Clean up email threads
+ for (const conversation of conversations) {
+ const threads = await ctx.db
+ .query("emailThreads")
+ .withIndex("by_conversation", (q) => q.eq("conversationId", conversation._id))
+ .collect();
+ for (const thread of threads) {
+ await ctx.db.delete(thread._id);
+ }
+ }
+
+ // Clean up tickets and ticket comments
+ const tickets = await ctx.db
+ .query("tickets")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const ticket of tickets) {
+ const comments = await ctx.db
+ .query("ticketComments")
+ .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
+ .collect();
+ for (const comment of comments) {
+ await ctx.db.delete(comment._id);
+ }
+ await ctx.db.delete(ticket._id);
+ }
+
+ // Clean up ticket forms
+ const ticketForms = await ctx.db
+ .query("ticketForms")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const form of ticketForms) {
+ await ctx.db.delete(form._id);
+ }
+
+ await ctx.db.delete(workspaceId);
+
+ return { success: true };
+ },
+});
+
+/**
+ * Cleans up all E2E test data from the database.
+ * This removes all users with emails matching *@test.opencom.dev pattern
+ * and their associated workspaces, conversations, etc.
+ *
+ * Can be run manually or at the start/end of E2E test runs.
+ */
+const cleanupE2ETestData = internalMutation({
+ args: {},
+ handler: async (ctx) => {
+ let deletedUsers = 0;
+ let deletedWorkspaces = 0;
+ let deletedConversations = 0;
+ let deletedMessages = 0;
+ let deletedVisitors = 0;
+ let deletedMembers = 0;
+ let deletedInvitations = 0;
+
+ // Find all test users (emails ending with @test.opencom.dev)
+ const allUsers = await ctx.db.query("users").collect();
+ const testUsers = allUsers.filter(
+ (user) => user.email && user.email.endsWith("@test.opencom.dev")
+ );
+
+ // Collect unique workspace IDs from test users
+ const testWorkspaceIds = new Set>();
+ for (const user of testUsers) {
+ if (user.workspaceId) {
+ testWorkspaceIds.add(user.workspaceId);
+ }
+ }
+
+ // Clean up each test workspace
+ for (const workspaceId of testWorkspaceIds) {
+ // Delete conversations and messages
+ const conversations = await ctx.db
+ .query("conversations")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ for (const conversation of conversations) {
+ const messages = await ctx.db
+ .query("messages")
+ .withIndex("by_conversation", (q) => q.eq("conversationId", conversation._id))
+ .collect();
+ for (const message of messages) {
+ await ctx.db.delete(message._id);
+ deletedMessages++;
+ }
+ await ctx.db.delete(conversation._id);
+ deletedConversations++;
+ }
+
+ // Delete visitors
+ const visitors = await ctx.db
+ .query("visitors")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+
+ const visitorPushTokens = await ctx.db
+ .query("visitorPushTokens")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const token of visitorPushTokens) {
+ await ctx.db.delete(token._id);
+ }
+
+ for (const visitor of visitors) {
+ await ctx.db.delete(visitor._id);
+ deletedVisitors++;
+ }
+
+ // Delete workspace members
+ const members = await ctx.db
+ .query("workspaceMembers")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const member of members) {
+ await ctx.db.delete(member._id);
+ deletedMembers++;
+ }
+
+ // Delete invitations
+ const invitations = await ctx.db
+ .query("workspaceInvitations")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const invitation of invitations) {
+ await ctx.db.delete(invitation._id);
+ deletedInvitations++;
+ }
+
+ const workspaceNotificationDefaults = await ctx.db
+ .query("workspaceNotificationDefaults")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ for (const defaults of workspaceNotificationDefaults) {
+ await ctx.db.delete(defaults._id);
+ }
+
+ // Delete the workspace
+ try {
+ await ctx.db.delete(workspaceId);
+ deletedWorkspaces++;
+ } catch (e) {
+ // Workspace might already be deleted
+ }
+ }
+
+ // Delete test users and their data
+ for (const user of testUsers) {
+ // Delete push tokens
+ const pushTokens = await ctx.db
+ .query("pushTokens")
+ .withIndex("by_user", (q) => q.eq("userId", user._id))
+ .collect();
+ for (const token of pushTokens) {
+ await ctx.db.delete(token._id);
+ }
+
+ // Delete notification preferences
+ const notifPrefs = await ctx.db
+ .query("notificationPreferences")
+ .withIndex("by_user", (q) => q.eq("userId", user._id))
+ .collect();
+ for (const pref of notifPrefs) {
+ await ctx.db.delete(pref._id);
+ }
+
+ await ctx.db.delete(user._id);
+ deletedUsers++;
+ }
+
+ return {
+ success: true,
+ deleted: {
+ users: deletedUsers,
+ workspaces: deletedWorkspaces,
+ conversations: deletedConversations,
+ messages: deletedMessages,
+ visitors: deletedVisitors,
+ members: deletedMembers,
+ invitations: deletedInvitations,
+ },
+ };
+ },
+});
+
+/**
+ * Creates a test email config for a workspace.
+ */
+
+export const cleanupTestHelpers: Record> = {
+ cleanupTestData,
+ cleanupE2ETestData,
+} as const;
diff --git a/packages/convex/convex/testing/helpers/content.ts b/packages/convex/convex/testing/helpers/content.ts
new file mode 100644
index 0000000..2284d64
--- /dev/null
+++ b/packages/convex/convex/testing/helpers/content.ts
@@ -0,0 +1,514 @@
+import { internalMutation } from "../../_generated/server";
+import { v } from "convex/values";
+
+const createTestSurvey = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.optional(v.string()),
+ status: v.optional(
+ v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))
+ ),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const randomSuffix = Math.random().toString(36).slice(2, 8);
+ const surveyId = await ctx.db.insert("surveys", {
+ workspaceId: args.workspaceId,
+ name: args.name ?? `Test Survey ${randomSuffix}`,
+ format: "small",
+ status: args.status ?? "active",
+ questions: [
+ {
+ id: "q1",
+ type: "nps" as const,
+ title: "How likely are you to recommend us?",
+ required: true,
+ },
+ ],
+ frequency: "once",
+ showProgressBar: true,
+ showDismissButton: true,
+ triggers: { type: "immediate" as const },
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ return { surveyId };
+ },
+});
+
+/**
+ * Forces a tooltip authoring session to expire for deterministic test scenarios.
+ */
+const expireTooltipAuthoringSession = internalMutation({
+ args: {
+ token: v.string(),
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args) => {
+ const session = await ctx.db
+ .query("tooltipAuthoringSessions")
+ .withIndex("by_token", (q) => q.eq("token", args.token))
+ .first();
+
+ if (!session) {
+ throw new Error("Session not found");
+ }
+ if (session.workspaceId !== args.workspaceId) {
+ throw new Error("Session workspace mismatch");
+ }
+
+ await ctx.db.patch(session._id, {
+ expiresAt: Date.now() - 1000,
+ status: "active",
+ });
+
+ return { sessionId: session._id };
+ },
+});
+
+/**
+ * Completes a tooltip authoring session for deterministic E2E flows.
+ */
+const completeTooltipAuthoringSession = internalMutation({
+ args: {
+ token: v.string(),
+ workspaceId: v.id("workspaces"),
+ elementSelector: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const session = await ctx.db
+ .query("tooltipAuthoringSessions")
+ .withIndex("by_token", (q) => q.eq("token", args.token))
+ .first();
+
+ if (!session) {
+ throw new Error("Session not found");
+ }
+ if (session.workspaceId !== args.workspaceId) {
+ throw new Error("Session workspace mismatch");
+ }
+
+ const quality = {
+ score: 90,
+ grade: "good" as const,
+ warnings: [] as string[],
+ signals: {
+ matchCount: 1,
+ depth: 1,
+ usesNth: false,
+ hasId: args.elementSelector.includes("#"),
+ hasDataAttribute: args.elementSelector.includes("[data-"),
+ classCount: (args.elementSelector.match(/\.[A-Za-z0-9_-]+/g) ?? []).length,
+ usesWildcard: args.elementSelector.includes("*"),
+ },
+ };
+
+ await ctx.db.patch(session._id, {
+ selectedSelector: args.elementSelector,
+ selectedSelectorQuality: quality,
+ status: "completed",
+ });
+
+ if (session.tooltipId) {
+ await ctx.db.patch(session.tooltipId, {
+ elementSelector: args.elementSelector,
+ selectorQuality: quality,
+ updatedAt: Date.now(),
+ });
+ }
+
+ return { sessionId: session._id };
+ },
+});
+
+/**
+ * Creates a test series directly (bypasses auth on series.create).
+ */
+const createTestCollection = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.string(),
+ description: v.optional(v.string()),
+ icon: v.optional(v.string()),
+ parentId: v.optional(v.id("collections")),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const baseSlug = args.name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+
+ return await ctx.db.insert("collections", {
+ workspaceId: args.workspaceId,
+ name: args.name,
+ slug: `${baseSlug}-${randomSuffix}`,
+ description: args.description,
+ icon: args.icon,
+ parentId: args.parentId,
+ order: now,
+ createdAt: now,
+ updatedAt: now,
+ });
+ },
+});
+
+/**
+ * Creates an article directly (bypasses auth on articles.create).
+ */
+const createTestArticle = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ title: v.string(),
+ content: v.string(),
+ collectionId: v.optional(v.id("collections")),
+ widgetLargeScreen: v.optional(v.boolean()),
+ status: v.optional(v.union(v.literal("draft"), v.literal("published"))),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const slug = args.title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+
+ const articleId = await ctx.db.insert("articles", {
+ workspaceId: args.workspaceId,
+ collectionId: args.collectionId,
+ title: args.title,
+ slug: `${slug}-${randomSuffix}`,
+ content: args.content,
+ widgetLargeScreen: args.widgetLargeScreen ?? false,
+ status: args.status || "draft",
+ order: 0,
+ createdAt: now,
+ updatedAt: now,
+ ...(args.status === "published" ? { publishedAt: now } : {}),
+ });
+
+ return articleId;
+ },
+});
+
+/**
+ * Publishes an article directly (bypasses auth on articles.publish).
+ */
+const publishTestArticle = internalMutation({
+ args: { id: v.id("articles") },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.id, {
+ status: "published",
+ publishedAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ },
+});
+
+/**
+ * Updates an article directly (bypasses auth on articles.update).
+ */
+const updateTestArticle = internalMutation({
+ args: {
+ id: v.id("articles"),
+ title: v.optional(v.string()),
+ content: v.optional(v.string()),
+ audienceRules: v.optional(v.any()),
+ widgetLargeScreen: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const { id, ...updates } = args;
+ await ctx.db.patch(id, { ...updates, updatedAt: Date.now() });
+ },
+});
+
+/**
+ * Removes an article directly (bypasses auth on articles.remove).
+ */
+const removeTestArticle = internalMutation({
+ args: { id: v.id("articles") },
+ handler: async (ctx, args) => {
+ await ctx.db.delete(args.id);
+ },
+});
+
+/**
+ * Creates an internal article directly (bypasses auth).
+ */
+const createTestInternalArticle = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ title: v.string(),
+ content: v.string(),
+ tags: v.optional(v.array(v.string())),
+ folderId: v.optional(v.id("contentFolders")),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const articleId = await ctx.db.insert("internalArticles", {
+ workspaceId: args.workspaceId,
+ title: args.title,
+ content: args.content,
+ tags: args.tags || [],
+ folderId: args.folderId,
+ status: "draft",
+ createdAt: now,
+ updatedAt: now,
+ });
+ return articleId;
+ },
+});
+
+/**
+ * Publishes an internal article directly (bypasses auth).
+ */
+const publishTestInternalArticle = internalMutation({
+ args: { id: v.id("internalArticles") },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.id, {
+ status: "published",
+ publishedAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+ },
+});
+
+/**
+ * Creates a snippet directly (bypasses auth).
+ */
+const createTestSnippet = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.string(),
+ content: v.string(),
+ shortcut: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const snippetId = await ctx.db.insert("snippets", {
+ workspaceId: args.workspaceId,
+ name: args.name,
+ content: args.content,
+ shortcut: args.shortcut,
+ createdAt: now,
+ updatedAt: now,
+ });
+ return snippetId;
+ },
+});
+
+/**
+ * Creates a content folder directly (bypasses auth).
+ */
+const createTestContentFolder = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.string(),
+ parentId: v.optional(v.id("contentFolders")),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const folderId = await ctx.db.insert("contentFolders", {
+ workspaceId: args.workspaceId,
+ name: args.name,
+ parentId: args.parentId,
+ order: 0,
+ createdAt: now,
+ updatedAt: now,
+ });
+ return folderId;
+ },
+});
+
+/**
+ * Creates a tour directly (bypasses auth on tours.create).
+ */
+const createTestTour = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.string(),
+ description: v.optional(v.string()),
+ status: v.optional(v.union(v.literal("draft"), v.literal("active"), v.literal("archived"))),
+ targetingRules: v.optional(v.any()),
+ audienceRules: v.optional(v.any()),
+ displayMode: v.optional(v.union(v.literal("first_time_only"), v.literal("until_dismissed"))),
+ priority: v.optional(v.number()),
+ buttonColor: v.optional(v.string()),
+ senderId: v.optional(v.id("users")),
+ showConfetti: v.optional(v.boolean()),
+ allowSnooze: v.optional(v.boolean()),
+ allowRestart: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const tourId = await ctx.db.insert("tours", {
+ workspaceId: args.workspaceId,
+ name: args.name,
+ description: args.description,
+ status: args.status || "draft",
+ targetingRules: args.targetingRules,
+ audienceRules: args.audienceRules,
+ displayMode: args.displayMode ?? "first_time_only",
+ priority: args.priority ?? 0,
+ buttonColor: args.buttonColor,
+ senderId: args.senderId,
+ showConfetti: args.showConfetti ?? true,
+ allowSnooze: args.allowSnooze ?? true,
+ allowRestart: args.allowRestart ?? true,
+ createdAt: now,
+ updatedAt: now,
+ });
+ return tourId;
+ },
+});
+
+/**
+ * Updates a tour directly (bypasses auth on tours.update).
+ */
+const updateTestTour = internalMutation({
+ args: {
+ id: v.id("tours"),
+ name: v.optional(v.string()),
+ description: v.optional(v.string()),
+ targetingRules: v.optional(v.any()),
+ audienceRules: v.optional(v.any()),
+ displayMode: v.optional(v.union(v.literal("first_time_only"), v.literal("until_dismissed"))),
+ priority: v.optional(v.number()),
+ buttonColor: v.optional(v.string()),
+ showConfetti: v.optional(v.boolean()),
+ allowSnooze: v.optional(v.boolean()),
+ allowRestart: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const { id, ...updates } = args;
+ await ctx.db.patch(id, { ...updates, updatedAt: Date.now() });
+ },
+});
+
+/**
+ * Removes a tour and its steps directly (bypasses auth on tours.remove).
+ */
+const removeTestTour = internalMutation({
+ args: { id: v.id("tours") },
+ handler: async (ctx, args) => {
+ const steps = await ctx.db
+ .query("tourSteps")
+ .withIndex("by_tour", (q) => q.eq("tourId", args.id))
+ .collect();
+ for (const step of steps) {
+ await ctx.db.delete(step._id);
+ }
+ await ctx.db.delete(args.id);
+ return { success: true };
+ },
+});
+
+/**
+ * Activates a tour directly (bypasses auth).
+ */
+const activateTestTour = internalMutation({
+ args: { id: v.id("tours") },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.id, { status: "active", updatedAt: Date.now() });
+ },
+});
+
+/**
+ * Deactivates a tour directly (bypasses auth).
+ */
+const deactivateTestTour = internalMutation({
+ args: { id: v.id("tours") },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.id, { status: "draft", updatedAt: Date.now() });
+ },
+});
+
+/**
+ * Gets a tour by ID directly (bypasses auth).
+ */
+const getTestTour = internalMutation({
+ args: { id: v.id("tours") },
+ handler: async (ctx, args) => {
+ return await ctx.db.get(args.id);
+ },
+});
+
+/**
+ * Lists tours for a workspace directly (bypasses auth).
+ */
+const listTestTours = internalMutation({
+ args: { workspaceId: v.id("workspaces") },
+ handler: async (ctx, args) => {
+ return await ctx.db
+ .query("tours")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .collect();
+ },
+});
+
+/**
+ * Duplicates a tour directly (bypasses auth).
+ */
+const duplicateTestTour = internalMutation({
+ args: {
+ id: v.id("tours"),
+ name: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const tour = await ctx.db.get(args.id);
+ if (!tour) throw new Error("Tour not found");
+ const now = Date.now();
+ const { _id: _tourId, _creationTime: _tourCt, ...tourData } = tour;
+ const newTourId = await ctx.db.insert("tours", {
+ ...tourData,
+ name: args.name || `${tour.name} (Copy)`,
+ status: "draft",
+ createdAt: now,
+ updatedAt: now,
+ });
+ const steps = await ctx.db
+ .query("tourSteps")
+ .withIndex("by_tour", (q) => q.eq("tourId", args.id))
+ .collect();
+ for (const step of steps) {
+ const { _id: _stepId, _creationTime: _stepCt, ...stepData } = step;
+ await ctx.db.insert("tourSteps", {
+ ...stepData,
+ workspaceId: tour.workspaceId,
+ tourId: newTourId,
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+ return newTourId;
+ },
+});
+
+/**
+ * Gets a conversation by ID directly (bypasses auth).
+ */
+
+export const contentTestHelpers: Record> = {
+ createTestSurvey,
+ expireTooltipAuthoringSession,
+ completeTooltipAuthoringSession,
+ createTestCollection,
+ createTestArticle,
+ publishTestArticle,
+ updateTestArticle,
+ removeTestArticle,
+ createTestInternalArticle,
+ publishTestInternalArticle,
+ createTestSnippet,
+ createTestContentFolder,
+ createTestTour,
+ updateTestTour,
+ removeTestTour,
+ activateTestTour,
+ deactivateTestTour,
+ getTestTour,
+ listTestTours,
+ duplicateTestTour,
+} as const;
diff --git a/packages/convex/convex/testing/helpers/conversations.ts b/packages/convex/convex/testing/helpers/conversations.ts
new file mode 100644
index 0000000..aa79234
--- /dev/null
+++ b/packages/convex/convex/testing/helpers/conversations.ts
@@ -0,0 +1,328 @@
+import { internalMutation } from "../../_generated/server";
+import { v } from "convex/values";
+import { formatReadableVisitorId } from "../../visitorReadableId";
+
+const createTestVisitor = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ email: v.optional(v.string()),
+ name: v.optional(v.string()),
+ externalUserId: v.optional(v.string()),
+ customAttributes: v.optional(v.any()),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const sessionId = `test-session-${timestamp}-${randomSuffix}`;
+
+ const visitorId = await ctx.db.insert("visitors", {
+ sessionId,
+ workspaceId: args.workspaceId,
+ email: args.email,
+ name: args.name,
+ externalUserId: args.externalUserId,
+ customAttributes: args.customAttributes,
+ createdAt: timestamp,
+ firstSeenAt: timestamp,
+ lastSeenAt: timestamp,
+ });
+
+ await ctx.db.patch(visitorId, {
+ readableId: formatReadableVisitorId(visitorId),
+ });
+
+ return { visitorId, sessionId };
+ },
+});
+
+/**
+ * Creates a test conversation in the specified workspace.
+ */
+const createTestConversation = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ visitorId: v.optional(v.id("visitors")),
+ userId: v.optional(v.id("users")),
+ assignedAgentId: v.optional(v.id("users")),
+ status: v.optional(v.union(v.literal("open"), v.literal("closed"), v.literal("snoozed"))),
+ firstResponseAt: v.optional(v.number()),
+ resolvedAt: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+
+ const conversationId = await ctx.db.insert("conversations", {
+ workspaceId: args.workspaceId,
+ visitorId: args.visitorId,
+ userId: args.userId,
+ assignedAgentId: args.assignedAgentId,
+ status: args.status || "open",
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ unreadByAgent: 0,
+ unreadByVisitor: 0,
+ firstResponseAt: args.firstResponseAt,
+ resolvedAt: args.resolvedAt,
+ aiWorkflowState: "none",
+ });
+
+ return { conversationId };
+ },
+});
+
+/**
+ * Creates a test survey directly (bypasses auth on surveys.create).
+ */
+const createTestMessage = internalMutation({
+ args: {
+ conversationId: v.id("conversations"),
+ content: v.string(),
+ senderType: v.union(
+ v.literal("user"),
+ v.literal("visitor"),
+ v.literal("agent"),
+ v.literal("bot")
+ ),
+ senderId: v.optional(v.string()),
+ emailMessageId: v.optional(v.string()),
+ externalEmailId: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+ const senderId = args.senderId || `test-sender-${timestamp}`;
+
+ const emailId = args.emailMessageId || args.externalEmailId;
+ const messageId = await ctx.db.insert("messages", {
+ conversationId: args.conversationId,
+ senderId,
+ senderType: args.senderType,
+ content: args.content,
+ createdAt: timestamp,
+ ...(emailId && {
+ channel: "email" as const,
+ emailMetadata: { messageId: emailId },
+ deliveryStatus: "pending" as const,
+ }),
+ });
+
+ await ctx.db.patch(args.conversationId, {
+ lastMessageAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { messageId };
+ },
+});
+const getTestConversation = internalMutation({
+ args: { id: v.id("conversations") },
+ handler: async (ctx, args) => {
+ return await ctx.db.get(args.id);
+ },
+});
+
+/**
+ * Lists conversations for a workspace directly (bypasses auth).
+ */
+const listTestConversations = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ status: v.optional(v.union(v.literal("open"), v.literal("closed"), v.literal("snoozed"))),
+ },
+ handler: async (ctx, args) => {
+ if (args.status) {
+ return await ctx.db
+ .query("conversations")
+ .withIndex("by_status", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("status", args.status!)
+ )
+ .order("desc")
+ .collect();
+ }
+ return await ctx.db
+ .query("conversations")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .order("desc")
+ .collect();
+ },
+});
+
+/**
+ * Updates conversation status directly (bypasses auth on conversations.updateStatus).
+ */
+const updateTestConversationStatus = internalMutation({
+ args: {
+ id: v.id("conversations"),
+ status: v.union(v.literal("open"), v.literal("closed"), v.literal("snoozed")),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const patch: Record = {
+ status: args.status,
+ updatedAt: now,
+ };
+ if (args.status === "closed") {
+ patch.resolvedAt = now;
+ } else if (args.status === "open") {
+ patch.resolvedAt = undefined;
+ }
+ await ctx.db.patch(args.id, patch);
+ },
+});
+
+/**
+ * Assigns a conversation directly (bypasses auth on conversations.assign).
+ */
+const assignTestConversation = internalMutation({
+ args: {
+ id: v.id("conversations"),
+ agentId: v.id("users"),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.id, {
+ assignedAgentId: args.agentId,
+ updatedAt: Date.now(),
+ });
+ },
+});
+
+/**
+ * Marks a conversation as read directly (bypasses auth).
+ */
+const markTestConversationAsRead = internalMutation({
+ args: { id: v.id("conversations") },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.id, {
+ unreadByAgent: 0,
+ updatedAt: Date.now(),
+ });
+ },
+});
+
+/**
+ * Lists messages for a conversation directly (bypasses auth).
+ */
+const listTestMessages = internalMutation({
+ args: { conversationId: v.id("conversations") },
+ handler: async (ctx, args) => {
+ return await ctx.db
+ .query("messages")
+ .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId))
+ .order("asc")
+ .collect();
+ },
+});
+
+/**
+ * Sends a message directly (bypasses auth on messages.send, including bot restriction).
+ */
+const sendTestMessageDirect = internalMutation({
+ args: {
+ conversationId: v.id("conversations"),
+ senderId: v.string(),
+ senderType: v.union(
+ v.literal("user"),
+ v.literal("visitor"),
+ v.literal("agent"),
+ v.literal("bot")
+ ),
+ content: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const messageId = await ctx.db.insert("messages", {
+ conversationId: args.conversationId,
+ senderId: args.senderId,
+ senderType: args.senderType,
+ content: args.content,
+ createdAt: now,
+ });
+
+ const conversation = await ctx.db.get(args.conversationId);
+ const unreadUpdate: Record = {};
+ if (args.senderType === "visitor") {
+ unreadUpdate.unreadByAgent = (conversation?.unreadByAgent || 0) + 1;
+ } else if (args.senderType === "agent" || args.senderType === "user") {
+ unreadUpdate.unreadByVisitor = (conversation?.unreadByVisitor || 0) + 1;
+ }
+
+ await ctx.db.patch(args.conversationId, {
+ updatedAt: now,
+ lastMessageAt: now,
+ ...unreadUpdate,
+ });
+ return messageId;
+ },
+});
+
+/**
+ * Seeds an AI response record and updates conversation workflow fields for deterministic tests.
+ */
+const getTestVisitor = internalMutation({
+ args: { id: v.id("visitors") },
+ handler: async (ctx, args) => {
+ return await ctx.db.get(args.id);
+ },
+});
+
+/**
+ * Adds a workspace member directly (bypasses auth on workspaceMembers.add).
+ */
+const getTestMessage = internalMutation({
+ args: { id: v.id("messages") },
+ handler: async (ctx, args) => {
+ return await ctx.db.get(args.id);
+ },
+});
+
+/**
+ * Updates workspace allowed origins directly (bypasses auth).
+ */
+const createTestConversationForVisitor = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ visitorId: v.id("visitors"),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const id = await ctx.db.insert("conversations", {
+ workspaceId: args.workspaceId,
+ visitorId: args.visitorId,
+ status: "open",
+ createdAt: now,
+ updatedAt: now,
+ lastMessageAt: now,
+ aiWorkflowState: "none",
+ });
+
+ await ctx.db.insert("messages", {
+ conversationId: id,
+ senderId: "system",
+ senderType: "bot",
+ content: "Hi! How can we help you today?",
+ createdAt: now,
+ });
+
+ return await ctx.db.get(id);
+ },
+});
+
+/**
+ * Looks up a user by email using the by_email index.
+ * Mirrors the typed query pattern used in authConvex createOrUpdateUser.
+ */
+
+export const conversationTestHelpers: Record> = {
+ createTestVisitor,
+ createTestConversation,
+ createTestMessage,
+ getTestConversation,
+ listTestConversations,
+ updateTestConversationStatus,
+ assignTestConversation,
+ markTestConversationAsRead,
+ listTestMessages,
+ sendTestMessageDirect,
+ getTestVisitor,
+ getTestMessage,
+ createTestConversationForVisitor,
+} as const;
diff --git a/packages/convex/convex/testing/helpers/email.ts b/packages/convex/convex/testing/helpers/email.ts
new file mode 100644
index 0000000..1a28406
--- /dev/null
+++ b/packages/convex/convex/testing/helpers/email.ts
@@ -0,0 +1,189 @@
+import { internalMutation } from "../../_generated/server";
+import { v } from "convex/values";
+
+const createTestEmailConfig = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ enabled: v.optional(v.boolean()),
+ fromName: v.optional(v.string()),
+ fromEmail: v.optional(v.string()),
+ signature: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const forwardingAddress = `test-inbox-${randomSuffix}@mail.opencom.app`;
+
+ const emailConfigId = await ctx.db.insert("emailConfigs", {
+ workspaceId: args.workspaceId,
+ forwardingAddress,
+ fromName: args.fromName,
+ fromEmail: args.fromEmail,
+ signature: args.signature,
+ enabled: args.enabled ?? true,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { emailConfigId, forwardingAddress };
+ },
+});
+
+/**
+ * Creates a test email conversation with email-specific fields.
+ */
+const createTestEmailConversation = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ visitorId: v.optional(v.id("visitors")),
+ subject: v.string(),
+ status: v.optional(v.union(v.literal("open"), v.literal("closed"), v.literal("snoozed"))),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+
+ const conversationId = await ctx.db.insert("conversations", {
+ workspaceId: args.workspaceId,
+ visitorId: args.visitorId,
+ status: args.status || "open",
+ channel: "email",
+ subject: args.subject,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ unreadByAgent: 0,
+ unreadByVisitor: 0,
+ });
+
+ return { conversationId };
+ },
+});
+
+/**
+ * Creates a test email message with email metadata.
+ */
+const createTestEmailMessage = internalMutation({
+ args: {
+ conversationId: v.id("conversations"),
+ content: v.string(),
+ senderType: v.union(v.literal("visitor"), v.literal("agent")),
+ senderId: v.optional(v.string()),
+ subject: v.string(),
+ from: v.string(),
+ to: v.array(v.string()),
+ messageId: v.string(),
+ inReplyTo: v.optional(v.string()),
+ references: v.optional(v.array(v.string())),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+ const senderId = args.senderId || `test-sender-${timestamp}`;
+
+ const dbMessageId = await ctx.db.insert("messages", {
+ conversationId: args.conversationId,
+ senderId,
+ senderType: args.senderType,
+ content: args.content,
+ channel: "email",
+ emailMetadata: {
+ subject: args.subject,
+ from: args.from,
+ to: args.to,
+ messageId: args.messageId,
+ inReplyTo: args.inReplyTo,
+ references: args.references,
+ },
+ createdAt: timestamp,
+ });
+
+ await ctx.db.patch(args.conversationId, {
+ lastMessageAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { messageId: dbMessageId };
+ },
+});
+
+/**
+ * Creates a test email thread record for thread matching tests.
+ */
+const createTestEmailThread = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ conversationId: v.id("conversations"),
+ messageId: v.string(),
+ subject: v.string(),
+ senderEmail: v.string(),
+ inReplyTo: v.optional(v.string()),
+ references: v.optional(v.array(v.string())),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+ const normalizedSubject = args.subject
+ .replace(/^(re|fwd|fw):\s*/gi, "")
+ .replace(/\s+/g, " ")
+ .trim()
+ .toLowerCase();
+
+ const threadId = await ctx.db.insert("emailThreads", {
+ workspaceId: args.workspaceId,
+ conversationId: args.conversationId,
+ messageId: args.messageId,
+ inReplyTo: args.inReplyTo,
+ references: args.references,
+ subject: args.subject,
+ normalizedSubject,
+ senderEmail: args.senderEmail.toLowerCase(),
+ createdAt: timestamp,
+ });
+
+ return { threadId };
+ },
+});
+
+/**
+ * Creates a test ticket in the specified workspace.
+ */
+const simulateEmailWebhook = internalMutation({
+ args: {
+ eventType: v.string(),
+ emailId: v.string(),
+ },
+ handler: async (ctx, args) => {
+ // Map event type to delivery status (only schema-valid values)
+ const statusMap: Record = {
+ "email.sent": "sent",
+ "email.delivered": "delivered",
+ "email.opened": "delivered",
+ "email.clicked": "delivered",
+ "email.bounced": "bounced",
+ "email.complained": "failed",
+ "email.delivery_delayed": "pending",
+ };
+
+ const deliveryStatus = statusMap[args.eventType];
+ if (!deliveryStatus) return;
+
+ // Find message by emailMetadata.messageId
+ const message = await ctx.db
+ .query("messages")
+ .withIndex("by_email_message_id", (q) => q.eq("emailMetadata.messageId", args.emailId))
+ .first();
+
+ if (message) {
+ await ctx.db.patch(message._id, { deliveryStatus });
+ }
+ },
+});
+
+/**
+ * Gets a message by ID directly (bypasses auth).
+ */
+
+export const emailTestHelpers: Record> = {
+ createTestEmailConfig,
+ createTestEmailConversation,
+ createTestEmailMessage,
+ createTestEmailThread,
+ simulateEmailWebhook,
+} as const;
diff --git a/packages/convex/convex/testing/helpers/notifications.ts b/packages/convex/convex/testing/helpers/notifications.ts
new file mode 100644
index 0000000..5c72e9e
--- /dev/null
+++ b/packages/convex/convex/testing/helpers/notifications.ts
@@ -0,0 +1,278 @@
+import { internalMutation } from "../../_generated/server";
+import { internal } from "../../_generated/api";
+import { v } from "convex/values";
+
+const createTestPushCampaign = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.optional(v.string()),
+ title: v.optional(v.string()),
+ body: v.optional(v.string()),
+ targeting: v.optional(v.any()),
+ audienceRules: v.optional(v.any()),
+ status: v.optional(
+ v.union(
+ v.literal("draft"),
+ v.literal("scheduled"),
+ v.literal("sending"),
+ v.literal("sent"),
+ v.literal("paused")
+ )
+ ),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const randomSuffix = Math.random().toString(36).slice(2, 8);
+
+ const campaignId = await ctx.db.insert("pushCampaigns", {
+ workspaceId: args.workspaceId,
+ name: args.name ?? `Test Push Campaign ${randomSuffix}`,
+ title: args.title ?? "Test push title",
+ body: args.body ?? "Test push body",
+ targeting: args.targeting,
+ audienceRules: args.audienceRules,
+ status: args.status ?? "draft",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ return { campaignId };
+ },
+});
+const sendTestPushCampaign: ReturnType = internalMutation({
+ args: {
+ campaignId: v.id("pushCampaigns"),
+ },
+ handler: async (ctx, args): Promise => {
+ return await ctx.runMutation((internal as any).pushCampaigns.sendForTesting, {
+ id: args.campaignId,
+ });
+ },
+});
+const getTestPendingPushCampaignRecipients: ReturnType =
+ internalMutation({
+ args: {
+ campaignId: v.id("pushCampaigns"),
+ limit: v.optional(v.number()),
+ },
+ handler: async (ctx, args): Promise => {
+ return await ctx.runQuery((internal as any).pushCampaigns.getPendingRecipients, {
+ campaignId: args.campaignId,
+ limit: args.limit,
+ });
+ },
+ });
+
+/**
+ * Creates a test message in the specified conversation.
+ */
+const createTestPushToken = internalMutation({
+ args: {
+ userId: v.id("users"),
+ token: v.optional(v.string()),
+ platform: v.optional(v.union(v.literal("ios"), v.literal("android"))),
+ notificationsEnabled: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const token =
+ args.token ??
+ `ExponentPushToken[test-${args.userId}-${Math.random().toString(36).slice(2, 10)}]`;
+ const tokenId = await ctx.db.insert("pushTokens", {
+ userId: args.userId,
+ token,
+ platform: args.platform ?? "ios",
+ notificationsEnabled: args.notificationsEnabled ?? true,
+ createdAt: now,
+ });
+ return { tokenId, token };
+ },
+});
+const createTestVisitorPushToken = internalMutation({
+ args: {
+ visitorId: v.id("visitors"),
+ token: v.optional(v.string()),
+ platform: v.optional(v.union(v.literal("ios"), v.literal("android"))),
+ notificationsEnabled: v.optional(v.boolean()),
+ workspaceId: v.optional(v.id("workspaces")),
+ },
+ handler: async (ctx, args) => {
+ const visitor = await ctx.db.get(args.visitorId);
+ if (!visitor) {
+ throw new Error("Visitor not found");
+ }
+
+ const now = Date.now();
+ const token =
+ args.token ??
+ `ExponentPushToken[visitor-${args.visitorId}-${Math.random().toString(36).slice(2, 10)}]`;
+ const tokenId = await ctx.db.insert("visitorPushTokens", {
+ visitorId: args.visitorId,
+ workspaceId: args.workspaceId ?? visitor.workspaceId,
+ token,
+ platform: args.platform ?? "ios",
+ notificationsEnabled: args.notificationsEnabled ?? true,
+ createdAt: now,
+ updatedAt: now,
+ });
+ return { tokenId, token };
+ },
+});
+const upsertTestNotificationPreference = internalMutation({
+ args: {
+ userId: v.id("users"),
+ workspaceId: v.id("workspaces"),
+ muted: v.optional(v.boolean()),
+ newVisitorMessageEmail: v.optional(v.boolean()),
+ newVisitorMessagePush: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("notificationPreferences")
+ .withIndex("by_user_workspace", (q) =>
+ q.eq("userId", args.userId).eq("workspaceId", args.workspaceId)
+ )
+ .first();
+
+ const now = Date.now();
+ const nextNewVisitorMessage = {
+ ...(existing?.events?.newVisitorMessage ?? {}),
+ ...(args.newVisitorMessageEmail !== undefined ? { email: args.newVisitorMessageEmail } : {}),
+ ...(args.newVisitorMessagePush !== undefined ? { push: args.newVisitorMessagePush } : {}),
+ };
+
+ const hasEventOverrides =
+ nextNewVisitorMessage.email !== undefined || nextNewVisitorMessage.push !== undefined;
+
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ ...(args.muted !== undefined ? { muted: args.muted } : {}),
+ ...(hasEventOverrides
+ ? {
+ events: {
+ ...(existing.events ?? {}),
+ newVisitorMessage: nextNewVisitorMessage,
+ },
+ }
+ : {}),
+ updatedAt: now,
+ });
+ return { preferenceId: existing._id };
+ }
+
+ const preferenceId = await ctx.db.insert("notificationPreferences", {
+ userId: args.userId,
+ workspaceId: args.workspaceId,
+ muted: args.muted ?? false,
+ ...(hasEventOverrides
+ ? {
+ events: {
+ newVisitorMessage: nextNewVisitorMessage,
+ },
+ }
+ : {}),
+ createdAt: now,
+ updatedAt: now,
+ });
+ return { preferenceId };
+ },
+});
+const upsertTestWorkspaceNotificationDefaults = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ newVisitorMessageEmail: v.optional(v.boolean()),
+ newVisitorMessagePush: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("workspaceNotificationDefaults")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .first();
+
+ const now = Date.now();
+ const nextNewVisitorMessage = {
+ ...(existing?.events?.newVisitorMessage ?? {}),
+ ...(args.newVisitorMessageEmail !== undefined ? { email: args.newVisitorMessageEmail } : {}),
+ ...(args.newVisitorMessagePush !== undefined ? { push: args.newVisitorMessagePush } : {}),
+ };
+
+ const hasEventDefaults =
+ nextNewVisitorMessage.email !== undefined || nextNewVisitorMessage.push !== undefined;
+
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ ...(hasEventDefaults
+ ? {
+ events: {
+ ...(existing.events ?? {}),
+ newVisitorMessage: nextNewVisitorMessage,
+ },
+ }
+ : {}),
+ updatedAt: now,
+ });
+ return { defaultsId: existing._id };
+ }
+
+ const defaultsId = await ctx.db.insert("workspaceNotificationDefaults", {
+ workspaceId: args.workspaceId,
+ ...(hasEventDefaults
+ ? {
+ events: {
+ newVisitorMessage: nextNewVisitorMessage,
+ },
+ }
+ : {}),
+ createdAt: now,
+ updatedAt: now,
+ });
+ return { defaultsId };
+ },
+});
+const getTestMemberRecipientsForNewVisitorMessage: ReturnType =
+ internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args): Promise => {
+ return await ctx.runQuery(
+ (internal as any).notifications.getMemberRecipientsForNewVisitorMessage,
+ {
+ workspaceId: args.workspaceId,
+ }
+ );
+ },
+ });
+const getTestVisitorRecipientsForSupportReply: ReturnType =
+ internalMutation({
+ args: {
+ conversationId: v.id("conversations"),
+ channel: v.optional(v.union(v.literal("chat"), v.literal("email"))),
+ },
+ handler: async (ctx, args): Promise => {
+ return await ctx.runQuery(
+ (internal as any).notifications.getVisitorRecipientsForSupportReply,
+ {
+ conversationId: args.conversationId,
+ channel: args.channel,
+ }
+ );
+ },
+ });
+
+/**
+ * Creates a test invitation in the specified workspace.
+ * This bypasses the email sending action for testing purposes.
+ */
+
+export const notificationTestHelpers: Record> = {
+ createTestPushCampaign,
+ sendTestPushCampaign,
+ getTestPendingPushCampaignRecipients,
+ createTestPushToken,
+ createTestVisitorPushToken,
+ upsertTestNotificationPreference,
+ upsertTestWorkspaceNotificationDefaults,
+ getTestMemberRecipientsForNewVisitorMessage,
+ getTestVisitorRecipientsForSupportReply,
+} as const;
diff --git a/packages/convex/convex/testing/helpers/series.ts b/packages/convex/convex/testing/helpers/series.ts
new file mode 100644
index 0000000..efce4ca
--- /dev/null
+++ b/packages/convex/convex/testing/helpers/series.ts
@@ -0,0 +1,178 @@
+import { internalMutation } from "../../_generated/server";
+import { v } from "convex/values";
+import {
+ runSeriesEvaluateEnrollmentForVisitor as runSeriesEvaluateEnrollmentForVisitorInternal,
+ runSeriesProcessWaitingProgress as runSeriesProcessWaitingProgressInternal,
+ runSeriesResumeWaitingForEvent as runSeriesResumeWaitingForEventInternal,
+} from "../../series/scheduler";
+
+const seriesEntryTriggerTestValidator = v.object({
+ source: v.union(
+ v.literal("event"),
+ v.literal("auto_event"),
+ v.literal("visitor_attribute_changed"),
+ v.literal("visitor_state_changed")
+ ),
+ eventName: v.optional(v.string()),
+ attributeKey: v.optional(v.string()),
+ fromValue: v.optional(v.string()),
+ toValue: v.optional(v.string()),
+});
+const seriesProgressStatusValidator = v.union(
+ v.literal("active"),
+ v.literal("waiting"),
+ v.literal("completed"),
+ v.literal("exited"),
+ v.literal("goal_reached"),
+ v.literal("failed")
+);
+
+const runSeriesEvaluateEnrollmentForVisitor: ReturnType =
+ internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ visitorId: v.id("visitors"),
+ triggerContext: seriesEntryTriggerTestValidator,
+ },
+ handler: async (ctx, args): Promise => {
+ return await runSeriesEvaluateEnrollmentForVisitorInternal(ctx, args);
+ },
+ });
+
+/**
+ * Runs event-based wait resumption for waiting series progress records.
+ */
+const runSeriesResumeWaitingForEvent: ReturnType = internalMutation(
+ {
+ args: {
+ workspaceId: v.id("workspaces"),
+ visitorId: v.id("visitors"),
+ eventName: v.string(),
+ },
+ handler: async (ctx, args): Promise => {
+ return await runSeriesResumeWaitingForEventInternal(ctx, args);
+ },
+ }
+);
+
+/**
+ * Runs wait backstop processing for active series.
+ */
+const runSeriesProcessWaitingProgress: ReturnType =
+ internalMutation({
+ args: {
+ seriesLimit: v.optional(v.number()),
+ waitingLimitPerSeries: v.optional(v.number()),
+ },
+ handler: async (ctx, args): Promise => {
+ return await runSeriesProcessWaitingProgressInternal(ctx, args);
+ },
+ });
+
+/**
+ * Returns the current progress record for a visitor in a series.
+ */
+const getSeriesProgressForVisitorSeries = internalMutation({
+ args: {
+ visitorId: v.id("visitors"),
+ seriesId: v.id("series"),
+ },
+ handler: async (ctx, args) => {
+ return await ctx.db
+ .query("seriesProgress")
+ .withIndex("by_visitor_series", (q) =>
+ q.eq("visitorId", args.visitorId).eq("seriesId", args.seriesId)
+ )
+ .first();
+ },
+});
+
+/**
+ * Patches series progress fields for deterministic runtime retry/backstop tests.
+ */
+const updateSeriesProgressForTest = internalMutation({
+ args: {
+ progressId: v.id("seriesProgress"),
+ status: v.optional(seriesProgressStatusValidator),
+ waitUntil: v.optional(v.number()),
+ waitEventName: v.optional(v.string()),
+ attemptCount: v.optional(v.number()),
+ lastExecutionError: v.optional(v.string()),
+ clearWaitUntil: v.optional(v.boolean()),
+ clearWaitEventName: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const progress = await ctx.db.get(args.progressId);
+ if (!progress) {
+ throw new Error("Progress not found");
+ }
+
+ await ctx.db.patch(args.progressId, {
+ ...(args.status !== undefined ? { status: args.status } : {}),
+ ...(args.waitUntil !== undefined ? { waitUntil: args.waitUntil } : {}),
+ ...(args.waitEventName !== undefined ? { waitEventName: args.waitEventName } : {}),
+ ...(args.attemptCount !== undefined ? { attemptCount: args.attemptCount } : {}),
+ ...(args.lastExecutionError !== undefined
+ ? { lastExecutionError: args.lastExecutionError }
+ : {}),
+ ...(args.clearWaitUntil ? { waitUntil: undefined } : {}),
+ ...(args.clearWaitEventName ? { waitEventName: undefined } : {}),
+ });
+
+ return await ctx.db.get(args.progressId);
+ },
+});
+
+/**
+ * Creates a test audit log entry directly (bypasses auth) for deterministic audit E2E flows.
+ */
+const createTestSeries = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.optional(v.string()),
+ status: v.optional(
+ v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))
+ ),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const randomSuffix = Math.random().toString(36).slice(2, 8);
+
+ const seriesId = await ctx.db.insert("series", {
+ workspaceId: args.workspaceId,
+ name: args.name ?? `Test Series ${randomSuffix}`,
+ status: args.status ?? "active",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ // Add a minimal entry block so evaluateEntry can traverse the series.
+ await ctx.db.insert("seriesBlocks", {
+ seriesId,
+ type: "wait",
+ position: { x: 0, y: 0 },
+ config: {
+ waitType: "duration",
+ waitDuration: 1,
+ waitUnit: "minutes",
+ },
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ return { seriesId };
+ },
+});
+
+/**
+ * Creates a test push campaign directly (bypasses auth on pushCampaigns.create).
+ */
+
+export const seriesTestHelpers: Record> = {
+ runSeriesEvaluateEnrollmentForVisitor,
+ runSeriesResumeWaitingForEvent,
+ runSeriesProcessWaitingProgress,
+ getSeriesProgressForVisitorSeries,
+ updateSeriesProgressForTest,
+ createTestSeries,
+} as const;
diff --git a/packages/convex/convex/testing/helpers/tickets.ts b/packages/convex/convex/testing/helpers/tickets.ts
new file mode 100644
index 0000000..86c12ee
--- /dev/null
+++ b/packages/convex/convex/testing/helpers/tickets.ts
@@ -0,0 +1,97 @@
+import { internalMutation } from "../../_generated/server";
+import { v } from "convex/values";
+
+const createTestTicket = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ visitorId: v.optional(v.id("visitors")),
+ conversationId: v.optional(v.id("conversations")),
+ subject: v.string(),
+ description: v.optional(v.string()),
+ status: v.optional(
+ v.union(
+ v.literal("submitted"),
+ v.literal("in_progress"),
+ v.literal("waiting_on_customer"),
+ v.literal("resolved")
+ )
+ ),
+ priority: v.optional(
+ v.union(v.literal("low"), v.literal("normal"), v.literal("high"), v.literal("urgent"))
+ ),
+ assigneeId: v.optional(v.id("users")),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+
+ const ticketId = await ctx.db.insert("tickets", {
+ workspaceId: args.workspaceId,
+ visitorId: args.visitorId,
+ conversationId: args.conversationId,
+ subject: args.subject,
+ description: args.description,
+ status: args.status || "submitted",
+ priority: args.priority || "normal",
+ assigneeId: args.assigneeId,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { ticketId };
+ },
+});
+
+/**
+ * Creates a test ticket form in the specified workspace.
+ */
+const createTestTicketForm = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ name: v.string(),
+ description: v.optional(v.string()),
+ isDefault: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+
+ const ticketFormId = await ctx.db.insert("ticketForms", {
+ workspaceId: args.workspaceId,
+ name: args.name,
+ description: args.description,
+ fields: [
+ {
+ id: "subject",
+ type: "text",
+ label: "Subject",
+ required: true,
+ },
+ {
+ id: "description",
+ type: "textarea",
+ label: "Description",
+ required: false,
+ },
+ ],
+ isDefault: args.isDefault || false,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ return { ticketFormId };
+ },
+});
+
+// ============================================================================
+// Auth-bypassing operation helpers for tests
+// These mirror auth-protected API functions but skip auth checks.
+// Only available via api.testing.helpers.* for test environments.
+// ============================================================================
+
+/**
+ * Creates a collection directly (bypasses auth on collections.create).
+ */
+
+export const ticketTestHelpers: Record> = {
+ createTestTicket,
+ createTestTicketForm,
+} as const;
diff --git a/packages/convex/convex/testing/helpers/workspace.ts b/packages/convex/convex/testing/helpers/workspace.ts
new file mode 100644
index 0000000..33722f7
--- /dev/null
+++ b/packages/convex/convex/testing/helpers/workspace.ts
@@ -0,0 +1,550 @@
+import { internalMutation } from "../../_generated/server";
+import { v } from "convex/values";
+
+const createTestWorkspace = internalMutation({
+ args: {
+ name: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const workspaceName = args.name || `test-workspace-${timestamp}-${randomSuffix}`;
+
+ const workspaceId = await ctx.db.insert("workspaces", {
+ name: workspaceName,
+ createdAt: timestamp,
+ helpCenterAccessPolicy: "public",
+ signupMode: "invite-only",
+ authMethods: ["password", "otp"],
+ });
+
+ // Create a default admin user for the workspace
+ const email = `admin-${randomSuffix}@test.opencom.dev`;
+ const userId = await ctx.db.insert("users", {
+ email,
+ name: "Test Admin",
+ workspaceId,
+ role: "admin",
+ createdAt: timestamp,
+ });
+
+ await ctx.db.insert("workspaceMembers", {
+ userId,
+ workspaceId,
+ role: "admin",
+ createdAt: timestamp,
+ });
+
+ return { workspaceId, userId, name: workspaceName };
+ },
+});
+
+/**
+ * Updates workspace help-center access policy directly (bypasses auth).
+ */
+const updateTestHelpCenterAccessPolicy = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ policy: v.union(v.literal("public"), v.literal("restricted")),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.workspaceId, {
+ helpCenterAccessPolicy: args.policy,
+ });
+ },
+});
+
+/**
+ * Runs series enrollment evaluation for a visitor with explicit trigger context.
+ */
+const createTestAuditLog = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ action: v.string(),
+ actorType: v.optional(v.union(v.literal("user"), v.literal("system"), v.literal("api"))),
+ actorId: v.optional(v.id("users")),
+ resourceType: v.optional(v.string()),
+ resourceId: v.optional(v.string()),
+ metadata: v.optional(v.any()),
+ timestamp: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ const now = args.timestamp ?? Date.now();
+ const logId = await ctx.db.insert("auditLogs", {
+ workspaceId: args.workspaceId,
+ actorId: args.actorId,
+ actorType: args.actorType ?? "system",
+ action: args.action,
+ resourceType: args.resourceType ?? "test",
+ resourceId: args.resourceId,
+ metadata: args.metadata,
+ timestamp: now,
+ });
+
+ return { logId, timestamp: now };
+ },
+});
+
+/**
+ * Creates a test user in the specified workspace.
+ */
+const createTestUser = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ email: v.optional(v.string()),
+ name: v.optional(v.string()),
+ role: v.optional(v.union(v.literal("admin"), v.literal("agent"))),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
+ const email = args.email || `test-${randomSuffix}@test.opencom.dev`;
+ const name = args.name || `Test User ${randomSuffix}`;
+ const role = args.role || "agent";
+
+ const userId = await ctx.db.insert("users", {
+ email,
+ name,
+ workspaceId: args.workspaceId,
+ role,
+ createdAt: timestamp,
+ });
+
+ await ctx.db.insert("workspaceMembers", {
+ userId,
+ workspaceId: args.workspaceId,
+ role,
+ createdAt: timestamp,
+ });
+
+ return { userId, email, name };
+ },
+});
+
+/**
+ * Creates a test session token for a visitor (used by tests that call visitor-facing endpoints).
+ */
+const createTestSessionToken = internalMutation({
+ args: {
+ visitorId: v.id("visitors"),
+ workspaceId: v.id("workspaces"),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const randomHex = Array.from({ length: 32 }, () =>
+ Math.floor(Math.random() * 256)
+ .toString(16)
+ .padStart(2, "0")
+ ).join("");
+ const token = `wst_test_${randomHex}`;
+
+ await ctx.db.insert("widgetSessions", {
+ token,
+ visitorId: args.visitorId,
+ workspaceId: args.workspaceId,
+ identityVerified: false,
+ expiresAt: now + 24 * 60 * 60 * 1000, // 24 hours
+ createdAt: now,
+ });
+
+ return { sessionToken: token };
+ },
+});
+
+/**
+ * Creates a test visitor in the specified workspace.
+ */
+const createTestInvitation = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ email: v.string(),
+ role: v.union(v.literal("admin"), v.literal("agent")),
+ invitedBy: v.id("users"),
+ },
+ handler: async (ctx, args) => {
+ const timestamp = Date.now();
+ const normalizedEmail = args.email.toLowerCase();
+
+ const invitationId = await ctx.db.insert("workspaceInvitations", {
+ workspaceId: args.workspaceId,
+ email: normalizedEmail,
+ role: args.role,
+ invitedBy: args.invitedBy,
+ status: "pending",
+ createdAt: timestamp,
+ });
+
+ return { invitationId };
+ },
+});
+
+/**
+ * Updates workspace settings for testing (e.g. identity verification).
+ */
+const updateWorkspaceSettings = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ identityVerificationEnabled: v.optional(v.boolean()),
+ identityVerificationMode: v.optional(v.union(v.literal("optional"), v.literal("required"))),
+ identitySecret: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
+ const updates: Record = {};
+ if (args.identityVerificationEnabled !== undefined) {
+ updates.identityVerificationEnabled = args.identityVerificationEnabled;
+ }
+ if (args.identityVerificationMode !== undefined) {
+ updates.identityVerificationMode = args.identityVerificationMode;
+ }
+ if (args.identitySecret !== undefined) {
+ updates.identitySecret = args.identitySecret;
+ }
+ await ctx.db.patch(args.workspaceId, updates);
+ },
+});
+
+/**
+ * Upserts automation settings for deterministic tests.
+ */
+const upsertTestAutomationSettings = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ suggestArticlesEnabled: v.optional(v.boolean()),
+ showReplyTimeEnabled: v.optional(v.boolean()),
+ collectEmailEnabled: v.optional(v.boolean()),
+ askForRatingEnabled: v.optional(v.boolean()),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const existing = await ctx.db
+ .query("automationSettings")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .first();
+
+ if (existing) {
+ await ctx.db.patch(existing._id, {
+ ...(args.suggestArticlesEnabled !== undefined && {
+ suggestArticlesEnabled: args.suggestArticlesEnabled,
+ }),
+ ...(args.showReplyTimeEnabled !== undefined && {
+ showReplyTimeEnabled: args.showReplyTimeEnabled,
+ }),
+ ...(args.collectEmailEnabled !== undefined && {
+ collectEmailEnabled: args.collectEmailEnabled,
+ }),
+ ...(args.askForRatingEnabled !== undefined && {
+ askForRatingEnabled: args.askForRatingEnabled,
+ }),
+ updatedAt: now,
+ });
+ return existing._id;
+ }
+
+ return await ctx.db.insert("automationSettings", {
+ workspaceId: args.workspaceId,
+ suggestArticlesEnabled: args.suggestArticlesEnabled ?? false,
+ showReplyTimeEnabled: args.showReplyTimeEnabled ?? false,
+ collectEmailEnabled: args.collectEmailEnabled ?? true,
+ askForRatingEnabled: args.askForRatingEnabled ?? false,
+ createdAt: now,
+ updatedAt: now,
+ });
+ },
+});
+
+/**
+ * Cleans up all test data for a workspace.
+ * Call this after each test suite to remove test data.
+ */
+const getTestWorkspaceFull = internalMutation({
+ args: { id: v.id("workspaces") },
+ handler: async (ctx, args) => {
+ return await ctx.db.get(args.id);
+ },
+});
+
+/**
+ * Updates workspace allowed origins directly (bypasses auth).
+ */
+const updateTestAllowedOrigins = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ allowedOrigins: v.array(v.string()),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.workspaceId, {
+ allowedOrigins: args.allowedOrigins,
+ });
+ },
+});
+
+/**
+ * Updates workspace signup settings directly (bypasses auth).
+ */
+const updateTestSignupSettings = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ signupMode: v.optional(
+ v.union(v.literal("invite-only"), v.literal("domain-allowlist"), v.literal("open"))
+ ),
+ allowedDomains: v.optional(v.array(v.string())),
+ authMethods: v.optional(v.array(v.union(v.literal("password"), v.literal("otp")))),
+ },
+ handler: async (ctx, args) => {
+ const updates: Record = {};
+ if (args.signupMode !== undefined) updates.signupMode = args.signupMode;
+ if (args.allowedDomains !== undefined) updates.allowedDomains = args.allowedDomains;
+ if (args.authMethods !== undefined) updates.authMethods = args.authMethods;
+ // Clear domains when switching to invite-only
+ if (args.signupMode === "invite-only" && args.allowedDomains === undefined) {
+ updates.allowedDomains = [];
+ }
+ await ctx.db.patch(args.workspaceId, updates);
+ },
+});
+
+/**
+ * Gets AI agent settings directly (bypasses auth).
+ */
+const addTestWorkspaceMember = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ userId: v.id("users"),
+ role: v.union(v.literal("owner"), v.literal("admin"), v.literal("agent"), v.literal("viewer")),
+ },
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const memberId = await ctx.db.insert("workspaceMembers", {
+ workspaceId: args.workspaceId,
+ userId: args.userId,
+ role: args.role,
+ createdAt: now,
+ });
+ return memberId;
+ },
+});
+
+/**
+ * Lists workspace members directly (bypasses auth).
+ */
+const listTestWorkspaceMembers = internalMutation({
+ args: { workspaceId: v.id("workspaces") },
+ handler: async (ctx, args) => {
+ const members = await ctx.db
+ .query("workspaceMembers")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .collect();
+
+ return await Promise.all(
+ members.map(async (m) => {
+ const user = await ctx.db.get(m.userId);
+ return { ...m, user };
+ })
+ );
+ },
+});
+
+/**
+ * Updates workspace-member custom permissions directly (bypasses auth).
+ * Passing an empty array clears custom permissions and falls back to role defaults.
+ */
+const updateTestMemberPermissions = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ userEmail: v.string(),
+ permissions: v.optional(v.array(v.string())),
+ },
+ handler: async (ctx, args) => {
+ const user = await ctx.db
+ .query("users")
+ .withIndex("by_email", (q) => q.eq("email", args.userEmail))
+ .first();
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ const membership = await ctx.db
+ .query("workspaceMembers")
+ .withIndex("by_user_workspace", (q) =>
+ q.eq("userId", user._id).eq("workspaceId", args.workspaceId)
+ )
+ .first();
+ if (!membership) {
+ throw new Error("Workspace member not found");
+ }
+
+ const normalized =
+ args.permissions && args.permissions.length > 0 ? args.permissions : undefined;
+ await ctx.db.patch(membership._id, { permissions: normalized });
+
+ return { membershipId: membership._id };
+ },
+});
+
+/**
+ * Updates a workspace member role directly (bypasses auth).
+ * Includes last-admin validation to match production behavior.
+ */
+const updateTestMemberRole = internalMutation({
+ args: {
+ membershipId: v.id("workspaceMembers"),
+ role: v.union(v.literal("owner"), v.literal("admin"), v.literal("agent"), v.literal("viewer")),
+ },
+ handler: async (ctx, args) => {
+ const member = await ctx.db.get(args.membershipId);
+ if (!member) throw new Error("Member not found");
+
+ // If demoting from admin, check there's at least one other admin
+ if (member.role === "admin" && args.role !== "admin") {
+ const admins = await ctx.db
+ .query("workspaceMembers")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", member.workspaceId))
+ .filter((q) => q.eq(q.field("role"), "admin"))
+ .collect();
+ if (admins.length <= 1) {
+ throw new Error("Cannot demote: workspace must have at least one admin");
+ }
+ }
+
+ await ctx.db.patch(args.membershipId, { role: args.role });
+ },
+});
+
+/**
+ * Removes a workspace member directly (bypasses auth).
+ * Includes last-admin validation to match production behavior.
+ */
+const removeTestMember = internalMutation({
+ args: { membershipId: v.id("workspaceMembers") },
+ handler: async (ctx, args) => {
+ const member = await ctx.db.get(args.membershipId);
+ if (!member) throw new Error("Member not found");
+
+ // If removing an admin, check there's at least one other admin
+ if (member.role === "admin") {
+ const admins = await ctx.db
+ .query("workspaceMembers")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", member.workspaceId))
+ .filter((q) => q.eq(q.field("role"), "admin"))
+ .collect();
+ if (admins.length <= 1) {
+ throw new Error("Cannot remove: workspace must have at least one admin");
+ }
+ }
+
+ await ctx.db.delete(args.membershipId);
+ },
+});
+
+/**
+ * Removes a workspace member directly (bypasses auth). Alias without validation.
+ */
+const removeTestWorkspaceMember = internalMutation({
+ args: { membershipId: v.id("workspaceMembers") },
+ handler: async (ctx, args) => {
+ await ctx.db.delete(args.membershipId);
+ },
+});
+
+/**
+ * Cancels a test invitation directly (bypasses auth).
+ */
+const cancelTestInvitation = internalMutation({
+ args: { invitationId: v.id("workspaceInvitations") },
+ handler: async (ctx, args) => {
+ await ctx.db.delete(args.invitationId);
+ },
+});
+
+/**
+ * Accepts a test invitation directly (bypasses auth).
+ */
+const acceptTestInvitation = internalMutation({
+ args: { invitationId: v.id("workspaceInvitations") },
+ handler: async (ctx, args) => {
+ const invitation = await ctx.db.get(args.invitationId);
+ if (!invitation) throw new Error("Invitation not found");
+ await ctx.db.patch(args.invitationId, { status: "accepted" });
+ },
+});
+
+/**
+ * Lists pending invitations for a workspace directly (bypasses auth).
+ */
+const listTestPendingInvitations = internalMutation({
+ args: { workspaceId: v.id("workspaces") },
+ handler: async (ctx, args) => {
+ return await ctx.db
+ .query("workspaceInvitations")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .filter((q) => q.eq(q.field("status"), "pending"))
+ .collect();
+ },
+});
+
+/**
+ * Simulates an email webhook event for testing delivery status updates.
+ */
+const updateWorkspaceOrigins = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ allowedOrigins: v.array(v.string()),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.workspaceId, { allowedOrigins: args.allowedOrigins });
+ },
+});
+
+/**
+ * Creates a conversation for a visitor directly (bypasses auth, like createForVisitor).
+ */
+const lookupUserByEmail = internalMutation({
+ args: { email: v.string() },
+ handler: async (ctx, args) => {
+ return await ctx.db
+ .query("users")
+ .withIndex("by_email", (q) => q.eq("email", args.email.toLowerCase()))
+ .first();
+ },
+});
+
+/**
+ * Looks up pending workspace invitations by email using the by_email index.
+ * Mirrors the typed query pattern used in authConvex createOrUpdateUser.
+ */
+const lookupPendingInvitationsByEmail = internalMutation({
+ args: { email: v.string() },
+ handler: async (ctx, args) => {
+ return await ctx.db
+ .query("workspaceInvitations")
+ .withIndex("by_email", (q) => q.eq("email", args.email.toLowerCase()))
+ .filter((q) => q.eq(q.field("status"), "pending"))
+ .collect();
+ },
+});
+
+export const workspaceTestHelpers: Record> = {
+ createTestWorkspace,
+ updateTestHelpCenterAccessPolicy,
+ createTestAuditLog,
+ createTestUser,
+ createTestSessionToken,
+ createTestInvitation,
+ updateWorkspaceSettings,
+ upsertTestAutomationSettings,
+ getTestWorkspaceFull,
+ updateTestAllowedOrigins,
+ updateTestSignupSettings,
+ addTestWorkspaceMember,
+ listTestWorkspaceMembers,
+ updateTestMemberPermissions,
+ updateTestMemberRole,
+ removeTestMember,
+ removeTestWorkspaceMember,
+ cancelTestInvitation,
+ acceptTestInvitation,
+ listTestPendingInvitations,
+ updateWorkspaceOrigins,
+ lookupUserByEmail,
+ lookupPendingInvitationsByEmail,
+} as const;
diff --git a/packages/web-shared/README.md b/packages/web-shared/README.md
index 1ce72e2..0fec325 100644
--- a/packages/web-shared/README.md
+++ b/packages/web-shared/README.md
@@ -40,3 +40,19 @@ Shared browser-focused utilities for `apps/web` and `apps/widget`.
- missing-field behavior for legacy payloads
- Do not re-implement snapshot/increase/suppression loops in app-level cue files.
- Add shared invariant tests in this package before changing cue logic.
+
+## Error Feedback Core Ownership
+
+- Source of truth for unknown-error normalization lives in `src/errorFeedback.ts`.
+- Shared behavior covered here:
+ - safe extraction of human-readable error messages from unknown thrown values
+ - fallback messaging when no trusted detail is available
+ - optional actionable next-step guidance (`nextAction`)
+
+## Error Feedback Extension Rules
+
+- Use `normalizeUnknownError` in web/widget catch paths before rendering UI error feedback.
+- Keep message extraction and fallback rules centralized in this package.
+- Surface-specific rendering can vary (banner, inline callout), but should consume the shared
+ `ErrorFeedbackMessage` contract.
+- Do not introduce new raw `alert(...)` calls in covered user-facing settings/ticket flows.
diff --git a/packages/web-shared/src/errorFeedback.test.ts b/packages/web-shared/src/errorFeedback.test.ts
new file mode 100644
index 0000000..a9f2400
--- /dev/null
+++ b/packages/web-shared/src/errorFeedback.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import { normalizeUnknownError } from "./errorFeedback";
+
+describe("normalizeUnknownError", () => {
+ it("uses fallback when unknown values do not contain a usable message", () => {
+ const normalized = normalizeUnknownError(undefined, {
+ fallbackMessage: "Failed to save settings",
+ nextAction: "Review your inputs and try again.",
+ });
+
+ expect(normalized).toEqual({
+ message: "Failed to save settings",
+ nextAction: "Review your inputs and try again.",
+ });
+ });
+
+ it("prefers explicit error messages from Error instances", () => {
+ const normalized = normalizeUnknownError(new Error("Request timed out"), {
+ fallbackMessage: "Failed to save settings",
+ });
+
+ expect(normalized).toEqual({
+ message: "Request timed out",
+ nextAction: undefined,
+ });
+ });
+
+ it("extracts message from object-like thrown values", () => {
+ const normalized = normalizeUnknownError(
+ { message: "Upload failed" },
+ {
+ fallbackMessage: "Failed to upload logo",
+ nextAction: "Try a smaller image and retry.",
+ }
+ );
+
+ expect(normalized).toEqual({
+ message: "Upload failed",
+ nextAction: "Try a smaller image and retry.",
+ });
+ });
+});
diff --git a/packages/web-shared/src/errorFeedback.ts b/packages/web-shared/src/errorFeedback.ts
new file mode 100644
index 0000000..e9c1f6a
--- /dev/null
+++ b/packages/web-shared/src/errorFeedback.ts
@@ -0,0 +1,47 @@
+export interface ErrorFeedbackMessage {
+ message: string;
+ nextAction?: string;
+}
+
+export interface NormalizeUnknownErrorOptions {
+ fallbackMessage: string;
+ nextAction?: string;
+}
+
+function sanitizeMessage(message: string | null | undefined): string | null {
+ if (!message) {
+ return null;
+ }
+ const trimmed = message.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+function readObjectMessage(error: unknown): string | null {
+ if (!error || typeof error !== "object") {
+ return null;
+ }
+ const message = (error as { message?: unknown }).message;
+ return typeof message === "string" ? sanitizeMessage(message) : null;
+}
+
+function readUnknownErrorMessage(error: unknown): string | null {
+ if (error instanceof Error) {
+ return sanitizeMessage(error.message);
+ }
+ if (typeof error === "string") {
+ return sanitizeMessage(error);
+ }
+ return readObjectMessage(error);
+}
+
+export function normalizeUnknownError(
+ error: unknown,
+ options: NormalizeUnknownErrorOptions
+): ErrorFeedbackMessage {
+ const message = readUnknownErrorMessage(error) ?? sanitizeMessage(options.fallbackMessage) ?? "Unexpected error";
+ const nextAction = sanitizeMessage(options.nextAction) ?? undefined;
+ return {
+ message,
+ nextAction,
+ };
+}
diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts
index 592cc1f..4ca003e 100644
--- a/packages/web-shared/src/index.ts
+++ b/packages/web-shared/src/index.ts
@@ -13,3 +13,8 @@ export {
type CuePreferenceAdapter,
type CuePreferences,
} from "./notificationCues";
+export {
+ normalizeUnknownError,
+ type ErrorFeedbackMessage,
+ type NormalizeUnknownErrorOptions,
+} from "./errorFeedback";
From 6dcf67b756b96be08c765649624fd9b876c61fe5 Mon Sep 17 00:00:00 2001
From: Jack D
Date: Thu, 5 Mar 2026 17:03:05 +0000
Subject: [PATCH 11/91] archive proposals
---
.../tasks.md | 19 -------
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../ai-help-center-linked-sources/spec.md | 0
.../tasks.md | 19 +++++++
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../spec.md | 0
.../tasks.md | 20 ++++++++
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../workspace-lint-and-quality-gates/spec.md | 0
.../tasks.md | 19 +++++++
.../tasks.md | 20 --------
.../tasks.md | 19 -------
.../ai-help-center-linked-sources/spec.md | 33 ++++++++++++
.../spec.md | 51 +++++++++++++++++++
.../workspace-lint-and-quality-gates/spec.md | 34 +++++++++++++
21 files changed, 176 insertions(+), 58 deletions(-)
delete mode 100644 openspec/changes/add-help-center-links-in-ai-responses/tasks.md
rename openspec/changes/{add-help-center-links-in-ai-responses => archive/2026-03-05-add-help-center-links-in-ai-responses}/.openspec.yaml (100%)
rename openspec/changes/{add-help-center-links-in-ai-responses => archive/2026-03-05-add-help-center-links-in-ai-responses}/design.md (100%)
rename openspec/changes/{add-help-center-links-in-ai-responses => archive/2026-03-05-add-help-center-links-in-ai-responses}/proposal.md (100%)
rename openspec/changes/{add-help-center-links-in-ai-responses => archive/2026-03-05-add-help-center-links-in-ai-responses}/specs/ai-help-center-linked-sources/spec.md (100%)
create mode 100644 openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/tasks.md
rename openspec/changes/{modularize-help-center-import-export-pipeline => archive/2026-03-05-modularize-help-center-import-export-pipeline}/.openspec.yaml (100%)
rename openspec/changes/{modularize-help-center-import-export-pipeline => archive/2026-03-05-modularize-help-center-import-export-pipeline}/design.md (100%)
rename openspec/changes/{modularize-help-center-import-export-pipeline => archive/2026-03-05-modularize-help-center-import-export-pipeline}/proposal.md (100%)
rename openspec/changes/{modularize-help-center-import-export-pipeline => archive/2026-03-05-modularize-help-center-import-export-pipeline}/specs/help-center-import-export-modularity/spec.md (100%)
create mode 100644 openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/tasks.md
rename openspec/changes/{normalize-lint-tooling-and-quality-gates => archive/2026-03-05-normalize-lint-tooling-and-quality-gates}/.openspec.yaml (100%)
rename openspec/changes/{normalize-lint-tooling-and-quality-gates => archive/2026-03-05-normalize-lint-tooling-and-quality-gates}/design.md (100%)
rename openspec/changes/{normalize-lint-tooling-and-quality-gates => archive/2026-03-05-normalize-lint-tooling-and-quality-gates}/proposal.md (100%)
rename openspec/changes/{normalize-lint-tooling-and-quality-gates => archive/2026-03-05-normalize-lint-tooling-and-quality-gates}/specs/workspace-lint-and-quality-gates/spec.md (100%)
create mode 100644 openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/tasks.md
delete mode 100644 openspec/changes/modularize-help-center-import-export-pipeline/tasks.md
delete mode 100644 openspec/changes/normalize-lint-tooling-and-quality-gates/tasks.md
create mode 100644 openspec/specs/ai-help-center-linked-sources/spec.md
create mode 100644 openspec/specs/help-center-import-export-modularity/spec.md
create mode 100644 openspec/specs/workspace-lint-and-quality-gates/spec.md
diff --git a/openspec/changes/add-help-center-links-in-ai-responses/tasks.md b/openspec/changes/add-help-center-links-in-ai-responses/tasks.md
deleted file mode 100644
index d32662c..0000000
--- a/openspec/changes/add-help-center-links-in-ai-responses/tasks.md
+++ /dev/null
@@ -1,19 +0,0 @@
-## 1. Source Metadata Extension
-
-- [ ] 1.1 Extend AI response source payload shape with linkable article metadata fields.
-- [ ] 1.2 Update source serialization/query code to return enriched metadata.
-
-## 2. UI Integration
-
-- [ ] 2.1 Render clickable article source links in widget AI message UI.
-- [ ] 2.2 Update AI review/chat source rendering to use linkable metadata where available.
-
-## 3. Verification
-
-- [ ] 3.1 Add tests for article link rendering and navigation behavior.
-- [ ] 3.2 Add tests for non-article source fallback handling.
-
-## 4. Cleanup
-
-- [ ] 4.1 Remove ad-hoc source-title-only rendering paths where replaced.
-- [ ] 4.2 Document source metadata contract for AI and UI contributors.
diff --git a/openspec/changes/add-help-center-links-in-ai-responses/.openspec.yaml b/openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/.openspec.yaml
similarity index 100%
rename from openspec/changes/add-help-center-links-in-ai-responses/.openspec.yaml
rename to openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/.openspec.yaml
diff --git a/openspec/changes/add-help-center-links-in-ai-responses/design.md b/openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/design.md
similarity index 100%
rename from openspec/changes/add-help-center-links-in-ai-responses/design.md
rename to openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/design.md
diff --git a/openspec/changes/add-help-center-links-in-ai-responses/proposal.md b/openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/proposal.md
similarity index 100%
rename from openspec/changes/add-help-center-links-in-ai-responses/proposal.md
rename to openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/proposal.md
diff --git a/openspec/changes/add-help-center-links-in-ai-responses/specs/ai-help-center-linked-sources/spec.md b/openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/specs/ai-help-center-linked-sources/spec.md
similarity index 100%
rename from openspec/changes/add-help-center-links-in-ai-responses/specs/ai-help-center-linked-sources/spec.md
rename to openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/specs/ai-help-center-linked-sources/spec.md
diff --git a/openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/tasks.md b/openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/tasks.md
new file mode 100644
index 0000000..97f8037
--- /dev/null
+++ b/openspec/changes/archive/2026-03-05-add-help-center-links-in-ai-responses/tasks.md
@@ -0,0 +1,19 @@
+## 1. Source Metadata Extension
+
+- [x] 1.1 Extend AI response source payload shape with linkable article metadata fields.
+- [x] 1.2 Update source serialization/query code to return enriched metadata.
+
+## 2. UI Integration
+
+- [x] 2.1 Render clickable article source links in widget AI message UI.
+- [x] 2.2 Update AI review/chat source rendering to use linkable metadata where available.
+
+## 3. Verification
+
+- [x] 3.1 Add tests for article link rendering and navigation behavior.
+- [x] 3.2 Add tests for non-article source fallback handling.
+
+## 4. Cleanup
+
+- [x] 4.1 Remove ad-hoc source-title-only rendering paths where replaced.
+- [x] 4.2 Document source metadata contract for AI and UI contributors.
diff --git a/openspec/changes/modularize-help-center-import-export-pipeline/.openspec.yaml b/openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/.openspec.yaml
similarity index 100%
rename from openspec/changes/modularize-help-center-import-export-pipeline/.openspec.yaml
rename to openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/.openspec.yaml
diff --git a/openspec/changes/modularize-help-center-import-export-pipeline/design.md b/openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/design.md
similarity index 100%
rename from openspec/changes/modularize-help-center-import-export-pipeline/design.md
rename to openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/design.md
diff --git a/openspec/changes/modularize-help-center-import-export-pipeline/proposal.md b/openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/proposal.md
similarity index 100%
rename from openspec/changes/modularize-help-center-import-export-pipeline/proposal.md
rename to openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/proposal.md
diff --git a/openspec/changes/modularize-help-center-import-export-pipeline/specs/help-center-import-export-modularity/spec.md b/openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/specs/help-center-import-export-modularity/spec.md
similarity index 100%
rename from openspec/changes/modularize-help-center-import-export-pipeline/specs/help-center-import-export-modularity/spec.md
rename to openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/specs/help-center-import-export-modularity/spec.md
diff --git a/openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/tasks.md b/openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/tasks.md
new file mode 100644
index 0000000..84c3146
--- /dev/null
+++ b/openspec/changes/archive/2026-03-05-modularize-help-center-import-export-pipeline/tasks.md
@@ -0,0 +1,20 @@
+## 1. Stage Boundary Definition
+
+- [x] 1.1 Define module boundaries for parsing, rewrite/normalization, sync apply, and export build stages.
+- [x] 1.2 Move shared path/reference helpers into a common internal utility layer.
+
+## 2. Extraction
+
+- [x] 2.1 Extract markdown/frontmatter parsing behavior into dedicated modules with unchanged outputs.
+- [x] 2.2 Extract asset reference rewrite and unresolved-reference reporting into dedicated modules.
+- [x] 2.3 Extract import apply orchestration and export packaging orchestration into separate modules.
+
+## 3. Parity Coverage
+
+- [x] 3.1 Add fixture-driven tests for import rewrite parity and unresolved reference reporting.
+- [x] 3.2 Add tests for export portability and re-import fidelity.
+
+## 4. Cleanup
+
+- [x] 4.1 Remove obsolete monolithic helper branches from `helpCenterImports.ts`.
+- [x] 4.2 Document module ownership and expected stage contracts.
diff --git a/openspec/changes/normalize-lint-tooling-and-quality-gates/.openspec.yaml b/openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/.openspec.yaml
similarity index 100%
rename from openspec/changes/normalize-lint-tooling-and-quality-gates/.openspec.yaml
rename to openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/.openspec.yaml
diff --git a/openspec/changes/normalize-lint-tooling-and-quality-gates/design.md b/openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/design.md
similarity index 100%
rename from openspec/changes/normalize-lint-tooling-and-quality-gates/design.md
rename to openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/design.md
diff --git a/openspec/changes/normalize-lint-tooling-and-quality-gates/proposal.md b/openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/proposal.md
similarity index 100%
rename from openspec/changes/normalize-lint-tooling-and-quality-gates/proposal.md
rename to openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/proposal.md
diff --git a/openspec/changes/normalize-lint-tooling-and-quality-gates/specs/workspace-lint-and-quality-gates/spec.md b/openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/specs/workspace-lint-and-quality-gates/spec.md
similarity index 100%
rename from openspec/changes/normalize-lint-tooling-and-quality-gates/specs/workspace-lint-and-quality-gates/spec.md
rename to openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/specs/workspace-lint-and-quality-gates/spec.md
diff --git a/openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/tasks.md b/openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/tasks.md
new file mode 100644
index 0000000..9f0f6fa
--- /dev/null
+++ b/openspec/changes/archive/2026-03-05-normalize-lint-tooling-and-quality-gates/tasks.md
@@ -0,0 +1,19 @@
+## 1. Script Contract Normalization
+
+- [x] 1.1 Add or align `lint`, `typecheck`, and `test` scripts for covered packages.
+- [x] 1.2 Replace deprecated web lint command with explicit ESLint CLI script.
+
+## 2. Root And CI Alignment
+
+- [x] 2.1 Fix broken root quality script definitions and align aliases to standardized scripts.
+- [x] 2.2 Update CI workflow steps to use standardized lint/type/test entry points.
+
+## 3. Verification
+
+- [x] 3.1 Run targeted script execution checks for web and convex packages.
+- [x] 3.2 Run root quality commands impacted by script normalization.
+
+## 4. Documentation
+
+- [x] 4.1 Update docs with canonical quality command contract.
+- [x] 4.2 Note migration guidance for contributors relying on previous command variants.
diff --git a/openspec/changes/modularize-help-center-import-export-pipeline/tasks.md b/openspec/changes/modularize-help-center-import-export-pipeline/tasks.md
deleted file mode 100644
index f387327..0000000
--- a/openspec/changes/modularize-help-center-import-export-pipeline/tasks.md
+++ /dev/null
@@ -1,20 +0,0 @@
-## 1. Stage Boundary Definition
-
-- [ ] 1.1 Define module boundaries for parsing, rewrite/normalization, sync apply, and export build stages.
-- [ ] 1.2 Move shared path/reference helpers into a common internal utility layer.
-
-## 2. Extraction
-
-- [ ] 2.1 Extract markdown/frontmatter parsing behavior into dedicated modules with unchanged outputs.
-- [ ] 2.2 Extract asset reference rewrite and unresolved-reference reporting into dedicated modules.
-- [ ] 2.3 Extract import apply orchestration and export packaging orchestration into separate modules.
-
-## 3. Parity Coverage
-
-- [ ] 3.1 Add fixture-driven tests for import rewrite parity and unresolved reference reporting.
-- [ ] 3.2 Add tests for export portability and re-import fidelity.
-
-## 4. Cleanup
-
-- [ ] 4.1 Remove obsolete monolithic helper branches from `helpCenterImports.ts`.
-- [ ] 4.2 Document module ownership and expected stage contracts.
diff --git a/openspec/changes/normalize-lint-tooling-and-quality-gates/tasks.md b/openspec/changes/normalize-lint-tooling-and-quality-gates/tasks.md
deleted file mode 100644
index 20484fd..0000000
--- a/openspec/changes/normalize-lint-tooling-and-quality-gates/tasks.md
+++ /dev/null
@@ -1,19 +0,0 @@
-## 1. Script Contract Normalization
-
-- [ ] 1.1 Add or align `lint`, `typecheck`, and `test` scripts for covered packages.
-- [ ] 1.2 Replace deprecated web lint command with explicit ESLint CLI script.
-
-## 2. Root And CI Alignment
-
-- [ ] 2.1 Fix broken root quality script definitions and align aliases to standardized scripts.
-- [ ] 2.2 Update CI workflow steps to use standardized lint/type/test entry points.
-
-## 3. Verification
-
-- [ ] 3.1 Run targeted script execution checks for web and convex packages.
-- [ ] 3.2 Run root quality commands impacted by script normalization.
-
-## 4. Documentation
-
-- [ ] 4.1 Update docs with canonical quality command contract.
-- [ ] 4.2 Note migration guidance for contributors relying on previous command variants.
diff --git a/openspec/specs/ai-help-center-linked-sources/spec.md b/openspec/specs/ai-help-center-linked-sources/spec.md
new file mode 100644
index 0000000..5448a92
--- /dev/null
+++ b/openspec/specs/ai-help-center-linked-sources/spec.md
@@ -0,0 +1,33 @@
+# ai-help-center-linked-sources Specification
+
+## Purpose
+TBD - created by archiving change add-help-center-links-in-ai-responses. Update Purpose after archive.
+
+## Requirements
+
+### Requirement: AI response source records MUST include linkable metadata for article sources
+
+AI response source entries associated with Help Center articles SHALL include metadata required for reliable article navigation.
+
+#### Scenario: AI response references published article
+
+- **WHEN** AI response includes a Help Center article source
+- **THEN** source metadata SHALL include linkable article identity fields for UI navigation
+
+### Requirement: Widget AI messages MUST render article sources as clickable links
+
+Widget conversation UI SHALL render article-backed AI sources as clickable elements that open the corresponding article context.
+
+#### Scenario: Visitor taps article source in AI message
+
+- **WHEN** widget displays an AI message with article sources
+- **THEN** selecting a source SHALL open the referenced article view
+
+### Requirement: Non-article sources MUST degrade gracefully
+
+Sources without linkable article targets SHALL remain visible as attribution text and MUST NOT render broken links.
+
+#### Scenario: AI response includes snippet source
+
+- **WHEN** AI response includes a non-article source
+- **THEN** UI SHALL display source attribution without invalid navigation affordances
diff --git a/openspec/specs/help-center-import-export-modularity/spec.md b/openspec/specs/help-center-import-export-modularity/spec.md
new file mode 100644
index 0000000..3190fe6
--- /dev/null
+++ b/openspec/specs/help-center-import-export-modularity/spec.md
@@ -0,0 +1,51 @@
+# help-center-import-export-modularity Specification
+
+## Purpose
+TBD - created by archiving change modularize-help-center-import-export-pipeline. Update Purpose after archive.
+
+## Requirements
+
+### Requirement: Help Center markdown pipeline MUST isolate stage responsibilities
+
+The markdown import/export pipeline SHALL separate parsing, reference normalization/rewrite, import apply, and export packaging into explicit modules with stable contracts.
+
+#### Scenario: Parser behavior changes
+
+- **WHEN** frontmatter parsing rules are updated
+- **THEN** changes SHALL be made in parsing modules
+- **AND** export packaging modules SHALL not require edits
+
+#### Scenario: Export packaging behavior changes
+
+- **WHEN** archive file path policy is updated
+- **THEN** changes SHALL be made in export packaging modules
+- **AND** markdown parsing modules SHALL remain unaffected
+
+### Requirement: Import and export MUST share canonical reference normalization rules
+
+Import rewrite and export rewrite behavior MUST use shared deterministic normalization for markdown paths and internal asset references.
+
+#### Scenario: Imported markdown image reference is rewritten
+
+- **WHEN** sync apply rewrites a local markdown image reference to internal asset format
+- **THEN** rewrite behavior SHALL use canonical normalization utilities shared with export
+
+#### Scenario: Export rewrites internal references to relative paths
+
+- **WHEN** markdown containing internal asset references is exported
+- **THEN** exported paths SHALL be derived with the same canonical normalization rules used by import
+
+### Requirement: Refactor MUST preserve sync/export parity outcomes
+
+The modularized implementation MUST preserve unresolved-reference reporting and portable export outcomes currently produced by the pipeline.
+
+#### Scenario: Import includes missing referenced files
+
+- **WHEN** markdown references image paths that cannot be resolved
+- **THEN** sync responses SHALL continue reporting unresolved references with file context
+- **AND** the operation SHALL not crash
+
+#### Scenario: Export bundle is re-imported
+
+- **WHEN** a generated markdown export bundle is re-imported unchanged
+- **THEN** image references SHALL still resolve correctly after import
diff --git a/openspec/specs/workspace-lint-and-quality-gates/spec.md b/openspec/specs/workspace-lint-and-quality-gates/spec.md
new file mode 100644
index 0000000..246ae90
--- /dev/null
+++ b/openspec/specs/workspace-lint-and-quality-gates/spec.md
@@ -0,0 +1,34 @@
+# workspace-lint-and-quality-gates Specification
+
+## Purpose
+TBD - created by archiving change normalize-lint-tooling-and-quality-gates. Update Purpose after archive.
+
+## Requirements
+
+### Requirement: Target workspace packages MUST expose a consistent quality script contract
+
+Core workspace packages covered by this change SHALL expose `lint`, `typecheck`, and `test` scripts with predictable behavior.
+
+#### Scenario: Contributor runs package quality checks
+
+- **WHEN** a contributor runs `pnpm --filter lint`, `typecheck`, and `test`
+- **THEN** each command SHALL exist and execute the package's intended quality gate
+
+### Requirement: Root quality commands MUST execute without script-level errors
+
+Root script aliases used for quality gates MUST resolve to valid commands and MUST NOT contain broken invocations.
+
+#### Scenario: Root production E2E command is executed
+
+- **WHEN** the root production E2E command is run
+- **THEN** the command SHALL resolve to a valid pnpm invocation
+- **AND** it SHALL start the expected test workflow rather than failing from command typos
+
+### Requirement: CI quality workflow MUST enforce standardized lint/type checks
+
+CI quality workflow steps SHALL call standardized package/root scripts so lint/type regressions are consistently caught.
+
+#### Scenario: Lint regression is introduced in covered package
+
+- **WHEN** CI runs for a change that includes a lint regression
+- **THEN** the quality workflow SHALL fail on the standardized lint step
From 465b38e4d332ffbe9499847025cb93fd51fbf6d9 Mon Sep 17 00:00:00 2001
From: Jack D
Date: Thu, 5 Mar 2026 17:03:48 +0000
Subject: [PATCH 12/91] Help center refactor
---
.github/workflows/ci.yml | 10 +-
apps/web/package.json | 2 +-
apps/web/src/app/inbox/page.tsx | 37 +-
.../src/components/ConversationView.test.tsx | 70 +-
.../src/components/ConversationView.tsx | 47 +-
apps/widget/src/styles.css | 38 +
docs/ai-source-metadata-contract.md | 29 +
docs/scripts-reference.md | 24 +-
docs/testing.md | 14 +-
package.json | 8 +-
packages/convex/convex/_generated/api.d.ts | 14 +
packages/convex/convex/aiAgent.ts | 23 +-
packages/convex/convex/aiAgentActions.ts | 3 +-
packages/convex/convex/helpCenterImports.ts | 1939 +----------------
.../convex/convex/helpCenterImports/README.md | 30 +
.../helpCenterImports/exportPipeline.ts | 209 ++
.../convex/helpCenterImports/markdownParse.ts | 139 ++
.../convex/helpCenterImports/pathUtils.ts | 322 +++
.../helpCenterImports/referenceRewrite.ts | 185 ++
.../helpCenterImports/restorePipeline.ts | 215 ++
.../convex/helpCenterImports/sourceQueries.ts | 120 +
.../convex/helpCenterImports/syncPipeline.ts | 871 ++++++++
packages/convex/convex/schema.ts | 2 +
packages/convex/convex/testing/helpers/ai.ts | 2 +
packages/convex/package.json | 1 +
.../helpCenterImportRewriteParity.test.ts | 128 ++
.../react-native-sdk/src/hooks/useAIAgent.ts | 1 +
packages/sdk-core/src/api/aiAgent.ts | 1 +
packages/web-shared/src/aiSourceLinks.test.ts | 35 +
packages/web-shared/src/aiSourceLinks.ts | 20 +
packages/web-shared/src/index.ts | 1 +
31 files changed, 2561 insertions(+), 1979 deletions(-)
create mode 100644 docs/ai-source-metadata-contract.md
create mode 100644 packages/convex/convex/helpCenterImports/README.md
create mode 100644 packages/convex/convex/helpCenterImports/exportPipeline.ts
create mode 100644 packages/convex/convex/helpCenterImports/markdownParse.ts
create mode 100644 packages/convex/convex/helpCenterImports/pathUtils.ts
create mode 100644 packages/convex/convex/helpCenterImports/referenceRewrite.ts
create mode 100644 packages/convex/convex/helpCenterImports/restorePipeline.ts
create mode 100644 packages/convex/convex/helpCenterImports/sourceQueries.ts
create mode 100644 packages/convex/convex/helpCenterImports/syncPipeline.ts
create mode 100644 packages/convex/tests/helpCenterImportRewriteParity.test.ts
create mode 100644 packages/web-shared/src/aiSourceLinks.test.ts
create mode 100644 packages/web-shared/src/aiSourceLinks.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1074543..6e26b12 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,11 +31,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- - name: Lint
- run: pnpm lint
+ - name: Lint (web + convex)
+ run: pnpm quality:lint
- - name: Typecheck
- run: pnpm typecheck
+ - name: Typecheck (web + convex)
+ run: pnpm quality:typecheck
- name: Convex raw auth guard
run: pnpm security:convex-auth-guard
@@ -50,7 +50,7 @@ jobs:
run: pnpm security:headers-check
- name: Convex backend tests
- run: pnpm --filter @opencom/convex test
+ run: pnpm test:convex
- name: Web production build
run: pnpm --filter @opencom/web build
diff --git a/apps/web/package.json b/apps/web/package.json
index 9b495f7..2804723 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -6,7 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "next lint",
+ "lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx
index 6694032..126423c 100644
--- a/apps/web/src/app/inbox/page.tsx
+++ b/apps/web/src/app/inbox/page.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { useQuery, useMutation, useAction } from "convex/react";
import { api } from "@opencom/convex";
+import { resolveArticleSourceId } from "@opencom/web-shared";
import { Button, Card, Input } from "@opencom/ui";
import {
Send,
@@ -1333,14 +1334,34 @@ function InboxContent(): React.JSX.Element | null {
{sourceLabel}
- {sourcesToShow.map((source, index) => (
- -
- {source.title}
-
- ))}
+ {sourcesToShow.map((source, index) => {
+ const articleSourceId = resolveArticleSourceId(source);
+ return (
+ -
+ {articleSourceId ? (
+
+ ) : (
+
+ {source.title}
+
+ )}
+
+ );
+ })}
)}
diff --git a/apps/widget/src/components/ConversationView.test.tsx b/apps/widget/src/components/ConversationView.test.tsx
index e33bf88..3e8cda6 100644
--- a/apps/widget/src/components/ConversationView.test.tsx
+++ b/apps/widget/src/components/ConversationView.test.tsx
@@ -57,7 +57,7 @@ type AiResponseFixture = {
_id: string;
messageId: string;
feedback?: "helpful" | "not_helpful";
- sources: Array<{ type: string; id: string; title: string }>;
+ sources: Array<{ type: string; id: string; title: string; articleId?: string }>;
handedOff?: boolean;
};
@@ -71,6 +71,7 @@ describe("ConversationView personas", () => {
let handoffToHumanMutationMock: ReturnType;
let generateAiResponseActionMock: ReturnType;
let searchSuggestionsActionMock: ReturnType;
+ let onSelectArticleMock: ReturnType;
beforeEach(() => {
vi.clearAllMocks();
@@ -86,6 +87,7 @@ describe("ConversationView personas", () => {
handoffToHumanMutationMock = vi.fn().mockResolvedValue(undefined);
generateAiResponseActionMock = vi.fn().mockResolvedValue(undefined);
searchSuggestionsActionMock = vi.fn().mockResolvedValue([]);
+ onSelectArticleMock = vi.fn();
const mockedUseMutation = useMutation as unknown as ReturnType;
mockedUseMutation.mockImplementation((mutationRef: unknown) => {
@@ -171,7 +173,7 @@ describe("ConversationView personas", () => {
commonIssueButtons={undefined}
onBack={vi.fn()}
onClose={vi.fn()}
- onSelectArticle={vi.fn()}
+ onSelectArticle={onSelectArticleMock as any}
/>
);
};
@@ -330,4 +332,68 @@ describe("ConversationView personas", () => {
);
});
});
+
+ it("renders article sources as clickable links and opens the article", () => {
+ messagesResult = [
+ {
+ _id: "m_ai_linked",
+ _creationTime: 1700000045000,
+ senderType: "bot",
+ senderId: "ai-agent",
+ content: "Try this guide.",
+ },
+ ];
+ aiResponsesResult = [
+ {
+ _id: "r_ai_linked",
+ messageId: "m_ai_linked",
+ sources: [
+ {
+ type: "article",
+ id: "legacy-article-id",
+ articleId: "article_link_123",
+ title: "Setup Guide",
+ },
+ ],
+ },
+ ];
+
+ renderSubject();
+
+ fireEvent.click(screen.getByTestId("widget-ai-source-link-r_ai_linked-0"));
+
+ expect(onSelectArticleMock).toHaveBeenCalledWith("article_link_123");
+ });
+
+ it("keeps non-article sources as non-clickable attribution text", () => {
+ messagesResult = [
+ {
+ _id: "m_ai_fallback",
+ _creationTime: 1700000050000,
+ senderType: "bot",
+ senderId: "ai-agent",
+ content: "Based on snippets.",
+ },
+ ];
+ aiResponsesResult = [
+ {
+ _id: "r_ai_fallback",
+ messageId: "m_ai_fallback",
+ sources: [
+ {
+ type: "snippet",
+ id: "snippet_1",
+ title: "Snippet Reference",
+ },
+ ],
+ },
+ ];
+
+ renderSubject();
+
+ expect(screen.getByTestId("widget-ai-source-text-r_ai_fallback-0")).toHaveTextContent(
+ "Snippet Reference"
+ );
+ expect(screen.queryByTestId("widget-ai-source-link-r_ai_fallback-0")).toBeNull();
+ });
});
diff --git a/apps/widget/src/components/ConversationView.tsx b/apps/widget/src/components/ConversationView.tsx
index 91ca516..09ea27e 100644
--- a/apps/widget/src/components/ConversationView.tsx
+++ b/apps/widget/src/components/ConversationView.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useRef } from "react";
import { useQuery, useMutation, useAction } from "convex/react";
import { api } from "@opencom/convex";
import type { Id } from "@opencom/convex/dataModel";
+import { resolveArticleSourceId } from "@opencom/web-shared";
import { ChevronLeft, X, Send, Bot, ThumbsUp, ThumbsDown, User, Book } from "../icons";
import { CsatPrompt } from "../CsatPrompt";
import { formatTime } from "../utils/format";
@@ -574,10 +575,48 @@ export function ConversationView({
{aiData.sources && aiData.sources.length > 0 && (
- Sources:{" "}
- {aiData.sources
- .map((s: { type: string; id: string; title: string }) => s.title)
- .join(", ")}
+
Sources:
+
+ {aiData.sources.map(
+ (
+ source: {
+ type: string;
+ id: string;
+ title: string;
+ articleId?: string;
+ },
+ index: number
+ ) => {
+ const articleSourceId = resolveArticleSourceId(source);
+ return (
+ -
+ {articleSourceId ? (
+
+ ) : (
+
+ {source.title}
+
+ )}
+
+ );
+ }
+ )}
+
)}
{!feedbackGiven ? (
diff --git a/apps/widget/src/styles.css b/apps/widget/src/styles.css
index 7e23961..338ff9d 100644
--- a/apps/widget/src/styles.css
+++ b/apps/widget/src/styles.css
@@ -3968,6 +3968,44 @@
color: #6b7280;
font-size: 11px;
margin-bottom: 6px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px;
+}
+
+.opencom-ai-source-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.opencom-ai-source-item {
+ display: inline-flex;
+ align-items: center;
+}
+
+.opencom-ai-source-link {
+ border: none;
+ background: none;
+ color: #2563eb;
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ padding: 0;
+ cursor: pointer;
+ font-size: 11px;
+ line-height: 1.2;
+}
+
+.opencom-ai-source-link:hover {
+ color: #1d4ed8;
+}
+
+.opencom-ai-source-text {
+ color: #6b7280;
font-style: italic;
}
diff --git a/docs/ai-source-metadata-contract.md b/docs/ai-source-metadata-contract.md
new file mode 100644
index 0000000..035ef27
--- /dev/null
+++ b/docs/ai-source-metadata-contract.md
@@ -0,0 +1,29 @@
+# AI Source Metadata Contract
+
+This document defines the source payload contract used by AI responses across Convex, widget UI, and inbox AI review surfaces.
+
+## Source Shape
+
+Each source entry includes:
+
+- `type: string` - knowledge source type (for example `article`, `internalArticle`, or `snippet`)
+- `id: string` - source record identifier from retrieval
+- `title: string` - human-readable source title
+- `articleId?: string` - optional explicit Help Center article ID for link navigation
+
+## Linking Rules
+
+- `articleId` is the canonical navigation field when present.
+- For legacy records, `type === "article"` falls back to `id` as the article identifier.
+- Non-article sources remain visible as attribution labels and must not render clickable article links.
+
+Shared helper: `resolveArticleSourceId(...)` in `@opencom/web-shared` applies this logic for all frontend surfaces.
+
+## UI Expectations
+
+- Widget AI messages:
+ - Article sources render as clickable controls that open the Help Center article view.
+ - Non-article sources render as plain attribution text.
+- Inbox AI review:
+ - Article sources render as clickable controls that route to `/articles/
`.
+ - Non-article sources remain non-clickable attribution entries.
diff --git a/docs/scripts-reference.md b/docs/scripts-reference.md
index dc6ef78..4e198d9 100644
--- a/docs/scripts-reference.md
+++ b/docs/scripts-reference.md
@@ -264,12 +264,18 @@ Files in `security/` configure CI gate behavior:
### Quality
-| Command | Description |
-| ------------------- | ---------------------- |
-| `pnpm lint` | Lint all packages |
-| `pnpm format` | Format all files |
-| `pnpm format:check` | Check formatting |
-| `pnpm typecheck` | Typecheck all packages |
+| Command | Description |
+| ------------------------ | ------------------------------------------------- |
+| `pnpm web:lint` | Lint `@opencom/web` using ESLint CLI |
+| `pnpm convex:lint` | Lint `@opencom/convex` Help Center import/export modules |
+| `pnpm quality:lint` | Run standardized lint gates for web + convex |
+| `pnpm web:typecheck` | Typecheck `@opencom/web` |
+| `pnpm convex:typecheck` | Typecheck `@opencom/convex` |
+| `pnpm quality:typecheck` | Run standardized typecheck gates for web + convex |
+| `pnpm lint` | Lint all packages (workspace-wide) |
+| `pnpm format` | Format all files |
+| `pnpm format:check` | Check formatting |
+| `pnpm typecheck` | Typecheck all packages |
### Testing
@@ -300,3 +306,9 @@ Files in `security/` configure CI gate behavior:
| `pnpm deploy:widget:cdn` | Deploy widget to CDN |
| `pnpm seed:landing` | Seed landing demo data |
| `pnpm seed:landing:cleanup` | Clean up landing demo data |
+
+## Migration Notes
+
+- `@opencom/web` lint now runs via explicit ESLint CLI (`eslint src --ext .ts,.tsx`) instead of deprecated `next lint`.
+- `@opencom/convex` now exposes a canonical `lint` script so package-level quality gates can run consistently.
+- Root `pnpm test:e2e:prod` now resolves through `pnpm web:test:e2e` (previous `pn` typo removed).
diff --git a/docs/testing.md b/docs/testing.md
index 7547914..b17befe 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -236,14 +236,14 @@ The CI pipeline runs two jobs:
**1. Checks job:**
- Install dependencies (`pnpm install --frozen-lockfile`)
-- Lint (`pnpm lint`)
-- Typecheck (`pnpm typecheck`)
+- Standardized lint gates (`pnpm quality:lint`)
+- Standardized typecheck gates (`pnpm quality:typecheck`)
- Security gates:
- `pnpm security:convex-auth-guard` — unguarded handler detection
- `pnpm security:convex-any-args-gate` — `v.any()` usage scanning
- `pnpm security:secret-scan` — committed secret detection
- `pnpm security:headers-check` — security header validation
-- Convex tests (`pnpm --filter @opencom/convex test`)
+- Convex tests (`pnpm test:convex`)
- Web build (`pnpm --filter @opencom/web build`)
- Dependency audit gate
@@ -256,13 +256,13 @@ The CI pipeline runs two jobs:
### CI-Equivalent Local Run
```bash
-pnpm lint
-pnpm typecheck
+pnpm quality:lint
+pnpm quality:typecheck
pnpm security:convex-auth-guard
pnpm security:convex-any-args-gate
pnpm security:secret-scan
pnpm security:headers-check
-pnpm --filter @opencom/convex test
+pnpm test:convex
pnpm --filter @opencom/web build
bash scripts/build-widget-for-tests.sh
pnpm web:test:e2e
@@ -301,7 +301,7 @@ E2E test runs are logged to `test-run-log.jsonl` for reliability analysis.
```bash
pnpm test:summary # Show recent run summary
pnpm test:clear # Clear run history
-pnpm test:e2e:prod # Run E2E against production build
+pnpm test:e2e:prod # Run E2E against production build path
```
Reliability budgets (`security/e2e-reliability-budget.json`):
diff --git a/package.json b/package.json
index 94dc242..5dcb28b 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,12 @@
"deploy:mobile:prev": "pnpm --filter @opencom/mobile deploy:production",
"deploy:mobile:prod": "pnpm --filter @opencom/mobile deploy:production",
"deploy:widget:cdn": "bash -lc 'set -a; source .env.local; set +a; bash scripts/deploy-widget-cdn.sh'",
+ "web:lint": "pnpm --filter @opencom/web lint",
+ "web:typecheck": "pnpm --filter @opencom/web typecheck",
+ "convex:lint": "pnpm --filter @opencom/convex lint",
+ "convex:typecheck": "pnpm --filter @opencom/convex typecheck",
+ "quality:lint": "pnpm web:lint && pnpm convex:lint",
+ "quality:typecheck": "pnpm web:typecheck && pnpm convex:typecheck",
"lint": "pnpm run -r lint",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
@@ -43,7 +49,7 @@
"security:headers-check": "node scripts/ci-security-headers-check.js",
"test:summary": "node scripts/test-summary.js",
"test:clear": "node scripts/test-summary.js clear",
- "test:e2e:prod": "E2E_USE_PROD_BUILD=true pn web:test:e2e",
+ "test:e2e:prod": "E2E_USE_PROD_BUILD=true pnpm web:test:e2e",
"seed:landing": "pnpm dlx dotenv-cli -e apps/landing/.env.local -- tsx scripts/seed-landing-demo.ts",
"seed:landing:cleanup": "pnpm dlx dotenv-cli -e apps/landing/.env.local -- tsx scripts/seed-landing-demo.ts --cleanup",
"export:landing:md": "tsx scripts/export-landing-content-to-md.ts"
diff --git a/packages/convex/convex/_generated/api.d.ts b/packages/convex/convex/_generated/api.d.ts
index eb1d9bf..ff5aaf9 100644
--- a/packages/convex/convex/_generated/api.d.ts
+++ b/packages/convex/convex/_generated/api.d.ts
@@ -33,6 +33,13 @@ import type * as emailTemplates from "../emailTemplates.js";
import type * as embeddings from "../embeddings.js";
import type * as events from "../events.js";
import type * as helpCenterImports from "../helpCenterImports.js";
+import type * as helpCenterImports_exportPipeline from "../helpCenterImports/exportPipeline.js";
+import type * as helpCenterImports_markdownParse from "../helpCenterImports/markdownParse.js";
+import type * as helpCenterImports_pathUtils from "../helpCenterImports/pathUtils.js";
+import type * as helpCenterImports_referenceRewrite from "../helpCenterImports/referenceRewrite.js";
+import type * as helpCenterImports_restorePipeline from "../helpCenterImports/restorePipeline.js";
+import type * as helpCenterImports_sourceQueries from "../helpCenterImports/sourceQueries.js";
+import type * as helpCenterImports_syncPipeline from "../helpCenterImports/syncPipeline.js";
import type * as http from "../http.js";
import type * as identityVerification from "../identityVerification.js";
import type * as internalArticles from "../internalArticles.js";
@@ -146,6 +153,13 @@ declare const fullApi: ApiFromModules<{
embeddings: typeof embeddings;
events: typeof events;
helpCenterImports: typeof helpCenterImports;
+ "helpCenterImports/exportPipeline": typeof helpCenterImports_exportPipeline;
+ "helpCenterImports/markdownParse": typeof helpCenterImports_markdownParse;
+ "helpCenterImports/pathUtils": typeof helpCenterImports_pathUtils;
+ "helpCenterImports/referenceRewrite": typeof helpCenterImports_referenceRewrite;
+ "helpCenterImports/restorePipeline": typeof helpCenterImports_restorePipeline;
+ "helpCenterImports/sourceQueries": typeof helpCenterImports_sourceQueries;
+ "helpCenterImports/syncPipeline": typeof helpCenterImports_syncPipeline;
http: typeof http;
identityVerification: typeof identityVerification;
internalArticles: typeof internalArticles;
diff --git a/packages/convex/convex/aiAgent.ts b/packages/convex/convex/aiAgent.ts
index b92666b..87a0e69 100644
--- a/packages/convex/convex/aiAgent.ts
+++ b/packages/convex/convex/aiAgent.ts
@@ -30,6 +30,13 @@ type KnowledgeResult = {
relevanceScore: number;
};
+const aiResponseSourceValidator = v.object({
+ type: v.string(),
+ id: v.string(),
+ title: v.string(),
+ articleId: v.optional(v.string()),
+});
+
const DEFAULT_AI_SETTINGS = {
enabled: false,
knowledgeSources: ["articles"] as KnowledgeSource[],
@@ -472,22 +479,10 @@ export const storeResponse = mutation({
response: v.string(),
generatedCandidateResponse: v.optional(v.string()),
generatedCandidateSources: v.optional(
- v.array(
- v.object({
- type: v.string(),
- id: v.string(),
- title: v.string(),
- })
- )
+ v.array(aiResponseSourceValidator)
),
generatedCandidateConfidence: v.optional(v.number()),
- sources: v.array(
- v.object({
- type: v.string(),
- id: v.string(),
- title: v.string(),
- })
- ),
+ sources: v.array(aiResponseSourceValidator),
confidence: v.number(),
handedOff: v.boolean(),
handoffReason: v.optional(v.string()),
diff --git a/packages/convex/convex/aiAgentActions.ts b/packages/convex/convex/aiAgentActions.ts
index 7abf971..1a380ff 100644
--- a/packages/convex/convex/aiAgentActions.ts
+++ b/packages/convex/convex/aiAgentActions.ts
@@ -245,7 +245,7 @@ export const generateResponse = action({
): Promise<{
response: string;
confidence: number;
- sources: Array<{ type: string; id: string; title: string }>;
+ sources: Array<{ type: string; id: string; title: string; articleId?: string }>;
handoff: boolean;
handoffReason: string | null;
messageId: string | null;
@@ -630,6 +630,7 @@ export const generateResponse = action({
type: r.type,
id: r.id,
title: r.title,
+ articleId: r.type === "article" ? r.id : undefined,
}));
if (handoff) {
diff --git a/packages/convex/convex/helpCenterImports.ts b/packages/convex/convex/helpCenterImports.ts
index 80be18e..72500b5 100644
--- a/packages/convex/convex/helpCenterImports.ts
+++ b/packages/convex/convex/helpCenterImports.ts
@@ -1,8 +1,9 @@
import { v } from "convex/values";
import { authMutation, authQuery } from "./lib/authWrappers";
-import type { Id } from "./_generated/dataModel";
-import type { MutationCtx } from "./_generated/server";
-import { ensureUniqueSlug, generateSlug } from "./utils/strings";
+import { runExportMarkdown } from "./helpCenterImports/exportPipeline";
+import { runRestoreRun } from "./helpCenterImports/restorePipeline";
+import { runListHistory, runListSources } from "./helpCenterImports/sourceQueries";
+import { runSyncMarkdownFolder } from "./helpCenterImports/syncPipeline";
const markdownFileValidator = v.object({
relativePath: v.string(),
@@ -16,632 +17,6 @@ const importAssetValidator = v.object({
size: v.optional(v.number()),
});
-const MARKDOWN_EXTENSION_REGEX = /\.md(?:own)?$/i;
-const IMAGE_EXTENSION_REGEX = /\.(png|jpe?g|gif|webp|avif)$/i;
-const ROOT_COLLECTION_MATCH_KEY = "__root__";
-const UNCATEGORIZED_COLLECTION_PATH = "uncategorized";
-const UNCATEGORIZED_COLLECTION_ALIASES = new Set([
- UNCATEGORIZED_COLLECTION_PATH,
- "uncategorised",
-]);
-const ASSET_REFERENCE_PREFIX = "oc-asset://";
-const ASSET_REFERENCE_REGEX = /oc-asset:\/\/([A-Za-z0-9_-]+)/g;
-const MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
-const HTML_IMAGE_REGEX = /
]*?)\bsrc=(["'])([^"']+)\2([^>]*)>/gi;
-const SUPPORTED_IMPORT_IMAGE_MIME_TYPES = new Set([
- "image/png",
- "image/jpeg",
- "image/gif",
- "image/webp",
- "image/avif",
-]);
-const MAX_IMPORT_IMAGE_BYTES = 5 * 1024 * 1024;
-
-function isSupportedImportMimeType(mimeType: string | undefined | null): boolean {
- if (!mimeType) {
- return false;
- }
- return SUPPORTED_IMPORT_IMAGE_MIME_TYPES.has(mimeType.toLowerCase());
-}
-
-function normalizePath(path: string): string {
- const normalized = path
- .replace(/\\/g, "/")
- .trim()
- .replace(/^\/+|\/+$/g, "");
- if (!normalized) {
- return "";
- }
- const segments = normalized.split("/").filter(Boolean);
- for (const segment of segments) {
- if (segment === "." || segment === "..") {
- throw new Error(`Invalid relative path segment "${segment}"`);
- }
- }
- return segments.join("/");
-}
-
-function normalizeSourceKey(sourceKey: string): string {
- return sourceKey
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9:_-]+/g, "-")
- .replace(/^-+|-+$/g, "");
-}
-
-function normalizeMatchText(value: string): string {
- return value
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, " ")
- .replace(/\s+/g, " ")
- .trim();
-}
-
-function buildCollectionNameMatchKey(
- parentId: Id<"collections"> | undefined,
- name: string
-): string {
- return `${parentId ?? ROOT_COLLECTION_MATCH_KEY}::${normalizeMatchText(name)}`;
-}
-
-function buildArticleTitleMatchKey(
- collectionId: Id<"collections"> | undefined,
- title: string
-): string {
- return `${collectionId ?? ROOT_COLLECTION_MATCH_KEY}::${normalizeMatchText(title)}`;
-}
-
-function buildArticleSlugMatchKey(
- collectionId: Id<"collections"> | undefined,
- slug: string
-): string {
- return `${collectionId ?? ROOT_COLLECTION_MATCH_KEY}::${normalizeSourceKey(slug)}`;
-}
-
-function addMapArrayValue(map: Map, key: K, value: V): void {
- const existing = map.get(key);
- if (existing) {
- existing.push(value);
- return;
- }
- map.set(key, [value]);
-}
-
-function buildDefaultSourceKey(sourceName: string, rootCollectionId?: Id<"collections">): string {
- const normalizedName = normalizeSourceKey(sourceName) || "import";
- const collectionScope = rootCollectionId ? `collection:${rootCollectionId}` : "collection:root";
- return `${collectionScope}:${normalizedName}`;
-}
-
-function getParentPath(path: string): string | undefined {
- const index = path.lastIndexOf("/");
- if (index === -1) {
- return undefined;
- }
- return path.slice(0, index);
-}
-
-function getFileName(path: string): string {
- const index = path.lastIndexOf("/");
- if (index === -1) {
- return path;
- }
- return path.slice(index + 1);
-}
-
-function getFileExtension(fileName: string): string {
- const index = fileName.lastIndexOf(".");
- if (index === -1) {
- return "";
- }
- return fileName.slice(index).toLowerCase();
-}
-
-function withoutMarkdownExtension(fileName: string): string {
- return fileName.replace(/\.md(?:own)?$/i, "");
-}
-
-function humanizeName(raw: string): string {
- if (!raw) {
- return "Untitled";
- }
- const words = raw
- .replace(/[-_]+/g, " ")
- .split(/\s+/)
- .filter(Boolean)
- .map((word) => word[0]?.toUpperCase() + word.slice(1));
- return words.length > 0 ? words.join(" ") : "Untitled";
-}
-
-function inferTitle(filePath: string, content: string): string {
- const headingMatch = content.match(/^\s*#\s+(.+)$/m);
- if (headingMatch && headingMatch[1]) {
- return headingMatch[1].trim();
- }
- return humanizeName(withoutMarkdownExtension(getFileName(filePath)));
-}
-
-function parseFrontmatterValue(rawValue: string): string {
- const value = rawValue.trim();
- if (!value) {
- return "";
- }
-
- if (value.startsWith('"') && value.endsWith('"')) {
- try {
- return JSON.parse(value) as string;
- } catch {
- return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
- }
- }
-
- if (value.startsWith("'") && value.endsWith("'")) {
- return value.slice(1, -1).replace(/''/g, "'");
- }
-
- return value;
-}
-
-function normalizeCollectionPathFromFrontmatter(
- rawValue: string | undefined
-): string | null | undefined {
- if (rawValue === undefined) {
- return undefined;
- }
-
- const collectionPath = rawValue.trim();
- if (!collectionPath) {
- return null;
- }
-
- const normalized = normalizePath(collectionPath);
- if (!normalized) {
- return null;
- }
-
- if (UNCATEGORIZED_COLLECTION_ALIASES.has(normalized.toLowerCase())) {
- return null;
- }
-
- return normalized;
-}
-
-function parseMarkdownImportContent(content: string): {
- body: string;
- frontmatterTitle?: string;
- frontmatterSlug?: string;
- frontmatterCollectionPath?: string | null;
-} {
- const normalizedContent = content.replace(/^\uFEFF/, "");
- const lines = normalizedContent.split(/\r?\n/);
- if (lines.length < 3 || lines[0]?.trim() !== "---") {
- return { body: normalizedContent };
- }
-
- let frontmatterEndIndex = -1;
- for (let index = 1; index < lines.length; index += 1) {
- if (lines[index]?.trim() === "---") {
- frontmatterEndIndex = index;
- break;
- }
- }
-
- if (frontmatterEndIndex === -1) {
- return { body: normalizedContent };
- }
-
- const frontmatterEntries = new Map();
- for (const line of lines.slice(1, frontmatterEndIndex)) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith("#")) {
- continue;
- }
-
- const separatorIndex = trimmed.indexOf(":");
- if (separatorIndex <= 0) {
- continue;
- }
-
- const key = trimmed.slice(0, separatorIndex).trim().toLowerCase();
- const rawValue = trimmed.slice(separatorIndex + 1);
- frontmatterEntries.set(key, parseFrontmatterValue(rawValue));
- }
-
- const body = lines
- .slice(frontmatterEndIndex + 1)
- .join("\n")
- .replace(/^\s*\n/, "");
- const frontmatterTitle = frontmatterEntries.get("title")?.trim() || undefined;
- const rawSlug = frontmatterEntries.get("slug");
- const normalizedSlug = rawSlug ? normalizeSourceKey(rawSlug) : "";
-
- return {
- body,
- frontmatterTitle,
- frontmatterSlug: normalizedSlug || undefined,
- frontmatterCollectionPath: normalizeCollectionPathFromFrontmatter(
- frontmatterEntries.get("collectionpath")
- ),
- };
-}
-
-function getDirectoryPath(filePath: string): string | undefined {
- const parentPath = getParentPath(filePath);
- return parentPath && parentPath.length > 0 ? parentPath : undefined;
-}
-
-function resolveIncomingCollectionPath(file: {
- relativePath: string;
- frontmatterCollectionPath?: string | null;
-}): string | undefined {
- if (file.frontmatterCollectionPath === null) {
- return undefined;
- }
- if (typeof file.frontmatterCollectionPath === "string") {
- return file.frontmatterCollectionPath;
- }
- return getDirectoryPath(file.relativePath);
-}
-
-function pathDepth(path: string): number {
- return path.split("/").length;
-}
-
-function getFirstPathSegment(path: string): string {
- const index = path.indexOf("/");
- return index === -1 ? path : path.slice(0, index);
-}
-
-function stripFirstPathSegment(path: string): string | null {
- const index = path.indexOf("/");
- if (index === -1) {
- return null;
- }
- return path.slice(index + 1);
-}
-
-function detectCommonRootFolder(paths: string[]): string | null {
- if (paths.length === 0) {
- return null;
- }
- const firstSegments = new Set(paths.map(getFirstPathSegment).filter(Boolean));
- const allNested = paths.every((path) => path.includes("/"));
- if (firstSegments.size !== 1 || !allNested) {
- return null;
- }
- return paths[0]!.split("/")[0]!;
-}
-
-function stripSpecificRootFolder(path: string, rootFolder: string): string {
- if (path === rootFolder) {
- return "";
- }
- const prefix = `${rootFolder}/`;
- if (path.startsWith(prefix)) {
- return path.slice(prefix.length);
- }
- return path;
-}
-
-function sanitizePathSegment(name: string): string {
- return name
- .trim()
- .replace(/[<>:"/\\|?*\u0000-\u001f]+/g, "-")
- .replace(/\s+/g, "-")
- .replace(/-+/g, "-")
- .replace(/^-+|-+$/g, "")
- .toLowerCase();
-}
-
-function ensureMarkdownPath(path: string): string {
- return MARKDOWN_EXTENSION_REGEX.test(path) ? path : `${path}.md`;
-}
-
-function dedupePath(path: string, usedPaths: Set): string {
- if (!usedPaths.has(path)) {
- usedPaths.add(path);
- return path;
- }
-
- const extensionIndex = path.lastIndexOf(".");
- const hasExtension = extensionIndex > -1 && extensionIndex < path.length - 1;
- const basePath = hasExtension ? path.slice(0, extensionIndex) : path;
- const extension = hasExtension ? path.slice(extensionIndex) : "";
-
- let attempt = 2;
- while (attempt < 10_000) {
- const candidate = `${basePath}-${attempt}${extension}`;
- if (!usedPaths.has(candidate)) {
- usedPaths.add(candidate);
- return candidate;
- }
- attempt += 1;
- }
-
- throw new Error(`Failed to create unique export path for "${path}"`);
-}
-
-function dedupeRelativePath(path: string, usedPaths: Set): string {
- const normalized = ensureMarkdownPath(path);
- return dedupePath(normalized, usedPaths);
-}
-
-function extractAssetReferenceIds(markdown: string): string[] {
- const ids = new Set();
- const matches = markdown.matchAll(ASSET_REFERENCE_REGEX);
- for (const match of matches) {
- const id = match[1];
- if (id) {
- ids.add(id);
- }
- }
- return Array.from(ids);
-}
-
-function parseMarkdownImageTarget(target: string): {
- path: string;
- suffix: string;
- wrappedInAngles: boolean;
-} | null {
- const trimmed = target.trim();
- if (!trimmed) {
- return null;
- }
-
- if (trimmed.startsWith("<")) {
- const end = trimmed.indexOf(">");
- if (end <= 1) {
- return null;
- }
- return {
- path: trimmed.slice(1, end).trim(),
- suffix: trimmed.slice(end + 1).trim(),
- wrappedInAngles: true,
- };
- }
-
- const [path, ...suffixParts] = trimmed.split(/\s+/);
- if (!path) {
- return null;
- }
- return {
- path,
- suffix: suffixParts.join(" ").trim(),
- wrappedInAngles: false,
- };
-}
-
-function buildMarkdownImageTarget(parsed: {
- path: string;
- suffix: string;
- wrappedInAngles: boolean;
-}): string {
- const path = parsed.wrappedInAngles ? `<${parsed.path}>` : parsed.path;
- return parsed.suffix ? `${path} ${parsed.suffix}` : path;
-}
-
-function isExternalReference(path: string): boolean {
- const normalized = path.trim().toLowerCase();
- return (
- normalized.startsWith("http://") ||
- normalized.startsWith("https://") ||
- normalized.startsWith("data:") ||
- normalized.startsWith("javascript:") ||
- normalized.startsWith("vbscript:") ||
- normalized.startsWith("//") ||
- normalized.startsWith("#")
- );
-}
-
-function resolveReferencePath(markdownPath: string, reference: string): string | null {
- const normalized = reference
- .trim()
- .replace(/\\/g, "/")
- .replace(/^<|>$/g, "");
- if (!normalized || isExternalReference(normalized)) {
- return null;
- }
-
- const pathWithoutQuery = normalized.split("#")[0]?.split("?")[0] ?? "";
- if (!pathWithoutQuery) {
- return null;
- }
-
- const baseSegments = (getDirectoryPath(markdownPath) ?? "").split("/").filter(Boolean);
- const referenceSegments = pathWithoutQuery.split("/").filter((segment) => segment.length > 0);
- const resolvedSegments = [...baseSegments];
-
- for (const segment of referenceSegments) {
- if (segment === ".") {
- continue;
- }
- if (segment === "..") {
- if (resolvedSegments.length === 0) {
- return null;
- }
- resolvedSegments.pop();
- continue;
- }
- resolvedSegments.push(segment);
- }
-
- return resolvedSegments.join("/");
-}
-
-function rewriteMarkdownImageReferences(
- markdownContent: string,
- markdownPath: string,
- assetReferenceByPath: Map
-): { content: string; unresolvedReferences: string[] } {
- const unresolved = new Set();
-
- const withMarkdownLinksRewritten = markdownContent.replace(
- MARKDOWN_IMAGE_REGEX,
- (full, alt, target) => {
- const parsed = parseMarkdownImageTarget(target);
- if (!parsed || parsed.path.startsWith(ASSET_REFERENCE_PREFIX) || isExternalReference(parsed.path)) {
- return full;
- }
-
- const resolvedPath = resolveReferencePath(markdownPath, parsed.path);
- if (!resolvedPath) {
- unresolved.add(`${markdownPath}: ${parsed.path}`);
- return full;
- }
-
- const assetReference = assetReferenceByPath.get(resolvedPath);
- if (!assetReference) {
- unresolved.add(`${markdownPath}: ${parsed.path}`);
- return full;
- }
-
- const rewrittenTarget = buildMarkdownImageTarget({
- ...parsed,
- path: assetReference,
- });
- return ``;
- }
- );
-
- const withHtmlLinksRewritten = withMarkdownLinksRewritten.replace(
- HTML_IMAGE_REGEX,
- (full, before, quote, src, after) => {
- if (src.startsWith(ASSET_REFERENCE_PREFIX) || isExternalReference(src)) {
- return full;
- }
-
- const resolvedPath = resolveReferencePath(markdownPath, src);
- if (!resolvedPath) {
- unresolved.add(`${markdownPath}: ${src}`);
- return full;
- }
-
- const assetReference = assetReferenceByPath.get(resolvedPath);
- if (!assetReference) {
- unresolved.add(`${markdownPath}: ${src}`);
- return full;
- }
-
- return `
`;
- }
- );
-
- return {
- content: withHtmlLinksRewritten,
- unresolvedReferences: Array.from(unresolved).sort((a, b) => a.localeCompare(b)),
- };
-}
-
-function getRelativePath(fromPath: string, toPath: string): string {
- const fromDirSegments = (getDirectoryPath(fromPath) ?? "").split("/").filter(Boolean);
- const toSegments = toPath.split("/").filter(Boolean);
-
- let sharedPrefixLength = 0;
- const maxShared = Math.min(fromDirSegments.length, toSegments.length);
- while (
- sharedPrefixLength < maxShared &&
- fromDirSegments[sharedPrefixLength] === toSegments[sharedPrefixLength]
- ) {
- sharedPrefixLength += 1;
- }
-
- const upSegments = fromDirSegments.slice(sharedPrefixLength).map(() => "..");
- const downSegments = toSegments.slice(sharedPrefixLength);
- const parts = [...upSegments, ...downSegments];
- return parts.length > 0 ? parts.join("/") : ".";
-}
-
-function rewriteAssetReferencesForExport(
- markdownContent: string,
- markdownPath: string,
- assetPathById: Map
-): string {
- return markdownContent.replace(ASSET_REFERENCE_REGEX, (fullMatch, id) => {
- const assetPath = assetPathById.get(id);
- if (!assetPath) {
- return fullMatch;
- }
- return getRelativePath(markdownPath, assetPath);
- });
-}
-
-function yamlQuote(value: string): string {
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, " ")}"`;
-}
-
-function buildFrontmatterContent(args: {
- title: string;
- slug: string;
- status: "draft" | "published";
- updatedAt: number;
- collectionPath?: string;
- sourceName?: string;
- body: string;
-}): string {
- const lines = [
- "---",
- `title: ${yamlQuote(args.title)}`,
- `slug: ${yamlQuote(args.slug)}`,
- `status: ${args.status}`,
- `updatedAt: ${yamlQuote(new Date(args.updatedAt).toISOString())}`,
- ];
-
- if (args.collectionPath) {
- lines.push(`collectionPath: ${yamlQuote(args.collectionPath)}`);
- }
- if (args.sourceName) {
- lines.push(`source: ${yamlQuote(args.sourceName)}`);
- }
-
- lines.push("---", "", args.body);
- return lines.join("\n");
-}
-
-function addDirectoryAndParents(pathSet: Set, directoryPath: string) {
- let cursor: string | undefined = directoryPath;
- while (cursor) {
- pathSet.add(cursor);
- cursor = getParentPath(cursor);
- }
-}
-
-function pushPreviewPath(list: string[], path: string, maxEntries = 200): void {
- if (list.length >= maxEntries) {
- return;
- }
- list.push(path);
-}
-
-type DbCtx = Pick;
-
-async function getNextCollectionOrder(
- ctx: DbCtx,
- workspaceId: Id<"workspaces">,
- parentId: Id<"collections"> | undefined
-): Promise {
- const siblings = await ctx.db
- .query("collections")
- .withIndex("by_parent", (q) => q.eq("workspaceId", workspaceId).eq("parentId", parentId))
- .collect();
- return siblings.reduce((max, sibling) => Math.max(max, sibling.order), 0) + 1;
-}
-
-async function getNextArticleOrder(
- ctx: DbCtx,
- collectionId: Id<"collections"> | undefined
-): Promise {
- const siblings = await ctx.db
- .query("articles")
- .withIndex("by_collection", (q) => q.eq("collectionId", collectionId))
- .collect();
- return siblings.reduce((max, sibling) => Math.max(max, sibling.order), 0) + 1;
-}
-
-function formatSourceLabel(sourceName: string, rootCollectionId?: Id<"collections">): string {
- return rootCollectionId ? `${sourceName} (${rootCollectionId})` : sourceName;
-}
-
export const syncMarkdownFolder = authMutation({
args: {
workspaceId: v.id("workspaces"),
@@ -654,818 +29,7 @@ export const syncMarkdownFolder = authMutation({
dryRun: v.optional(v.boolean()),
},
permission: "articles.create",
- handler: async (ctx, args) => {
- const now = Date.now();
- const publishByDefault = args.publishByDefault ?? true;
- const dryRun = args.dryRun ?? false;
- const sourceKey = normalizeSourceKey(
- args.sourceKey ?? buildDefaultSourceKey(args.sourceName, args.rootCollectionId)
- );
- if (!sourceKey) {
- throw new Error("Import source key is required");
- }
-
- if (args.rootCollectionId) {
- const rootCollection = await ctx.db.get(args.rootCollectionId);
- if (!rootCollection || rootCollection.workspaceId !== args.workspaceId) {
- throw new Error("Target collection not found");
- }
- }
-
- const rawIncomingFiles = args.files
- .map((file) => {
- const parsedContent = parseMarkdownImportContent(file.content);
- return {
- relativePath: normalizePath(file.relativePath),
- content: parsedContent.body,
- frontmatterTitle: parsedContent.frontmatterTitle,
- frontmatterSlug: parsedContent.frontmatterSlug,
- frontmatterCollectionPath: parsedContent.frontmatterCollectionPath,
- };
- })
- .filter((file) => Boolean(file.relativePath) && MARKDOWN_EXTENSION_REGEX.test(file.relativePath));
-
- if (rawIncomingFiles.length === 0) {
- throw new Error("No markdown files were found in this upload");
- }
-
- const rawIncomingAssets = (args.assets ?? [])
- .map((asset) => ({
- relativePath: normalizePath(asset.relativePath),
- storageId: asset.storageId,
- mimeType: asset.mimeType,
- size: asset.size,
- }))
- .filter((asset) => Boolean(asset.relativePath) && IMAGE_EXTENSION_REGEX.test(asset.relativePath));
-
- const commonRootFolder = detectCommonRootFolder(
- [...rawIncomingFiles.map((file) => file.relativePath), ...rawIncomingAssets.map((asset) => asset.relativePath)]
- );
-
- const incomingFiles = new Map<
- string,
- {
- relativePath: string;
- originalPath: string;
- content: string;
- frontmatterTitle?: string;
- frontmatterSlug?: string;
- frontmatterCollectionPath?: string | null;
- }
- >();
- for (const file of rawIncomingFiles) {
- const normalizedPath = commonRootFolder
- ? stripSpecificRootFolder(file.relativePath, commonRootFolder)
- : file.relativePath;
- if (!normalizedPath) {
- continue;
- }
- if (incomingFiles.has(normalizedPath)) {
- throw new Error(
- `Import contains duplicate markdown path after root normalization: "${normalizedPath}"`
- );
- }
- incomingFiles.set(normalizedPath, {
- relativePath: normalizedPath,
- originalPath: file.relativePath,
- content: file.content,
- frontmatterTitle: file.frontmatterTitle,
- frontmatterSlug: file.frontmatterSlug,
- frontmatterCollectionPath: file.frontmatterCollectionPath,
- });
- }
-
- if (incomingFiles.size === 0) {
- throw new Error("No markdown files remained after path normalization");
- }
-
- const incomingAssets = new Map<
- string,
- {
- relativePath: string;
- originalPath: string;
- storageId?: Id<"_storage">;
- mimeType?: string;
- size?: number;
- }
- >();
- for (const asset of rawIncomingAssets) {
- const normalizedPath = commonRootFolder
- ? stripSpecificRootFolder(asset.relativePath, commonRootFolder)
- : asset.relativePath;
- if (!normalizedPath) {
- continue;
- }
- if (incomingAssets.has(normalizedPath)) {
- throw new Error(
- `Import contains duplicate image path after root normalization: "${normalizedPath}"`
- );
- }
- incomingAssets.set(normalizedPath, {
- relativePath: normalizedPath,
- originalPath: asset.relativePath,
- storageId: asset.storageId,
- mimeType: asset.mimeType,
- size: asset.size,
- });
- }
-
- const existingSource = await ctx.db
- .query("helpCenterImportSources")
- .withIndex("by_workspace_source_key", (q) =>
- q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey)
- )
- .first();
- let sourceId = existingSource?._id;
-
- if (!sourceId && !dryRun) {
- sourceId = await ctx.db.insert("helpCenterImportSources", {
- workspaceId: args.workspaceId,
- sourceKey,
- sourceName: args.sourceName,
- rootCollectionId: args.rootCollectionId,
- createdAt: now,
- updatedAt: now,
- });
- }
-
- if (existingSource && !dryRun) {
- await ctx.db.patch(existingSource._id, {
- sourceName: args.sourceName,
- rootCollectionId: args.rootCollectionId,
- updatedAt: now,
- });
- }
-
- const existingCollections = sourceId
- ? await ctx.db
- .query("collections")
- .withIndex("by_workspace_import_source", (q) =>
- q.eq("workspaceId", args.workspaceId).eq("importSourceId", sourceId!)
- )
- .collect()
- : [];
-
- const existingArticles = sourceId
- ? await ctx.db
- .query("articles")
- .withIndex("by_workspace_import_source", (q) =>
- q.eq("workspaceId", args.workspaceId).eq("importSourceId", sourceId!)
- )
- .collect()
- : [];
-
- const existingAssets = sourceId
- ? await ctx.db
- .query("articleAssets")
- .withIndex("by_import_source", (q) => q.eq("importSourceId", sourceId!))
- .collect()
- : [];
- const existingAssetByPath = new Map(
- existingAssets
- .filter((asset) => asset.importPath)
- .map((asset) => [asset.importPath!, asset] as const)
- );
- const assetReferenceByPath = new Map();
- for (const existingAsset of existingAssets) {
- if (!existingAsset.importPath) {
- continue;
- }
- assetReferenceByPath.set(
- existingAsset.importPath,
- `${ASSET_REFERENCE_PREFIX}${existingAsset._id}`
- );
- }
-
- for (const incomingAsset of incomingAssets.values()) {
- const existingAsset = existingAssetByPath.get(incomingAsset.relativePath);
-
- if (dryRun) {
- const syntheticReference =
- existingAsset
- ? `${ASSET_REFERENCE_PREFIX}${existingAsset._id}`
- : `${ASSET_REFERENCE_PREFIX}dryrun-${normalizeSourceKey(incomingAsset.relativePath) || "asset"}`;
- assetReferenceByPath.set(incomingAsset.relativePath, syntheticReference);
- continue;
- }
-
- if (!sourceId) {
- throw new Error("Failed to resolve import source");
- }
- if (!incomingAsset.storageId) {
- throw new Error(
- `Image "${incomingAsset.relativePath}" is missing storageId. Upload assets before applying import.`
- );
- }
-
- const metadata = await ctx.storage.getMetadata(incomingAsset.storageId);
- if (!metadata) {
- throw new Error(`Uploaded image "${incomingAsset.relativePath}" was not found in storage.`);
- }
-
- const mimeType = (metadata.contentType ?? incomingAsset.mimeType ?? "").toLowerCase();
- if (!isSupportedImportMimeType(mimeType)) {
- throw new Error(
- `Unsupported image type for "${incomingAsset.relativePath}". Allowed: PNG, JPEG, GIF, WEBP, AVIF.`
- );
- }
- if (metadata.size > MAX_IMPORT_IMAGE_BYTES) {
- throw new Error(`Image "${incomingAsset.relativePath}" exceeds the 5MB upload limit.`);
- }
-
- const nowTimestamp = Date.now();
- if (existingAsset) {
- if (existingAsset.storageId !== incomingAsset.storageId) {
- await ctx.storage.delete(existingAsset.storageId);
- }
- await ctx.db.patch(existingAsset._id, {
- storageId: incomingAsset.storageId,
- fileName: getFileName(incomingAsset.relativePath),
- mimeType,
- size: metadata.size,
- updatedAt: nowTimestamp,
- });
- assetReferenceByPath.set(
- incomingAsset.relativePath,
- `${ASSET_REFERENCE_PREFIX}${existingAsset._id}`
- );
- continue;
- }
-
- const createdAssetId = await ctx.db.insert("articleAssets", {
- workspaceId: args.workspaceId,
- importSourceId: sourceId,
- importPath: incomingAsset.relativePath,
- storageId: incomingAsset.storageId,
- fileName: getFileName(incomingAsset.relativePath),
- mimeType,
- size: metadata.size,
- createdBy: ctx.user._id,
- createdAt: nowTimestamp,
- updatedAt: nowTimestamp,
- });
- assetReferenceByPath.set(incomingAsset.relativePath, `${ASSET_REFERENCE_PREFIX}${createdAssetId}`);
- }
-
- const unresolvedImageReferenceSet = new Set();
- for (const [path, file] of incomingFiles.entries()) {
- const rewritten = rewriteMarkdownImageReferences(file.content, path, assetReferenceByPath);
- for (const unresolved of rewritten.unresolvedReferences) {
- unresolvedImageReferenceSet.add(unresolved);
- }
- incomingFiles.set(path, {
- ...file,
- content: rewritten.content,
- });
- }
-
- const canMatchImportSource = (
- importSourceId: Id<"helpCenterImportSources"> | undefined
- ): boolean => {
- if (!importSourceId) {
- return true;
- }
- if (!sourceId) {
- return false;
- }
- return importSourceId === sourceId;
- };
-
- const workspaceCollections = await ctx.db
- .query("collections")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
- const collectionCandidatesByName = new Map();
- for (const collection of workspaceCollections) {
- if (!canMatchImportSource(collection.importSourceId)) {
- continue;
- }
- const nameKey = buildCollectionNameMatchKey(collection.parentId, collection.name);
- addMapArrayValue(collectionCandidatesByName, nameKey, collection);
- }
-
- const workspaceArticles = await ctx.db
- .query("articles")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
- const articleCandidatesByTitle = new Map();
- const articleCandidatesBySlug = new Map();
- for (const article of workspaceArticles) {
- if (!canMatchImportSource(article.importSourceId)) {
- continue;
- }
- const titleKey = buildArticleTitleMatchKey(article.collectionId, article.title);
- addMapArrayValue(articleCandidatesByTitle, titleKey, article);
- const slugKey = buildArticleSlugMatchKey(article.collectionId, article.slug);
- addMapArrayValue(articleCandidatesBySlug, slugKey, article);
- }
-
- const incomingTopLevelSegments = new Set(
- Array.from(incomingFiles.keys()).map(getFirstPathSegment).filter(Boolean)
- );
-
- const existingCollectionByPath = new Map(
- existingCollections
- .filter((collection) => typeof collection.importPath === "string" && collection.importPath)
- .map((collection) => [collection.importPath as string, collection] as const)
- );
- const existingCollectionByStrippedPath = new Map<
- string,
- (typeof existingCollections)[number]
- >();
- for (const collection of existingCollections) {
- if (!collection.importPath) {
- continue;
- }
- const strippedPath = stripFirstPathSegment(collection.importPath);
- if (!strippedPath) {
- continue;
- }
- const firstSegment = getFirstPathSegment(collection.importPath);
- if (incomingTopLevelSegments.has(firstSegment)) {
- continue;
- }
- if (
- !existingCollectionByPath.has(strippedPath) &&
- !existingCollectionByStrippedPath.has(strippedPath)
- ) {
- existingCollectionByStrippedPath.set(strippedPath, collection);
- }
- }
-
- const existingArticleByPath = new Map(
- existingArticles
- .filter((article) => typeof article.importPath === "string" && article.importPath)
- .map((article) => [article.importPath as string, article] as const)
- );
- const existingArticleByStrippedPath = new Map();
- for (const article of existingArticles) {
- if (!article.importPath) {
- continue;
- }
- const strippedPath = stripFirstPathSegment(article.importPath);
- if (!strippedPath) {
- continue;
- }
- const firstSegment = getFirstPathSegment(article.importPath);
- if (incomingTopLevelSegments.has(firstSegment)) {
- continue;
- }
- if (
- !existingArticleByPath.has(strippedPath) &&
- !existingArticleByStrippedPath.has(strippedPath)
- ) {
- existingArticleByStrippedPath.set(strippedPath, article);
- }
- }
-
- const desiredCollectionPaths = new Set();
- for (const file of incomingFiles.values()) {
- const directoryPath = resolveIncomingCollectionPath(file);
- if (directoryPath) {
- addDirectoryAndParents(desiredCollectionPaths, directoryPath);
- }
- }
-
- const sortedDesiredCollections = Array.from(desiredCollectionPaths).sort((a, b) => {
- const depthDelta = pathDepth(a) - pathDepth(b);
- if (depthDelta !== 0) {
- return depthDelta;
- }
- return a.localeCompare(b);
- });
-
- const collectionPathToId = new Map>();
- for (const [path, collection] of existingCollectionByPath.entries()) {
- collectionPathToId.set(path, collection._id);
- }
-
- let createdCollections = 0;
- let updatedCollections = 0;
- let createdArticles = 0;
- let updatedArticles = 0;
- let deletedArticles = 0;
- let deletedCollections = 0;
- const createdCollectionPaths: string[] = [];
- const updatedCollectionPaths: string[] = [];
- const deletedCollectionPaths: string[] = [];
- const createdArticlePaths: string[] = [];
- const updatedArticlePaths: string[] = [];
- const deletedArticlePaths: string[] = [];
- const matchedCollectionIds = new Set>();
- const matchedArticleIds = new Set>();
- const deletedArticleIdsInRun = new Set>();
- const deletedCollectionIdsInRun = new Set>();
-
- const rootCollectionPathSentinel = "__root__";
- const existingCollectionPathById = new Map, string>();
- for (const collection of existingCollections) {
- if (collection.importPath) {
- existingCollectionPathById.set(collection._id, collection.importPath);
- }
- }
- const getExistingArticleCollectionPath = (
- article: (typeof existingArticles)[number]
- ): string | undefined => {
- if (article.collectionId === args.rootCollectionId) {
- return rootCollectionPathSentinel;
- }
- if (!article.collectionId && !args.rootCollectionId) {
- return rootCollectionPathSentinel;
- }
- if (!article.collectionId) {
- return undefined;
- }
- return existingCollectionPathById.get(article.collectionId);
- };
-
- for (const collectionPath of sortedDesiredCollections) {
- let existingCollection =
- existingCollectionByPath.get(collectionPath) ??
- existingCollectionByStrippedPath.get(collectionPath);
- const segmentName = collectionPath.split("/").pop() ?? collectionPath;
- const targetName = humanizeName(segmentName);
- const parentPath = getParentPath(collectionPath);
- const expectedParentId = parentPath
- ? collectionPathToId.get(parentPath)
- : args.rootCollectionId;
-
- if (parentPath && !expectedParentId) {
- throw new Error(`Unable to resolve parent collection for "${collectionPath}"`);
- }
-
- if (!existingCollection) {
- const matchKey = buildCollectionNameMatchKey(expectedParentId, targetName);
- const nameCandidates =
- collectionCandidatesByName
- .get(matchKey)
- ?.filter((candidate) => !matchedCollectionIds.has(candidate._id)) ?? [];
- if (nameCandidates.length === 1) {
- existingCollection = nameCandidates[0];
- }
- }
-
- if (existingCollection) {
- const updates: {
- name?: string;
- slug?: string;
- parentId?: Id<"collections">;
- importPath?: string;
- importSourceId?: Id<"helpCenterImportSources">;
- updatedAt?: number;
- } = {};
-
- if (existingCollection.name !== targetName) {
- updates.name = targetName;
- if (!dryRun) {
- updates.slug = await ensureUniqueSlug(
- ctx.db,
- "collections",
- args.workspaceId,
- generateSlug(targetName),
- existingCollection._id
- );
- }
- }
-
- if (existingCollection.parentId !== expectedParentId) {
- updates.parentId = expectedParentId;
- }
-
- if (existingCollection.importPath !== collectionPath) {
- updates.importPath = collectionPath;
- }
-
- if (sourceId && existingCollection.importSourceId !== sourceId) {
- updates.importSourceId = sourceId;
- }
-
- if (Object.keys(updates).length > 0) {
- if (!dryRun) {
- updates.updatedAt = now;
- await ctx.db.patch(existingCollection._id, updates);
- }
- updatedCollections += 1;
- pushPreviewPath(updatedCollectionPaths, collectionPath);
- }
-
- matchedCollectionIds.add(existingCollection._id);
- collectionPathToId.set(collectionPath, existingCollection._id);
- continue;
- }
-
- if (dryRun) {
- const simulatedCollectionId = `dry-run:${collectionPath}` as unknown as Id<"collections">;
- collectionPathToId.set(collectionPath, simulatedCollectionId);
- } else {
- if (!sourceId) {
- throw new Error("Failed to resolve import source");
- }
- const slug = await ensureUniqueSlug(
- ctx.db,
- "collections",
- args.workspaceId,
- generateSlug(targetName)
- );
- const order = await getNextCollectionOrder(ctx, args.workspaceId, expectedParentId);
-
- const collectionId = await ctx.db.insert("collections", {
- workspaceId: args.workspaceId,
- name: targetName,
- slug,
- parentId: expectedParentId,
- order,
- importSourceId: sourceId,
- importPath: collectionPath,
- createdAt: now,
- updatedAt: now,
- });
-
- collectionPathToId.set(collectionPath, collectionId);
- matchedCollectionIds.add(collectionId);
- }
- createdCollections += 1;
- pushPreviewPath(createdCollectionPaths, collectionPath);
- }
-
- const sortedIncomingFiles = Array.from(incomingFiles.values()).sort((a, b) =>
- a.relativePath.localeCompare(b.relativePath)
- );
- const importRunId = `${now}-${Math.random().toString(36).slice(2, 8)}`;
-
- for (const file of sortedIncomingFiles) {
- let existingArticle =
- existingArticleByPath.get(file.relativePath) ??
- existingArticleByStrippedPath.get(file.relativePath);
- const collectionPath = resolveIncomingCollectionPath(file);
- const collectionId = collectionPath
- ? collectionPathToId.get(collectionPath)
- : args.rootCollectionId;
- if (collectionPath && !collectionId) {
- throw new Error(`Unable to resolve collection for file "${file.relativePath}"`);
- }
-
- const title = file.frontmatterTitle ?? inferTitle(file.relativePath, file.content);
- const preferredSlug = file.frontmatterSlug ?? generateSlug(title);
- if (!existingArticle) {
- const titleMatchKey = buildArticleTitleMatchKey(collectionId, title);
- const titleMatches =
- articleCandidatesByTitle
- .get(titleMatchKey)
- ?.filter((candidate) => !matchedArticleIds.has(candidate._id)) ?? [];
-
- if (titleMatches.length === 1) {
- existingArticle = titleMatches[0];
- } else if (titleMatches.length === 0) {
- const potentialSlugs = new Set([
- generateSlug(title),
- generateSlug(humanizeName(withoutMarkdownExtension(getFileName(file.relativePath)))),
- generateSlug(withoutMarkdownExtension(getFileName(file.relativePath))),
- ]);
- if (file.frontmatterSlug) {
- potentialSlugs.add(file.frontmatterSlug);
- }
-
- const slugMatches = new Map, (typeof workspaceArticles)[number]>();
- for (const slug of potentialSlugs) {
- const slugMatchKey = buildArticleSlugMatchKey(collectionId, slug);
- const slugCandidates =
- articleCandidatesBySlug
- .get(slugMatchKey)
- ?.filter((candidate) => !matchedArticleIds.has(candidate._id)) ?? [];
- for (const candidate of slugCandidates) {
- slugMatches.set(candidate._id, candidate);
- }
- }
-
- if (slugMatches.size === 1) {
- existingArticle = Array.from(slugMatches.values())[0];
- }
- }
- }
-
- if (existingArticle) {
- const updates: {
- title?: string;
- slug?: string;
- content?: string;
- collectionId?: Id<"collections">;
- status?: "draft" | "published";
- publishedAt?: number;
- importPath?: string;
- importSourceId?: Id<"helpCenterImportSources">;
- updatedAt?: number;
- } = {};
-
- if (existingArticle.title !== title) {
- updates.title = title;
- if (!dryRun) {
- updates.slug = await ensureUniqueSlug(
- ctx.db,
- "articles",
- args.workspaceId,
- preferredSlug,
- existingArticle._id
- );
- }
- }
-
- if (existingArticle.content !== file.content) {
- updates.content = file.content;
- }
-
- const targetCollectionPath = collectionPath ?? rootCollectionPathSentinel;
- const existingArticleCollectionPath = getExistingArticleCollectionPath(existingArticle);
- if (existingArticleCollectionPath !== targetCollectionPath) {
- updates.collectionId = collectionId;
- }
-
- if (publishByDefault && existingArticle.status !== "published") {
- updates.status = "published";
- updates.publishedAt = now;
- }
-
- if (existingArticle.importPath !== file.relativePath) {
- updates.importPath = file.relativePath;
- }
-
- if (sourceId && existingArticle.importSourceId !== sourceId) {
- updates.importSourceId = sourceId;
- }
-
- if (Object.keys(updates).length > 0) {
- if (!dryRun) {
- updates.updatedAt = now;
- await ctx.db.patch(existingArticle._id, updates);
- }
- updatedArticles += 1;
- pushPreviewPath(updatedArticlePaths, file.relativePath);
- }
- matchedArticleIds.add(existingArticle._id);
- continue;
- }
-
- if (!dryRun) {
- if (!sourceId) {
- throw new Error("Failed to resolve import source");
- }
- const order = await getNextArticleOrder(ctx, collectionId);
- const slug = await ensureUniqueSlug(
- ctx.db,
- "articles",
- args.workspaceId,
- preferredSlug
- );
- const articleId = await ctx.db.insert("articles", {
- workspaceId: args.workspaceId,
- collectionId,
- title,
- slug,
- content: file.content,
- status: publishByDefault ? "published" : "draft",
- order,
- importSourceId: sourceId,
- importPath: file.relativePath,
- createdAt: now,
- updatedAt: now,
- publishedAt: publishByDefault ? now : undefined,
- });
- matchedArticleIds.add(articleId);
- }
- createdArticles += 1;
- pushPreviewPath(createdArticlePaths, file.relativePath);
- }
-
- for (const article of existingArticles) {
- if (matchedArticleIds.has(article._id)) {
- continue;
- }
- if (!article.importPath) {
- continue;
- }
-
- if (!dryRun) {
- if (!sourceId) {
- throw new Error("Failed to resolve import source");
- }
- await ctx.db.insert("helpCenterImportArchives", {
- workspaceId: args.workspaceId,
- sourceId,
- importRunId,
- entityType: "article",
- importPath: article.importPath,
- parentPath: getDirectoryPath(article.importPath),
- name: article.title,
- content: article.content,
- status: article.status,
- deletedAt: now,
- });
- await ctx.db.delete(article._id);
- }
- deletedArticles += 1;
- deletedArticleIdsInRun.add(article._id);
- pushPreviewPath(deletedArticlePaths, article.importPath);
- }
-
- const collectionsToDelete = existingCollections
- .filter((collection) => collection.importPath && !matchedCollectionIds.has(collection._id))
- .sort((a, b) => pathDepth(b.importPath!) - pathDepth(a.importPath!));
-
- for (const collection of collectionsToDelete) {
- const childCollections = await ctx.db
- .query("collections")
- .withIndex("by_parent", (q) =>
- q.eq("workspaceId", args.workspaceId).eq("parentId", collection._id)
- )
- .collect();
- const effectiveChildCollections = childCollections.filter(
- (child) => !deletedCollectionIdsInRun.has(child._id)
- );
- const childArticles = await ctx.db
- .query("articles")
- .withIndex("by_collection", (q) => q.eq("collectionId", collection._id))
- .collect();
- const effectiveChildArticles = childArticles.filter(
- (child) => !deletedArticleIdsInRun.has(child._id)
- );
-
- if (effectiveChildCollections.length > 0 || effectiveChildArticles.length > 0) {
- continue;
- }
-
- if (!dryRun) {
- if (!sourceId) {
- throw new Error("Failed to resolve import source");
- }
- await ctx.db.insert("helpCenterImportArchives", {
- workspaceId: args.workspaceId,
- sourceId,
- importRunId,
- entityType: "collection",
- importPath: collection.importPath!,
- parentPath: getParentPath(collection.importPath!),
- name: collection.name,
- description: collection.description,
- icon: collection.icon,
- deletedAt: now,
- });
- await ctx.db.delete(collection._id);
- }
- deletedCollections += 1;
- deletedCollectionIdsInRun.add(collection._id);
- pushPreviewPath(deletedCollectionPaths, collection.importPath!);
- }
-
- if (!dryRun) {
- if (!sourceId) {
- throw new Error("Failed to resolve import source");
- }
- await ctx.db.patch(sourceId, {
- sourceName: args.sourceName,
- rootCollectionId: args.rootCollectionId,
- updatedAt: now,
- lastImportedAt: now,
- lastImportRunId: importRunId,
- lastImportedFileCount: incomingFiles.size + incomingAssets.size,
- lastImportedCollectionCount: desiredCollectionPaths.size,
- });
- }
-
- const sortPaths = (paths: string[]) => paths.slice().sort((a, b) => a.localeCompare(b));
-
- return {
- sourceId,
- sourceKey,
- sourceLabel: formatSourceLabel(args.sourceName, args.rootCollectionId),
- importRunId,
- dryRun,
- createdCollections,
- updatedCollections,
- createdArticles,
- updatedArticles,
- deletedArticles,
- deletedCollections,
- totalFiles: incomingFiles.size,
- totalAssets: incomingAssets.size,
- totalCollections: desiredCollectionPaths.size,
- strippedRootFolder: commonRootFolder ?? undefined,
- unresolvedImageReferences: Array.from(unresolvedImageReferenceSet).sort((a, b) =>
- a.localeCompare(b)
- ),
- preview: {
- collections: {
- create: sortPaths(createdCollectionPaths),
- update: sortPaths(updatedCollectionPaths),
- delete: sortPaths(deletedCollectionPaths),
- },
- articles: {
- create: sortPaths(createdArticlePaths),
- update: sortPaths(updatedArticlePaths),
- delete: sortPaths(deletedArticlePaths),
- },
- },
- };
- },
+ handler: runSyncMarkdownFolder,
});
export const listSources = authQuery({
@@ -1473,37 +37,7 @@ export const listSources = authQuery({
workspaceId: v.id("workspaces"),
},
permission: "articles.read",
- handler: async (ctx, args) => {
- const sources = await ctx.db
- .query("helpCenterImportSources")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
-
- const rootCollectionIds = Array.from(
- new Set(
- sources
- .map((source) => source.rootCollectionId)
- .filter((collectionId): collectionId is Id<"collections"> => Boolean(collectionId))
- )
- );
-
- const rootCollections = new Map, string>();
- for (const collectionId of rootCollectionIds) {
- const collection = await ctx.db.get(collectionId);
- if (collection) {
- rootCollections.set(collectionId, collection.name);
- }
- }
-
- return sources
- .sort((a, b) => b.updatedAt - a.updatedAt)
- .map((source) => ({
- ...source,
- rootCollectionName: source.rootCollectionId
- ? rootCollections.get(source.rootCollectionId)
- : undefined,
- }));
- },
+ handler: runListSources,
});
export const listHistory = authQuery({
@@ -1513,81 +47,7 @@ export const listHistory = authQuery({
limit: v.optional(v.number()),
},
permission: "articles.read",
- handler: async (ctx, args) => {
- const limit = Math.max(1, Math.min(args.limit ?? 25, 100));
- const sourceId = args.sourceId;
- const archives = sourceId
- ? await ctx.db
- .query("helpCenterImportArchives")
- .withIndex("by_workspace_source", (q) =>
- q.eq("workspaceId", args.workspaceId).eq("sourceId", sourceId)
- )
- .collect()
- : await ctx.db
- .query("helpCenterImportArchives")
- .withIndex("by_workspace_deleted_at", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
-
- const sourceMap = new Map();
- const sourceIds = Array.from(new Set(archives.map((entry) => entry.sourceId)));
- for (const sourceId of sourceIds) {
- const source = await ctx.db.get(sourceId);
- if (source) {
- sourceMap.set(sourceId, source.sourceName);
- }
- }
-
- const groups = new Map<
- string,
- {
- sourceId: Id<"helpCenterImportSources">;
- sourceName: string;
- importRunId: string;
- deletedAt: number;
- deletedArticles: number;
- deletedCollections: number;
- restoredEntries: number;
- totalEntries: number;
- }
- >();
-
- for (const entry of archives) {
- const groupKey = `${entry.sourceId}:${entry.importRunId}`;
- const existing = groups.get(groupKey);
- if (!existing) {
- groups.set(groupKey, {
- sourceId: entry.sourceId,
- sourceName: sourceMap.get(entry.sourceId) ?? "Unknown source",
- importRunId: entry.importRunId,
- deletedAt: entry.deletedAt,
- deletedArticles: entry.entityType === "article" ? 1 : 0,
- deletedCollections: entry.entityType === "collection" ? 1 : 0,
- restoredEntries: entry.restoredAt ? 1 : 0,
- totalEntries: 1,
- });
- continue;
- }
-
- existing.deletedAt = Math.max(existing.deletedAt, entry.deletedAt);
- if (entry.entityType === "article") {
- existing.deletedArticles += 1;
- } else {
- existing.deletedCollections += 1;
- }
- if (entry.restoredAt) {
- existing.restoredEntries += 1;
- }
- existing.totalEntries += 1;
- }
-
- return Array.from(groups.values())
- .sort((a, b) => b.deletedAt - a.deletedAt)
- .slice(0, limit)
- .map((group) => ({
- ...group,
- restorableEntries: group.totalEntries - group.restoredEntries,
- }));
- },
+ handler: runListHistory,
});
export const exportMarkdown = authQuery({
@@ -1598,192 +58,7 @@ export const exportMarkdown = authQuery({
includeDrafts: v.optional(v.boolean()),
},
permission: "data.export",
- handler: async (ctx, args) => {
- const includeDrafts = args.includeDrafts ?? true;
- const sourceId = args.sourceId;
- const rootCollectionId = args.rootCollectionId;
- const now = Date.now();
-
- let sourceName: string | undefined;
- if (sourceId) {
- const source = await ctx.db.get(sourceId);
- if (!source || source.workspaceId !== args.workspaceId) {
- throw new Error("Import source not found");
- }
- sourceName = source.sourceName;
- }
-
- const allCollections = await ctx.db
- .query("collections")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
-
- const collectionById = new Map(
- allCollections.map((collection) => [collection._id, collection] as const)
- );
- const childrenByParent = new Map>>();
- for (const collection of allCollections) {
- const parentKey = collection.parentId ?? "__root__";
- const existingChildren = childrenByParent.get(parentKey) ?? [];
- existingChildren.push(collection._id);
- childrenByParent.set(parentKey, existingChildren);
- }
-
- const descendantSet = new Set>();
- if (rootCollectionId) {
- const rootCollection = collectionById.get(rootCollectionId);
- if (!rootCollection) {
- throw new Error("Export root collection not found");
- }
- const stack: Array> = [rootCollectionId];
- while (stack.length > 0) {
- const current = stack.pop()!;
- if (descendantSet.has(current)) {
- continue;
- }
- descendantSet.add(current);
- const children = childrenByParent.get(current) ?? [];
- for (const child of children) {
- stack.push(child);
- }
- }
- }
-
- const cachedPathById = new Map, string>();
- const buildCollectionPath = (collectionId: Id<"collections">): string => {
- const cached = cachedPathById.get(collectionId);
- if (cached) {
- return cached;
- }
- const collection = collectionById.get(collectionId);
- if (!collection) {
- return "";
- }
- const segment = sanitizePathSegment(collection.name) || "collection";
- const parentPath = collection.parentId ? buildCollectionPath(collection.parentId) : "";
- const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
- cachedPathById.set(collectionId, fullPath);
- return fullPath;
- };
-
- let articles = await ctx.db
- .query("articles")
- .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
- .collect();
-
- if (!includeDrafts) {
- articles = articles.filter((article) => article.status === "published");
- }
- if (sourceId) {
- articles = articles.filter((article) => article.importSourceId === sourceId);
- }
- if (rootCollectionId) {
- articles = articles.filter(
- (article) => article.collectionId && descendantSet.has(article.collectionId)
- );
- }
-
- const usedPaths = new Set();
- const articleExports = articles
- .sort((a, b) => a.title.localeCompare(b.title))
- .map((article) => {
- const hasCollection = Boolean(article.collectionId);
- const collectionPath = hasCollection ? buildCollectionPath(article.collectionId!) : "";
- const frontmatterCollectionPath = hasCollection
- ? collectionPath || undefined
- : UNCATEGORIZED_COLLECTION_PATH;
- const fallbackPathBase = collectionPath
- ? `${collectionPath}/${sanitizePathSegment(article.slug || article.title) || "article"}`
- : sanitizePathSegment(article.slug || article.title) || "article";
- const preferredPath =
- sourceId && article.importPath ? article.importPath : fallbackPathBase;
- const markdownPath = dedupeRelativePath(preferredPath, usedPaths);
- return {
- article,
- markdownPath,
- frontmatterCollectionPath,
- };
- });
-
- const referencedAssetIds = new Set();
- for (const articleExport of articleExports) {
- const ids = extractAssetReferenceIds(articleExport.article.content);
- for (const id of ids) {
- referencedAssetIds.add(id);
- }
- }
-
- const assetPathById = new Map();
- const assetFiles: Array<{ path: string; assetUrl: string; type: "asset" }> = [];
- for (const assetId of referencedAssetIds) {
- const asset = await ctx.db.get(assetId as Id<"articleAssets">);
- if (!asset || asset.workspaceId !== args.workspaceId) {
- continue;
- }
-
- const fileNameStem =
- sanitizePathSegment(withoutMarkdownExtension(asset.fileName)) || `image-${asset._id}`;
- const extension = getFileExtension(asset.fileName) || ".bin";
- const preferredAssetPath = asset.importPath
- ? `_assets/${asset.importPath}`
- : `_assets/${fileNameStem}${extension}`;
- const dedupedAssetPath = dedupePath(preferredAssetPath, usedPaths);
- const assetUrl = await ctx.storage.getUrl(asset.storageId);
- if (!assetUrl) {
- continue;
- }
-
- assetPathById.set(assetId, dedupedAssetPath);
- assetFiles.push({
- path: dedupedAssetPath,
- assetUrl,
- type: "asset",
- });
- }
-
- const markdownFiles = articleExports.map((articleExport) => {
- const bodyWithPortableAssetRefs = rewriteAssetReferencesForExport(
- articleExport.article.content,
- articleExport.markdownPath,
- assetPathById
- );
- return {
- path: articleExport.markdownPath,
- type: "markdown" as const,
- content: buildFrontmatterContent({
- title: articleExport.article.title,
- slug: articleExport.article.slug,
- status: articleExport.article.status,
- updatedAt: articleExport.article.updatedAt,
- collectionPath: articleExport.frontmatterCollectionPath,
- sourceName,
- body: bodyWithPortableAssetRefs,
- }),
- };
- });
-
- const files = [...markdownFiles, ...assetFiles];
-
- const exportNameParts = ["help-center", "markdown"];
- if (sourceName) {
- exportNameParts.push(sanitizePathSegment(sourceName) || "source");
- } else if (rootCollectionId) {
- const rootCollection = collectionById.get(rootCollectionId);
- if (rootCollection) {
- exportNameParts.push(sanitizePathSegment(rootCollection.name) || "collection");
- }
- } else {
- exportNameParts.push("all");
- }
- exportNameParts.push(new Date(now).toISOString().slice(0, 10));
-
- return {
- exportedAt: now,
- count: files.length,
- fileName: `${exportNameParts.join("-")}.zip`,
- files,
- };
- },
+ handler: runExportMarkdown,
});
export const restoreRun = authMutation({
@@ -1793,201 +68,5 @@ export const restoreRun = authMutation({
importRunId: v.string(),
},
permission: "articles.create",
- handler: async (ctx, args) => {
- const source = await ctx.db.get(args.sourceId);
- if (!source || source.workspaceId !== args.workspaceId) {
- throw new Error("Import source not found");
- }
-
- const allEntries = await ctx.db
- .query("helpCenterImportArchives")
- .withIndex("by_workspace_source_run", (q) =>
- q
- .eq("workspaceId", args.workspaceId)
- .eq("sourceId", args.sourceId)
- .eq("importRunId", args.importRunId)
- )
- .collect();
-
- const entries = allEntries.filter((entry) => !entry.restoredAt);
- if (entries.length === 0) {
- return {
- restoredCollections: 0,
- restoredArticles: 0,
- };
- }
-
- const now = Date.now();
- const existingCollections = await ctx.db
- .query("collections")
- .withIndex("by_workspace_import_source", (q) =>
- q.eq("workspaceId", args.workspaceId).eq("importSourceId", args.sourceId)
- )
- .collect();
- const existingArticles = await ctx.db
- .query("articles")
- .withIndex("by_workspace_import_source", (q) =>
- q.eq("workspaceId", args.workspaceId).eq("importSourceId", args.sourceId)
- )
- .collect();
-
- const collectionPathToId = new Map>();
- for (const collection of existingCollections) {
- if (collection.importPath) {
- collectionPathToId.set(collection.importPath, collection._id);
- }
- }
-
- const articlePathToDoc = new Map(
- existingArticles
- .filter((article) => article.importPath)
- .map((article) => [article.importPath!, article] as const)
- );
-
- const collectionEntries = entries
- .filter((entry) => entry.entityType === "collection")
- .sort((a, b) => pathDepth(a.importPath) - pathDepth(b.importPath));
- const collectionArchiveByPath = new Map(
- collectionEntries.map((entry) => [entry.importPath, entry] as const)
- );
-
- const ensureCollectionPath = async (collectionPath: string): Promise> => {
- const existingId = collectionPathToId.get(collectionPath);
- if (existingId) {
- return existingId;
- }
-
- const archiveEntry = collectionArchiveByPath.get(collectionPath);
- const parentPath = archiveEntry?.parentPath ?? getParentPath(collectionPath);
- const parentId = parentPath
- ? await ensureCollectionPath(parentPath)
- : source.rootCollectionId;
- const collectionName =
- archiveEntry?.name ?? humanizeName(collectionPath.split("/").pop() ?? "");
- const slug = await ensureUniqueSlug(
- ctx.db,
- "collections",
- args.workspaceId,
- generateSlug(collectionName)
- );
- const order = await getNextCollectionOrder(ctx, args.workspaceId, parentId);
-
- const collectionId = await ctx.db.insert("collections", {
- workspaceId: args.workspaceId,
- name: collectionName,
- slug,
- description: archiveEntry?.description,
- icon: archiveEntry?.icon,
- parentId,
- order,
- importSourceId: args.sourceId,
- importPath: collectionPath,
- createdAt: now,
- updatedAt: now,
- });
- collectionPathToId.set(collectionPath, collectionId);
- return collectionId;
- };
-
- let restoredCollections = 0;
- for (const entry of collectionEntries) {
- if (collectionPathToId.has(entry.importPath)) {
- continue;
- }
- await ensureCollectionPath(entry.importPath);
- restoredCollections += 1;
- }
-
- let restoredArticles = 0;
- const articleEntries = entries
- .filter((entry) => entry.entityType === "article")
- .sort((a, b) => a.importPath.localeCompare(b.importPath));
- for (const entry of articleEntries) {
- const collectionPath = entry.parentPath ?? getDirectoryPath(entry.importPath);
- const collectionId = collectionPath
- ? await ensureCollectionPath(collectionPath)
- : source.rootCollectionId;
- const existingArticle = articlePathToDoc.get(entry.importPath);
-
- if (existingArticle) {
- const updates: {
- title?: string;
- slug?: string;
- content?: string;
- status?: "draft" | "published";
- collectionId?: Id<"collections">;
- publishedAt?: number;
- updatedAt?: number;
- } = {};
-
- if (existingArticle.title !== entry.name) {
- updates.title = entry.name;
- updates.slug = await ensureUniqueSlug(
- ctx.db,
- "articles",
- args.workspaceId,
- generateSlug(entry.name),
- existingArticle._id
- );
- }
- if (entry.content !== undefined && existingArticle.content !== entry.content) {
- updates.content = entry.content;
- }
- if (existingArticle.collectionId !== collectionId) {
- updates.collectionId = collectionId;
- }
- if (entry.status && existingArticle.status !== entry.status) {
- updates.status = entry.status;
- if (entry.status === "published") {
- updates.publishedAt = now;
- }
- }
-
- if (Object.keys(updates).length > 0) {
- updates.updatedAt = now;
- await ctx.db.patch(existingArticle._id, updates);
- restoredArticles += 1;
- }
- continue;
- }
-
- const slug = await ensureUniqueSlug(
- ctx.db,
- "articles",
- args.workspaceId,
- generateSlug(entry.name)
- );
- const order = await getNextArticleOrder(ctx, collectionId);
- await ctx.db.insert("articles", {
- workspaceId: args.workspaceId,
- collectionId,
- title: entry.name,
- slug,
- content: entry.content ?? "",
- status: entry.status ?? "published",
- order,
- importSourceId: args.sourceId,
- importPath: entry.importPath,
- createdAt: now,
- updatedAt: now,
- publishedAt: (entry.status ?? "published") === "published" ? now : undefined,
- });
- restoredArticles += 1;
- }
-
- for (const entry of entries) {
- await ctx.db.patch(entry._id, {
- restoredAt: now,
- });
- }
-
- await ctx.db.patch(args.sourceId, {
- updatedAt: now,
- });
-
- return {
- restoredCollections,
- restoredArticles,
- };
- },
+ handler: runRestoreRun,
});
diff --git a/packages/convex/convex/helpCenterImports/README.md b/packages/convex/convex/helpCenterImports/README.md
new file mode 100644
index 0000000..386718a
--- /dev/null
+++ b/packages/convex/convex/helpCenterImports/README.md
@@ -0,0 +1,30 @@
+# Help Center Import/Export Pipeline Modules
+
+This folder splits the Help Center markdown pipeline into explicit stages.
+
+## Stage Ownership
+
+- `markdownParse.ts`
+ - Frontmatter extraction and normalization.
+ - Import title inference and collection path resolution.
+- `referenceRewrite.ts`
+ - Markdown/HTML image reference rewrites for import.
+ - `oc-asset://` extraction and export-time relative rewrite.
+- `pathUtils.ts`
+ - Canonical path and slug normalization helpers shared by import/export.
+ - Preview/path-dedup helpers and import/export frontmatter assembly.
+- `syncPipeline.ts`
+ - Import apply orchestration (source upsert, collection/article reconciliation, archive records).
+- `exportPipeline.ts`
+ - Export package orchestration (article selection, asset path mapping, portable markdown generation).
+- `sourceQueries.ts`
+ - Source list and run-history query aggregation.
+- `restorePipeline.ts`
+ - Archive restore orchestration for deleted collections/articles.
+
+## Extension Rules
+
+- Keep parsing changes inside `markdownParse.ts`; avoid mixing them into DB orchestration.
+- Keep reference rewrite behavior in `referenceRewrite.ts` so import/export path logic stays consistent.
+- Reuse `pathUtils.ts` normalization helpers from both sync and export paths.
+- When adding new sync/export behavior, update `packages/convex/tests/helpCenterImports.test.ts` first.
diff --git a/packages/convex/convex/helpCenterImports/exportPipeline.ts b/packages/convex/convex/helpCenterImports/exportPipeline.ts
new file mode 100644
index 0000000..005b751
--- /dev/null
+++ b/packages/convex/convex/helpCenterImports/exportPipeline.ts
@@ -0,0 +1,209 @@
+import type { Id } from "../_generated/dataModel";
+import type { QueryCtx } from "../_generated/server";
+import {
+ UNCATEGORIZED_COLLECTION_PATH,
+ buildFrontmatterContent,
+ dedupePath,
+ dedupeRelativePath,
+ getFileExtension,
+ sanitizePathSegment,
+ withoutMarkdownExtension,
+} from "./pathUtils";
+import { extractAssetReferenceIds, rewriteAssetReferencesForExport } from "./referenceRewrite";
+
+export interface ExportMarkdownArgs {
+ workspaceId: Id<"workspaces">;
+ sourceId?: Id<"helpCenterImportSources">;
+ rootCollectionId?: Id<"collections">;
+ includeDrafts?: boolean;
+}
+
+export async function runExportMarkdown(
+ ctx: QueryCtx,
+ args: ExportMarkdownArgs
+) {
+ const includeDrafts = args.includeDrafts ?? true;
+ const sourceId = args.sourceId;
+ const rootCollectionId = args.rootCollectionId;
+ const now = Date.now();
+
+ let sourceName: string | undefined;
+ if (sourceId) {
+ const source = await ctx.db.get(sourceId);
+ if (!source || source.workspaceId !== args.workspaceId) {
+ throw new Error("Import source not found");
+ }
+ sourceName = source.sourceName;
+ }
+
+ const allCollections = await ctx.db
+ .query("collections")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .collect();
+
+ const collectionById = new Map(
+ allCollections.map((collection) => [collection._id, collection] as const)
+ );
+ const childrenByParent = new Map>>();
+ for (const collection of allCollections) {
+ const parentKey = collection.parentId ?? "__root__";
+ const existingChildren = childrenByParent.get(parentKey) ?? [];
+ existingChildren.push(collection._id);
+ childrenByParent.set(parentKey, existingChildren);
+ }
+
+ const descendantSet = new Set>();
+ if (rootCollectionId) {
+ const rootCollection = collectionById.get(rootCollectionId);
+ if (!rootCollection) {
+ throw new Error("Export root collection not found");
+ }
+ const stack: Array> = [rootCollectionId];
+ while (stack.length > 0) {
+ const current = stack.pop()!;
+ if (descendantSet.has(current)) {
+ continue;
+ }
+ descendantSet.add(current);
+ const children = childrenByParent.get(current) ?? [];
+ for (const child of children) {
+ stack.push(child);
+ }
+ }
+ }
+
+ const cachedPathById = new Map, string>();
+ const buildCollectionPath = (collectionId: Id<"collections">): string => {
+ const cached = cachedPathById.get(collectionId);
+ if (cached) {
+ return cached;
+ }
+ const collection = collectionById.get(collectionId);
+ if (!collection) {
+ return "";
+ }
+ const segment = sanitizePathSegment(collection.name) || "collection";
+ const parentPath = collection.parentId ? buildCollectionPath(collection.parentId) : "";
+ const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
+ cachedPathById.set(collectionId, fullPath);
+ return fullPath;
+ };
+
+ let articles = await ctx.db
+ .query("articles")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .collect();
+
+ if (!includeDrafts) {
+ articles = articles.filter((article) => article.status === "published");
+ }
+ if (sourceId) {
+ articles = articles.filter((article) => article.importSourceId === sourceId);
+ }
+ if (rootCollectionId) {
+ articles = articles.filter(
+ (article) => article.collectionId && descendantSet.has(article.collectionId)
+ );
+ }
+
+ const usedPaths = new Set();
+ const articleExports = articles
+ .sort((a, b) => a.title.localeCompare(b.title))
+ .map((article) => {
+ const hasCollection = Boolean(article.collectionId);
+ const collectionPath = hasCollection ? buildCollectionPath(article.collectionId!) : "";
+ const frontmatterCollectionPath = hasCollection
+ ? collectionPath || undefined
+ : UNCATEGORIZED_COLLECTION_PATH;
+ const fallbackPathBase = collectionPath
+ ? `${collectionPath}/${sanitizePathSegment(article.slug || article.title) || "article"}`
+ : sanitizePathSegment(article.slug || article.title) || "article";
+ const preferredPath =
+ sourceId && article.importPath ? article.importPath : fallbackPathBase;
+ const markdownPath = dedupeRelativePath(preferredPath, usedPaths);
+ return {
+ article,
+ markdownPath,
+ frontmatterCollectionPath,
+ };
+ });
+
+ const referencedAssetIds = new Set();
+ for (const articleExport of articleExports) {
+ const ids = extractAssetReferenceIds(articleExport.article.content);
+ for (const id of ids) {
+ referencedAssetIds.add(id);
+ }
+ }
+
+ const assetPathById = new Map();
+ const assetFiles: Array<{ path: string; assetUrl: string; type: "asset" }> = [];
+ for (const assetId of referencedAssetIds) {
+ const asset = await ctx.db.get(assetId as Id<"articleAssets">);
+ if (!asset || asset.workspaceId !== args.workspaceId) {
+ continue;
+ }
+
+ const fileNameStem =
+ sanitizePathSegment(withoutMarkdownExtension(asset.fileName)) || `image-${asset._id}`;
+ const extension = getFileExtension(asset.fileName) || ".bin";
+ const preferredAssetPath = asset.importPath
+ ? `_assets/${asset.importPath}`
+ : `_assets/${fileNameStem}${extension}`;
+ const dedupedAssetPath = dedupePath(preferredAssetPath, usedPaths);
+ const assetUrl = await ctx.storage.getUrl(asset.storageId);
+ if (!assetUrl) {
+ continue;
+ }
+
+ assetPathById.set(assetId, dedupedAssetPath);
+ assetFiles.push({
+ path: dedupedAssetPath,
+ assetUrl,
+ type: "asset",
+ });
+ }
+
+ const markdownFiles = articleExports.map((articleExport) => {
+ const bodyWithPortableAssetRefs = rewriteAssetReferencesForExport(
+ articleExport.article.content,
+ articleExport.markdownPath,
+ assetPathById
+ );
+ return {
+ path: articleExport.markdownPath,
+ type: "markdown" as const,
+ content: buildFrontmatterContent({
+ title: articleExport.article.title,
+ slug: articleExport.article.slug,
+ status: articleExport.article.status,
+ updatedAt: articleExport.article.updatedAt,
+ collectionPath: articleExport.frontmatterCollectionPath,
+ sourceName,
+ body: bodyWithPortableAssetRefs,
+ }),
+ };
+ });
+
+ const files = [...markdownFiles, ...assetFiles];
+
+ const exportNameParts = ["help-center", "markdown"];
+ if (sourceName) {
+ exportNameParts.push(sanitizePathSegment(sourceName) || "source");
+ } else if (rootCollectionId) {
+ const rootCollection = collectionById.get(rootCollectionId);
+ if (rootCollection) {
+ exportNameParts.push(sanitizePathSegment(rootCollection.name) || "collection");
+ }
+ } else {
+ exportNameParts.push("all");
+ }
+ exportNameParts.push(new Date(now).toISOString().slice(0, 10));
+
+ return {
+ exportedAt: now,
+ count: files.length,
+ fileName: `${exportNameParts.join("-")}.zip`,
+ files,
+ };
+}
diff --git a/packages/convex/convex/helpCenterImports/markdownParse.ts b/packages/convex/convex/helpCenterImports/markdownParse.ts
new file mode 100644
index 0000000..d42c104
--- /dev/null
+++ b/packages/convex/convex/helpCenterImports/markdownParse.ts
@@ -0,0 +1,139 @@
+import {
+ UNCATEGORIZED_COLLECTION_PATH,
+ getDirectoryPath,
+ getFileName,
+ humanizeName,
+ normalizePath,
+ normalizeSourceKey,
+ withoutMarkdownExtension,
+} from "./pathUtils";
+
+const UNCATEGORIZED_COLLECTION_ALIASES = new Set([
+ UNCATEGORIZED_COLLECTION_PATH,
+ "uncategorised",
+]);
+
+export function inferTitle(filePath: string, content: string): string {
+ const headingMatch = content.match(/^\s*#\s+(.+)$/m);
+ if (headingMatch && headingMatch[1]) {
+ return headingMatch[1].trim();
+ }
+ return humanizeName(withoutMarkdownExtension(getFileName(filePath)));
+}
+
+function parseFrontmatterValue(rawValue: string): string {
+ const value = rawValue.trim();
+ if (!value) {
+ return "";
+ }
+
+ if (value.startsWith('"') && value.endsWith('"')) {
+ try {
+ return JSON.parse(value) as string;
+ } catch {
+ return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
+ }
+ }
+
+ if (value.startsWith("'") && value.endsWith("'")) {
+ return value.slice(1, -1).replace(/''/g, "'");
+ }
+
+ return value;
+}
+
+function normalizeCollectionPathFromFrontmatter(
+ rawValue: string | undefined
+): string | null | undefined {
+ if (rawValue === undefined) {
+ return undefined;
+ }
+
+ const collectionPath = rawValue.trim();
+ if (!collectionPath) {
+ return null;
+ }
+
+ const normalized = normalizePath(collectionPath);
+ if (!normalized) {
+ return null;
+ }
+
+ if (UNCATEGORIZED_COLLECTION_ALIASES.has(normalized.toLowerCase())) {
+ return null;
+ }
+
+ return normalized;
+}
+
+export function parseMarkdownImportContent(content: string): {
+ body: string;
+ frontmatterTitle?: string;
+ frontmatterSlug?: string;
+ frontmatterCollectionPath?: string | null;
+} {
+ const normalizedContent = content.replace(/^\uFEFF/, "");
+ const lines = normalizedContent.split(/\r?\n/);
+ if (lines.length < 3 || lines[0]?.trim() !== "---") {
+ return { body: normalizedContent };
+ }
+
+ let frontmatterEndIndex = -1;
+ for (let index = 1; index < lines.length; index += 1) {
+ if (lines[index]?.trim() === "---") {
+ frontmatterEndIndex = index;
+ break;
+ }
+ }
+
+ if (frontmatterEndIndex === -1) {
+ return { body: normalizedContent };
+ }
+
+ const frontmatterEntries = new Map();
+ for (const line of lines.slice(1, frontmatterEndIndex)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) {
+ continue;
+ }
+
+ const separatorIndex = trimmed.indexOf(":");
+ if (separatorIndex <= 0) {
+ continue;
+ }
+
+ const key = trimmed.slice(0, separatorIndex).trim().toLowerCase();
+ const rawValue = trimmed.slice(separatorIndex + 1);
+ frontmatterEntries.set(key, parseFrontmatterValue(rawValue));
+ }
+
+ const body = lines
+ .slice(frontmatterEndIndex + 1)
+ .join("\n")
+ .replace(/^\s*\n/, "");
+ const frontmatterTitle = frontmatterEntries.get("title")?.trim() || undefined;
+ const rawSlug = frontmatterEntries.get("slug");
+ const normalizedSlug = rawSlug ? normalizeSourceKey(rawSlug) : "";
+
+ return {
+ body,
+ frontmatterTitle,
+ frontmatterSlug: normalizedSlug || undefined,
+ frontmatterCollectionPath: normalizeCollectionPathFromFrontmatter(
+ frontmatterEntries.get("collectionpath")
+ ),
+ };
+}
+
+export function resolveIncomingCollectionPath(file: {
+ relativePath: string;
+ frontmatterCollectionPath?: string | null;
+}): string | undefined {
+ if (file.frontmatterCollectionPath === null) {
+ return undefined;
+ }
+ if (typeof file.frontmatterCollectionPath === "string") {
+ return file.frontmatterCollectionPath;
+ }
+ return getDirectoryPath(file.relativePath);
+}
diff --git a/packages/convex/convex/helpCenterImports/pathUtils.ts b/packages/convex/convex/helpCenterImports/pathUtils.ts
new file mode 100644
index 0000000..37476e8
--- /dev/null
+++ b/packages/convex/convex/helpCenterImports/pathUtils.ts
@@ -0,0 +1,322 @@
+import type { Id } from "../_generated/dataModel";
+import type { MutationCtx } from "../_generated/server";
+
+export const MARKDOWN_EXTENSION_REGEX = /\.md(?:own)?$/i;
+export const IMAGE_EXTENSION_REGEX = /\.(png|jpe?g|gif|webp|avif)$/i;
+const ROOT_COLLECTION_MATCH_KEY = "__root__";
+export const UNCATEGORIZED_COLLECTION_PATH = "uncategorized";
+export const ASSET_REFERENCE_PREFIX = "oc-asset://";
+const SUPPORTED_IMPORT_IMAGE_MIME_TYPES = new Set([
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+ "image/avif",
+]);
+export const MAX_IMPORT_IMAGE_BYTES = 5 * 1024 * 1024;
+
+export function isSupportedImportMimeType(mimeType: string | undefined | null): boolean {
+ if (!mimeType) {
+ return false;
+ }
+ return SUPPORTED_IMPORT_IMAGE_MIME_TYPES.has(mimeType.toLowerCase());
+}
+
+export function normalizePath(path: string): string {
+ const normalized = path
+ .replace(/\\/g, "/")
+ .trim()
+ .replace(/^\/+|\/+$/g, "");
+ if (!normalized) {
+ return "";
+ }
+ const segments = normalized.split("/").filter(Boolean);
+ for (const segment of segments) {
+ if (segment === "." || segment === "..") {
+ throw new Error(`Invalid relative path segment "${segment}"`);
+ }
+ }
+ return segments.join("/");
+}
+
+export function normalizeSourceKey(sourceKey: string): string {
+ return sourceKey
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9:_-]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+function normalizeMatchText(value: string): string {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+export function buildCollectionNameMatchKey(
+ parentId: Id<"collections"> | undefined,
+ name: string
+): string {
+ return `${parentId ?? ROOT_COLLECTION_MATCH_KEY}::${normalizeMatchText(name)}`;
+}
+
+export function buildArticleTitleMatchKey(
+ collectionId: Id<"collections"> | undefined,
+ title: string
+): string {
+ return `${collectionId ?? ROOT_COLLECTION_MATCH_KEY}::${normalizeMatchText(title)}`;
+}
+
+export function buildArticleSlugMatchKey(
+ collectionId: Id<"collections"> | undefined,
+ slug: string
+): string {
+ return `${collectionId ?? ROOT_COLLECTION_MATCH_KEY}::${normalizeSourceKey(slug)}`;
+}
+
+export function addMapArrayValue(map: Map, key: K, value: V): void {
+ const existing = map.get(key);
+ if (existing) {
+ existing.push(value);
+ return;
+ }
+ map.set(key, [value]);
+}
+
+export function buildDefaultSourceKey(
+ sourceName: string,
+ rootCollectionId?: Id<"collections">
+): string {
+ const normalizedName = normalizeSourceKey(sourceName) || "import";
+ const collectionScope = rootCollectionId ? `collection:${rootCollectionId}` : "collection:root";
+ return `${collectionScope}:${normalizedName}`;
+}
+
+export function getParentPath(path: string): string | undefined {
+ const index = path.lastIndexOf("/");
+ if (index === -1) {
+ return undefined;
+ }
+ return path.slice(0, index);
+}
+
+export function getDirectoryPath(filePath: string): string | undefined {
+ const parentPath = getParentPath(filePath);
+ return parentPath && parentPath.length > 0 ? parentPath : undefined;
+}
+
+export function getFileName(path: string): string {
+ const index = path.lastIndexOf("/");
+ if (index === -1) {
+ return path;
+ }
+ return path.slice(index + 1);
+}
+
+export function getFileExtension(fileName: string): string {
+ const index = fileName.lastIndexOf(".");
+ if (index === -1) {
+ return "";
+ }
+ return fileName.slice(index).toLowerCase();
+}
+
+export function withoutMarkdownExtension(fileName: string): string {
+ return fileName.replace(/\.md(?:own)?$/i, "");
+}
+
+export function humanizeName(raw: string): string {
+ if (!raw) {
+ return "Untitled";
+ }
+ const words = raw
+ .replace(/[-_]+/g, " ")
+ .split(/\s+/)
+ .filter(Boolean)
+ .map((word) => word[0]?.toUpperCase() + word.slice(1));
+ return words.length > 0 ? words.join(" ") : "Untitled";
+}
+
+export function pathDepth(path: string): number {
+ return path.split("/").length;
+}
+
+export function getFirstPathSegment(path: string): string {
+ const index = path.indexOf("/");
+ return index === -1 ? path : path.slice(0, index);
+}
+
+export function stripFirstPathSegment(path: string): string | null {
+ const index = path.indexOf("/");
+ if (index === -1) {
+ return null;
+ }
+ return path.slice(index + 1);
+}
+
+export function detectCommonRootFolder(paths: string[]): string | null {
+ if (paths.length === 0) {
+ return null;
+ }
+ const firstSegments = new Set(paths.map(getFirstPathSegment).filter(Boolean));
+ const allNested = paths.every((path) => path.includes("/"));
+ if (firstSegments.size !== 1 || !allNested) {
+ return null;
+ }
+ return paths[0]!.split("/")[0]!;
+}
+
+export function stripSpecificRootFolder(path: string, rootFolder: string): string {
+ if (path === rootFolder) {
+ return "";
+ }
+ const prefix = `${rootFolder}/`;
+ if (path.startsWith(prefix)) {
+ return path.slice(prefix.length);
+ }
+ return path;
+}
+
+export function sanitizePathSegment(name: string): string {
+ return name
+ .trim()
+ .replace(/[<>:"/\\|?*]+/g, "-")
+ .split("")
+ .map((char) => (char.charCodeAt(0) < 32 ? "-" : char))
+ .join("")
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .toLowerCase();
+}
+
+function ensureMarkdownPath(path: string): string {
+ return MARKDOWN_EXTENSION_REGEX.test(path) ? path : `${path}.md`;
+}
+
+export function dedupePath(path: string, usedPaths: Set): string {
+ if (!usedPaths.has(path)) {
+ usedPaths.add(path);
+ return path;
+ }
+
+ const extensionIndex = path.lastIndexOf(".");
+ const hasExtension = extensionIndex > -1 && extensionIndex < path.length - 1;
+ const basePath = hasExtension ? path.slice(0, extensionIndex) : path;
+ const extension = hasExtension ? path.slice(extensionIndex) : "";
+
+ let attempt = 2;
+ while (attempt < 10_000) {
+ const candidate = `${basePath}-${attempt}${extension}`;
+ if (!usedPaths.has(candidate)) {
+ usedPaths.add(candidate);
+ return candidate;
+ }
+ attempt += 1;
+ }
+
+ throw new Error(`Failed to create unique export path for "${path}"`);
+}
+
+export function dedupeRelativePath(path: string, usedPaths: Set): string {
+ const normalized = ensureMarkdownPath(path);
+ return dedupePath(normalized, usedPaths);
+}
+
+export function getRelativePath(fromPath: string, toPath: string): string {
+ const fromDirSegments = (getDirectoryPath(fromPath) ?? "").split("/").filter(Boolean);
+ const toSegments = toPath.split("/").filter(Boolean);
+
+ let sharedPrefixLength = 0;
+ const maxShared = Math.min(fromDirSegments.length, toSegments.length);
+ while (
+ sharedPrefixLength < maxShared &&
+ fromDirSegments[sharedPrefixLength] === toSegments[sharedPrefixLength]
+ ) {
+ sharedPrefixLength += 1;
+ }
+
+ const upSegments = fromDirSegments.slice(sharedPrefixLength).map(() => "..");
+ const downSegments = toSegments.slice(sharedPrefixLength);
+ const parts = [...upSegments, ...downSegments];
+ return parts.length > 0 ? parts.join("/") : ".";
+}
+
+function yamlQuote(value: string): string {
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, " ")}"`;
+}
+
+export function buildFrontmatterContent(args: {
+ title: string;
+ slug: string;
+ status: "draft" | "published";
+ updatedAt: number;
+ collectionPath?: string;
+ sourceName?: string;
+ body: string;
+}): string {
+ const lines = [
+ "---",
+ `title: ${yamlQuote(args.title)}`,
+ `slug: ${yamlQuote(args.slug)}`,
+ `status: ${args.status}`,
+ `updatedAt: ${yamlQuote(new Date(args.updatedAt).toISOString())}`,
+ ];
+
+ if (args.collectionPath) {
+ lines.push(`collectionPath: ${yamlQuote(args.collectionPath)}`);
+ }
+ if (args.sourceName) {
+ lines.push(`source: ${yamlQuote(args.sourceName)}`);
+ }
+
+ lines.push("---", "", args.body);
+ return lines.join("\n");
+}
+
+export function addDirectoryAndParents(pathSet: Set, directoryPath: string) {
+ let cursor: string | undefined = directoryPath;
+ while (cursor) {
+ pathSet.add(cursor);
+ cursor = getParentPath(cursor);
+ }
+}
+
+export function pushPreviewPath(list: string[], path: string, maxEntries = 200): void {
+ if (list.length >= maxEntries) {
+ return;
+ }
+ list.push(path);
+}
+
+type DbCtx = Pick;
+
+export async function getNextCollectionOrder(
+ ctx: DbCtx,
+ workspaceId: Id<"workspaces">,
+ parentId: Id<"collections"> | undefined
+): Promise {
+ const siblings = await ctx.db
+ .query("collections")
+ .withIndex("by_parent", (q) => q.eq("workspaceId", workspaceId).eq("parentId", parentId))
+ .collect();
+ return siblings.reduce((max, sibling) => Math.max(max, sibling.order), 0) + 1;
+}
+
+export async function getNextArticleOrder(
+ ctx: DbCtx,
+ collectionId: Id<"collections"> | undefined
+): Promise {
+ const siblings = await ctx.db
+ .query("articles")
+ .withIndex("by_collection", (q) => q.eq("collectionId", collectionId))
+ .collect();
+ return siblings.reduce((max, sibling) => Math.max(max, sibling.order), 0) + 1;
+}
+
+export function formatSourceLabel(sourceName: string, rootCollectionId?: Id<"collections">): string {
+ return rootCollectionId ? `${sourceName} (${rootCollectionId})` : sourceName;
+}
diff --git a/packages/convex/convex/helpCenterImports/referenceRewrite.ts b/packages/convex/convex/helpCenterImports/referenceRewrite.ts
new file mode 100644
index 0000000..587a0b5
--- /dev/null
+++ b/packages/convex/convex/helpCenterImports/referenceRewrite.ts
@@ -0,0 +1,185 @@
+import { ASSET_REFERENCE_PREFIX, getDirectoryPath, getRelativePath } from "./pathUtils";
+
+const ASSET_REFERENCE_REGEX = /oc-asset:\/\/([A-Za-z0-9_-]+)/g;
+const MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
+const HTML_IMAGE_REGEX = /
]*?)\bsrc=(["'])([^"']+)\2([^>]*)>/gi;
+
+export function extractAssetReferenceIds(markdown: string): string[] {
+ const ids = new Set();
+ const matches = markdown.matchAll(ASSET_REFERENCE_REGEX);
+ for (const match of matches) {
+ const id = match[1];
+ if (id) {
+ ids.add(id);
+ }
+ }
+ return Array.from(ids);
+}
+
+function parseMarkdownImageTarget(target: string): {
+ path: string;
+ suffix: string;
+ wrappedInAngles: boolean;
+} | null {
+ const trimmed = target.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ if (trimmed.startsWith("<")) {
+ const end = trimmed.indexOf(">");
+ if (end <= 1) {
+ return null;
+ }
+ return {
+ path: trimmed.slice(1, end).trim(),
+ suffix: trimmed.slice(end + 1).trim(),
+ wrappedInAngles: true,
+ };
+ }
+
+ const [path, ...suffixParts] = trimmed.split(/\s+/);
+ if (!path) {
+ return null;
+ }
+ return {
+ path,
+ suffix: suffixParts.join(" ").trim(),
+ wrappedInAngles: false,
+ };
+}
+
+function buildMarkdownImageTarget(parsed: {
+ path: string;
+ suffix: string;
+ wrappedInAngles: boolean;
+}): string {
+ const path = parsed.wrappedInAngles ? `<${parsed.path}>` : parsed.path;
+ return parsed.suffix ? `${path} ${parsed.suffix}` : path;
+}
+
+function isExternalReference(path: string): boolean {
+ const normalized = path.trim().toLowerCase();
+ return (
+ normalized.startsWith("http://") ||
+ normalized.startsWith("https://") ||
+ normalized.startsWith("data:") ||
+ normalized.startsWith("javascript:") ||
+ normalized.startsWith("vbscript:") ||
+ normalized.startsWith("//") ||
+ normalized.startsWith("#")
+ );
+}
+
+function resolveReferencePath(markdownPath: string, reference: string): string | null {
+ const normalized = reference
+ .trim()
+ .replace(/\\/g, "/")
+ .replace(/^<|>$/g, "");
+ if (!normalized || isExternalReference(normalized)) {
+ return null;
+ }
+
+ const pathWithoutQuery = normalized.split("#")[0]?.split("?")[0] ?? "";
+ if (!pathWithoutQuery) {
+ return null;
+ }
+
+ const baseSegments = (getDirectoryPath(markdownPath) ?? "").split("/").filter(Boolean);
+ const referenceSegments = pathWithoutQuery.split("/").filter((segment) => segment.length > 0);
+ const resolvedSegments = [...baseSegments];
+
+ for (const segment of referenceSegments) {
+ if (segment === ".") {
+ continue;
+ }
+ if (segment === "..") {
+ if (resolvedSegments.length === 0) {
+ return null;
+ }
+ resolvedSegments.pop();
+ continue;
+ }
+ resolvedSegments.push(segment);
+ }
+
+ return resolvedSegments.join("/");
+}
+
+export function rewriteMarkdownImageReferences(
+ markdownContent: string,
+ markdownPath: string,
+ assetReferenceByPath: Map
+): { content: string; unresolvedReferences: string[] } {
+ const unresolved = new Set();
+
+ const withMarkdownLinksRewritten = markdownContent.replace(
+ MARKDOWN_IMAGE_REGEX,
+ (full, alt, target) => {
+ const parsed = parseMarkdownImageTarget(target);
+ if (!parsed || parsed.path.startsWith(ASSET_REFERENCE_PREFIX) || isExternalReference(parsed.path)) {
+ return full;
+ }
+
+ const resolvedPath = resolveReferencePath(markdownPath, parsed.path);
+ if (!resolvedPath) {
+ unresolved.add(`${markdownPath}: ${parsed.path}`);
+ return full;
+ }
+
+ const assetReference = assetReferenceByPath.get(resolvedPath);
+ if (!assetReference) {
+ unresolved.add(`${markdownPath}: ${parsed.path}`);
+ return full;
+ }
+
+ const rewrittenTarget = buildMarkdownImageTarget({
+ ...parsed,
+ path: assetReference,
+ });
+ return ``;
+ }
+ );
+
+ const withHtmlLinksRewritten = withMarkdownLinksRewritten.replace(
+ HTML_IMAGE_REGEX,
+ (full, before, quote, src, after) => {
+ if (src.startsWith(ASSET_REFERENCE_PREFIX) || isExternalReference(src)) {
+ return full;
+ }
+
+ const resolvedPath = resolveReferencePath(markdownPath, src);
+ if (!resolvedPath) {
+ unresolved.add(`${markdownPath}: ${src}`);
+ return full;
+ }
+
+ const assetReference = assetReferenceByPath.get(resolvedPath);
+ if (!assetReference) {
+ unresolved.add(`${markdownPath}: ${src}`);
+ return full;
+ }
+
+ return `
`;
+ }
+ );
+
+ return {
+ content: withHtmlLinksRewritten,
+ unresolvedReferences: Array.from(unresolved).sort((a, b) => a.localeCompare(b)),
+ };
+}
+
+export function rewriteAssetReferencesForExport(
+ markdownContent: string,
+ markdownPath: string,
+ assetPathById: Map
+): string {
+ return markdownContent.replace(ASSET_REFERENCE_REGEX, (fullMatch, id) => {
+ const assetPath = assetPathById.get(id);
+ if (!assetPath) {
+ return fullMatch;
+ }
+ return getRelativePath(markdownPath, assetPath);
+ });
+}
diff --git a/packages/convex/convex/helpCenterImports/restorePipeline.ts b/packages/convex/convex/helpCenterImports/restorePipeline.ts
new file mode 100644
index 0000000..bfa37f6
--- /dev/null
+++ b/packages/convex/convex/helpCenterImports/restorePipeline.ts
@@ -0,0 +1,215 @@
+import type { Id } from "../_generated/dataModel";
+import type { MutationCtx } from "../_generated/server";
+import { ensureUniqueSlug, generateSlug } from "../utils/strings";
+import {
+ getDirectoryPath,
+ getNextArticleOrder,
+ getNextCollectionOrder,
+ getParentPath,
+ humanizeName,
+ pathDepth,
+} from "./pathUtils";
+
+interface RestoreRunArgs {
+ workspaceId: Id<"workspaces">;
+ sourceId: Id<"helpCenterImportSources">;
+ importRunId: string;
+}
+
+export async function runRestoreRun(ctx: MutationCtx, args: RestoreRunArgs) {
+ const source = await ctx.db.get(args.sourceId);
+ if (!source || source.workspaceId !== args.workspaceId) {
+ throw new Error("Import source not found");
+ }
+
+ const allEntries = await ctx.db
+ .query("helpCenterImportArchives")
+ .withIndex("by_workspace_source_run", (q) =>
+ q
+ .eq("workspaceId", args.workspaceId)
+ .eq("sourceId", args.sourceId)
+ .eq("importRunId", args.importRunId)
+ )
+ .collect();
+
+ const entries = allEntries.filter((entry) => !entry.restoredAt);
+ if (entries.length === 0) {
+ return {
+ restoredCollections: 0,
+ restoredArticles: 0,
+ };
+ }
+
+ const now = Date.now();
+ const existingCollections = await ctx.db
+ .query("collections")
+ .withIndex("by_workspace_import_source", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("importSourceId", args.sourceId)
+ )
+ .collect();
+ const existingArticles = await ctx.db
+ .query("articles")
+ .withIndex("by_workspace_import_source", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("importSourceId", args.sourceId)
+ )
+ .collect();
+
+ const collectionPathToId = new Map>();
+ for (const collection of existingCollections) {
+ if (collection.importPath) {
+ collectionPathToId.set(collection.importPath, collection._id);
+ }
+ }
+
+ const articlePathToDoc = new Map(
+ existingArticles
+ .filter((article) => article.importPath)
+ .map((article) => [article.importPath!, article] as const)
+ );
+
+ const collectionEntries = entries
+ .filter((entry) => entry.entityType === "collection")
+ .sort((a, b) => pathDepth(a.importPath) - pathDepth(b.importPath));
+ const collectionArchiveByPath = new Map(
+ collectionEntries.map((entry) => [entry.importPath, entry] as const)
+ );
+
+ const ensureCollectionPath = async (collectionPath: string): Promise> => {
+ const existingId = collectionPathToId.get(collectionPath);
+ if (existingId) {
+ return existingId;
+ }
+
+ const archiveEntry = collectionArchiveByPath.get(collectionPath);
+ const parentPath = archiveEntry?.parentPath ?? getParentPath(collectionPath);
+ const parentId = parentPath
+ ? await ensureCollectionPath(parentPath)
+ : source.rootCollectionId;
+ const collectionName =
+ archiveEntry?.name ?? humanizeName(collectionPath.split("/").pop() ?? "");
+ const slug = await ensureUniqueSlug(
+ ctx.db,
+ "collections",
+ args.workspaceId,
+ generateSlug(collectionName)
+ );
+ const order = await getNextCollectionOrder(ctx, args.workspaceId, parentId);
+
+ const collectionId = await ctx.db.insert("collections", {
+ workspaceId: args.workspaceId,
+ name: collectionName,
+ slug,
+ description: archiveEntry?.description,
+ icon: archiveEntry?.icon,
+ parentId,
+ order,
+ importSourceId: args.sourceId,
+ importPath: collectionPath,
+ createdAt: now,
+ updatedAt: now,
+ });
+ collectionPathToId.set(collectionPath, collectionId);
+ return collectionId;
+ };
+
+ let restoredCollections = 0;
+ for (const entry of collectionEntries) {
+ if (collectionPathToId.has(entry.importPath)) {
+ continue;
+ }
+ await ensureCollectionPath(entry.importPath);
+ restoredCollections += 1;
+ }
+
+ let restoredArticles = 0;
+ const articleEntries = entries
+ .filter((entry) => entry.entityType === "article")
+ .sort((a, b) => a.importPath.localeCompare(b.importPath));
+ for (const entry of articleEntries) {
+ const collectionPath = entry.parentPath ?? getDirectoryPath(entry.importPath);
+ const collectionId = collectionPath
+ ? await ensureCollectionPath(collectionPath)
+ : source.rootCollectionId;
+ const existingArticle = articlePathToDoc.get(entry.importPath);
+
+ if (existingArticle) {
+ const updates: {
+ title?: string;
+ slug?: string;
+ content?: string;
+ status?: "draft" | "published";
+ collectionId?: Id<"collections">;
+ publishedAt?: number;
+ updatedAt?: number;
+ } = {};
+
+ if (existingArticle.title !== entry.name) {
+ updates.title = entry.name;
+ updates.slug = await ensureUniqueSlug(
+ ctx.db,
+ "articles",
+ args.workspaceId,
+ generateSlug(entry.name),
+ existingArticle._id
+ );
+ }
+ if (entry.content !== undefined && existingArticle.content !== entry.content) {
+ updates.content = entry.content;
+ }
+ if (existingArticle.collectionId !== collectionId) {
+ updates.collectionId = collectionId;
+ }
+ if (entry.status && existingArticle.status !== entry.status) {
+ updates.status = entry.status;
+ if (entry.status === "published") {
+ updates.publishedAt = now;
+ }
+ }
+
+ if (Object.keys(updates).length > 0) {
+ updates.updatedAt = now;
+ await ctx.db.patch(existingArticle._id, updates);
+ restoredArticles += 1;
+ }
+ continue;
+ }
+
+ const slug = await ensureUniqueSlug(
+ ctx.db,
+ "articles",
+ args.workspaceId,
+ generateSlug(entry.name)
+ );
+ const order = await getNextArticleOrder(ctx, collectionId);
+ await ctx.db.insert("articles", {
+ workspaceId: args.workspaceId,
+ collectionId,
+ title: entry.name,
+ slug,
+ content: entry.content ?? "",
+ status: entry.status ?? "published",
+ order,
+ importSourceId: args.sourceId,
+ importPath: entry.importPath,
+ createdAt: now,
+ updatedAt: now,
+ publishedAt: (entry.status ?? "published") === "published" ? now : undefined,
+ });
+ restoredArticles += 1;
+ }
+
+ for (const entry of entries) {
+ await ctx.db.patch(entry._id, {
+ restoredAt: now,
+ });
+ }
+
+ await ctx.db.patch(args.sourceId, {
+ updatedAt: now,
+ });
+
+ return {
+ restoredCollections,
+ restoredArticles,
+ };
+}
diff --git a/packages/convex/convex/helpCenterImports/sourceQueries.ts b/packages/convex/convex/helpCenterImports/sourceQueries.ts
new file mode 100644
index 0000000..597be25
--- /dev/null
+++ b/packages/convex/convex/helpCenterImports/sourceQueries.ts
@@ -0,0 +1,120 @@
+import type { Id } from "../_generated/dataModel";
+import type { QueryCtx } from "../_generated/server";
+
+interface ListSourcesArgs {
+ workspaceId: Id<"workspaces">;
+}
+
+interface ListHistoryArgs {
+ workspaceId: Id<"workspaces">;
+ sourceId?: Id<"helpCenterImportSources">;
+ limit?: number;
+}
+
+export async function runListSources(ctx: QueryCtx, args: ListSourcesArgs) {
+ const sources = await ctx.db
+ .query("helpCenterImportSources")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .collect();
+
+ const rootCollectionIds = Array.from(
+ new Set(
+ sources
+ .map((source) => source.rootCollectionId)
+ .filter((collectionId): collectionId is Id<"collections"> => Boolean(collectionId))
+ )
+ );
+
+ const rootCollections = new Map, string>();
+ for (const collectionId of rootCollectionIds) {
+ const collection = await ctx.db.get(collectionId);
+ if (collection) {
+ rootCollections.set(collectionId, collection.name);
+ }
+ }
+
+ return sources
+ .sort((a, b) => b.updatedAt - a.updatedAt)
+ .map((source) => ({
+ ...source,
+ rootCollectionName: source.rootCollectionId
+ ? rootCollections.get(source.rootCollectionId)
+ : undefined,
+ }));
+}
+
+export async function runListHistory(ctx: QueryCtx, args: ListHistoryArgs) {
+ const limit = Math.max(1, Math.min(args.limit ?? 25, 100));
+ const sourceId = args.sourceId;
+ const archives = sourceId
+ ? await ctx.db
+ .query("helpCenterImportArchives")
+ .withIndex("by_workspace_source", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("sourceId", sourceId)
+ )
+ .collect()
+ : await ctx.db
+ .query("helpCenterImportArchives")
+ .withIndex("by_workspace_deleted_at", (q) => q.eq("workspaceId", args.workspaceId))
+ .collect();
+
+ const sourceMap = new Map();
+ const sourceIds = Array.from(new Set(archives.map((entry) => entry.sourceId)));
+ for (const sourceId of sourceIds) {
+ const source = await ctx.db.get(sourceId);
+ if (source) {
+ sourceMap.set(sourceId, source.sourceName);
+ }
+ }
+
+ const groups = new Map<
+ string,
+ {
+ sourceId: Id<"helpCenterImportSources">;
+ sourceName: string;
+ importRunId: string;
+ deletedAt: number;
+ deletedArticles: number;
+ deletedCollections: number;
+ restoredEntries: number;
+ totalEntries: number;
+ }
+ >();
+
+ for (const entry of archives) {
+ const groupKey = `${entry.sourceId}:${entry.importRunId}`;
+ const existing = groups.get(groupKey);
+ if (!existing) {
+ groups.set(groupKey, {
+ sourceId: entry.sourceId,
+ sourceName: sourceMap.get(entry.sourceId) ?? "Unknown source",
+ importRunId: entry.importRunId,
+ deletedAt: entry.deletedAt,
+ deletedArticles: entry.entityType === "article" ? 1 : 0,
+ deletedCollections: entry.entityType === "collection" ? 1 : 0,
+ restoredEntries: entry.restoredAt ? 1 : 0,
+ totalEntries: 1,
+ });
+ continue;
+ }
+
+ existing.deletedAt = Math.max(existing.deletedAt, entry.deletedAt);
+ if (entry.entityType === "article") {
+ existing.deletedArticles += 1;
+ } else {
+ existing.deletedCollections += 1;
+ }
+ if (entry.restoredAt) {
+ existing.restoredEntries += 1;
+ }
+ existing.totalEntries += 1;
+ }
+
+ return Array.from(groups.values())
+ .sort((a, b) => b.deletedAt - a.deletedAt)
+ .slice(0, limit)
+ .map((group) => ({
+ ...group,
+ restorableEntries: group.totalEntries - group.restoredEntries,
+ }));
+}
diff --git a/packages/convex/convex/helpCenterImports/syncPipeline.ts b/packages/convex/convex/helpCenterImports/syncPipeline.ts
new file mode 100644
index 0000000..104a255
--- /dev/null
+++ b/packages/convex/convex/helpCenterImports/syncPipeline.ts
@@ -0,0 +1,871 @@
+import type { Id } from "../_generated/dataModel";
+import type { MutationCtx } from "../_generated/server";
+import { ensureUniqueSlug, generateSlug } from "../utils/strings";
+import { inferTitle, parseMarkdownImportContent, resolveIncomingCollectionPath } from "./markdownParse";
+import {
+ ASSET_REFERENCE_PREFIX,
+ IMAGE_EXTENSION_REGEX,
+ MARKDOWN_EXTENSION_REGEX,
+ MAX_IMPORT_IMAGE_BYTES,
+ addDirectoryAndParents,
+ addMapArrayValue,
+ buildArticleSlugMatchKey,
+ buildArticleTitleMatchKey,
+ buildCollectionNameMatchKey,
+ buildDefaultSourceKey,
+ detectCommonRootFolder,
+ formatSourceLabel,
+ getDirectoryPath,
+ getFileName,
+ getFirstPathSegment,
+ getNextArticleOrder,
+ getNextCollectionOrder,
+ getParentPath,
+ humanizeName,
+ isSupportedImportMimeType,
+ normalizePath,
+ normalizeSourceKey,
+ pathDepth,
+ pushPreviewPath,
+ stripFirstPathSegment,
+ stripSpecificRootFolder,
+ withoutMarkdownExtension,
+} from "./pathUtils";
+import { rewriteMarkdownImageReferences } from "./referenceRewrite";
+
+type SyncCtx = MutationCtx & { user: { _id: Id<"users"> } };
+
+export interface SyncMarkdownFolderArgs {
+ workspaceId: Id<"workspaces">;
+ sourceKey?: string;
+ sourceName: string;
+ rootCollectionId?: Id<"collections">;
+ files: Array<{
+ relativePath: string;
+ content: string;
+ }>;
+ assets?: Array<{
+ relativePath: string;
+ storageId?: Id<"_storage">;
+ mimeType?: string;
+ size?: number;
+ }>;
+ publishByDefault?: boolean;
+ dryRun?: boolean;
+}
+
+export async function runSyncMarkdownFolder(
+ ctx: SyncCtx,
+ args: SyncMarkdownFolderArgs
+) {
+ const now = Date.now();
+ const publishByDefault = args.publishByDefault ?? true;
+ const dryRun = args.dryRun ?? false;
+ const sourceKey = normalizeSourceKey(
+ args.sourceKey ?? buildDefaultSourceKey(args.sourceName, args.rootCollectionId)
+ );
+ if (!sourceKey) {
+ throw new Error("Import source key is required");
+ }
+
+ if (args.rootCollectionId) {
+ const rootCollection = await ctx.db.get(args.rootCollectionId);
+ if (!rootCollection || rootCollection.workspaceId !== args.workspaceId) {
+ throw new Error("Target collection not found");
+ }
+ }
+
+ const rawIncomingFiles = args.files
+ .map((file) => {
+ const parsedContent = parseMarkdownImportContent(file.content);
+ return {
+ relativePath: normalizePath(file.relativePath),
+ content: parsedContent.body,
+ frontmatterTitle: parsedContent.frontmatterTitle,
+ frontmatterSlug: parsedContent.frontmatterSlug,
+ frontmatterCollectionPath: parsedContent.frontmatterCollectionPath,
+ };
+ })
+ .filter((file) => Boolean(file.relativePath) && MARKDOWN_EXTENSION_REGEX.test(file.relativePath));
+
+ if (rawIncomingFiles.length === 0) {
+ throw new Error("No markdown files were found in this upload");
+ }
+
+ const rawIncomingAssets = (args.assets ?? [])
+ .map((asset) => ({
+ relativePath: normalizePath(asset.relativePath),
+ storageId: asset.storageId,
+ mimeType: asset.mimeType,
+ size: asset.size,
+ }))
+ .filter((asset) => Boolean(asset.relativePath) && IMAGE_EXTENSION_REGEX.test(asset.relativePath));
+
+ const commonRootFolder = detectCommonRootFolder(
+ [...rawIncomingFiles.map((file) => file.relativePath), ...rawIncomingAssets.map((asset) => asset.relativePath)]
+ );
+
+ const incomingFiles = new Map<
+ string,
+ {
+ relativePath: string;
+ originalPath: string;
+ content: string;
+ frontmatterTitle?: string;
+ frontmatterSlug?: string;
+ frontmatterCollectionPath?: string | null;
+ }
+ >();
+ for (const file of rawIncomingFiles) {
+ const normalizedPath = commonRootFolder
+ ? stripSpecificRootFolder(file.relativePath, commonRootFolder)
+ : file.relativePath;
+ if (!normalizedPath) {
+ continue;
+ }
+ if (incomingFiles.has(normalizedPath)) {
+ throw new Error(
+ `Import contains duplicate markdown path after root normalization: "${normalizedPath}"`
+ );
+ }
+ incomingFiles.set(normalizedPath, {
+ relativePath: normalizedPath,
+ originalPath: file.relativePath,
+ content: file.content,
+ frontmatterTitle: file.frontmatterTitle,
+ frontmatterSlug: file.frontmatterSlug,
+ frontmatterCollectionPath: file.frontmatterCollectionPath,
+ });
+ }
+
+ if (incomingFiles.size === 0) {
+ throw new Error("No markdown files remained after path normalization");
+ }
+
+ const incomingAssets = new Map<
+ string,
+ {
+ relativePath: string;
+ originalPath: string;
+ storageId?: Id<"_storage">;
+ mimeType?: string;
+ size?: number;
+ }
+ >();
+ for (const asset of rawIncomingAssets) {
+ const normalizedPath = commonRootFolder
+ ? stripSpecificRootFolder(asset.relativePath, commonRootFolder)
+ : asset.relativePath;
+ if (!normalizedPath) {
+ continue;
+ }
+ if (incomingAssets.has(normalizedPath)) {
+ throw new Error(
+ `Import contains duplicate image path after root normalization: "${normalizedPath}"`
+ );
+ }
+ incomingAssets.set(normalizedPath, {
+ relativePath: normalizedPath,
+ originalPath: asset.relativePath,
+ storageId: asset.storageId,
+ mimeType: asset.mimeType,
+ size: asset.size,
+ });
+ }
+
+ const existingSource = await ctx.db
+ .query("helpCenterImportSources")
+ .withIndex("by_workspace_source_key", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey)
+ )
+ .first();
+ let sourceId = existingSource?._id;
+
+ if (!sourceId && !dryRun) {
+ sourceId = await ctx.db.insert("helpCenterImportSources", {
+ workspaceId: args.workspaceId,
+ sourceKey,
+ sourceName: args.sourceName,
+ rootCollectionId: args.rootCollectionId,
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+
+ if (existingSource && !dryRun) {
+ await ctx.db.patch(existingSource._id, {
+ sourceName: args.sourceName,
+ rootCollectionId: args.rootCollectionId,
+ updatedAt: now,
+ });
+ }
+
+ const existingCollections = sourceId
+ ? await ctx.db
+ .query("collections")
+ .withIndex("by_workspace_import_source", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("importSourceId", sourceId!)
+ )
+ .collect()
+ : [];
+
+ const existingArticles = sourceId
+ ? await ctx.db
+ .query("articles")
+ .withIndex("by_workspace_import_source", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("importSourceId", sourceId!)
+ )
+ .collect()
+ : [];
+
+ const existingAssets = sourceId
+ ? await ctx.db
+ .query("articleAssets")
+ .withIndex("by_import_source", (q) => q.eq("importSourceId", sourceId!))
+ .collect()
+ : [];
+ const existingAssetByPath = new Map(
+ existingAssets
+ .filter((asset) => asset.importPath)
+ .map((asset) => [asset.importPath!, asset] as const)
+ );
+ const assetReferenceByPath = new Map();
+ for (const existingAsset of existingAssets) {
+ if (!existingAsset.importPath) {
+ continue;
+ }
+ assetReferenceByPath.set(
+ existingAsset.importPath,
+ `${ASSET_REFERENCE_PREFIX}${existingAsset._id}`
+ );
+ }
+
+ for (const incomingAsset of incomingAssets.values()) {
+ const existingAsset = existingAssetByPath.get(incomingAsset.relativePath);
+
+ if (dryRun) {
+ const syntheticReference =
+ existingAsset
+ ? `${ASSET_REFERENCE_PREFIX}${existingAsset._id}`
+ : `${ASSET_REFERENCE_PREFIX}dryrun-${normalizeSourceKey(incomingAsset.relativePath) || "asset"}`;
+ assetReferenceByPath.set(incomingAsset.relativePath, syntheticReference);
+ continue;
+ }
+
+ if (!sourceId) {
+ throw new Error("Failed to resolve import source");
+ }
+ if (!incomingAsset.storageId) {
+ throw new Error(
+ `Image "${incomingAsset.relativePath}" is missing storageId. Upload assets before applying import.`
+ );
+ }
+
+ const metadata = await ctx.storage.getMetadata(incomingAsset.storageId);
+ if (!metadata) {
+ throw new Error(`Uploaded image "${incomingAsset.relativePath}" was not found in storage.`);
+ }
+
+ const mimeType = (metadata.contentType ?? incomingAsset.mimeType ?? "").toLowerCase();
+ if (!isSupportedImportMimeType(mimeType)) {
+ throw new Error(
+ `Unsupported image type for "${incomingAsset.relativePath}". Allowed: PNG, JPEG, GIF, WEBP, AVIF.`
+ );
+ }
+ if (metadata.size > MAX_IMPORT_IMAGE_BYTES) {
+ throw new Error(`Image "${incomingAsset.relativePath}" exceeds the 5MB upload limit.`);
+ }
+
+ const nowTimestamp = Date.now();
+ if (existingAsset) {
+ if (existingAsset.storageId !== incomingAsset.storageId) {
+ await ctx.storage.delete(existingAsset.storageId);
+ }
+ await ctx.db.patch(existingAsset._id, {
+ storageId: incomingAsset.storageId,
+ fileName: getFileName(incomingAsset.relativePath),
+ mimeType,
+ size: metadata.size,
+ updatedAt: nowTimestamp,
+ });
+ assetReferenceByPath.set(
+ incomingAsset.relativePath,
+ `${ASSET_REFERENCE_PREFIX}${existingAsset._id}`
+ );
+ continue;
+ }
+
+ const createdAssetId = await ctx.db.insert("articleAssets", {
+ workspaceId: args.workspaceId,
+ importSourceId: sourceId,
+ importPath: incomingAsset.relativePath,
+ storageId: incomingAsset.storageId,
+ fileName: getFileName(incomingAsset.relativePath),
+ mimeType,
+ size: metadata.size,
+ createdBy: ctx.user._id,
+ createdAt: nowTimestamp,
+ updatedAt: nowTimestamp,
+ });
+ assetReferenceByPath.set(incomingAsset.relativePath, `${ASSET_REFERENCE_PREFIX}${createdAssetId}`);
+ }
+
+ const unresolvedImageReferenceSet = new Set();
+ for (const [path, file] of incomingFiles.entries()) {
+ const rewritten = rewriteMarkdownImageReferences(file.content, path, assetReferenceByPath);
+ for (const unresolved of rewritten.unresolvedReferences) {
+ unresolvedImageReferenceSet.add(unresolved);
+ }
+ incomingFiles.set(path, {
+ ...file,
+ content: rewritten.content,
+ });
+ }
+
+ const canMatchImportSource = (
+ importSourceId: Id<"helpCenterImportSources"> | undefined
+ ): boolean => {
+ if (!importSourceId) {
+ return true;
+ }
+ if (!sourceId) {
+ return false;
+ }
+ return importSourceId === sourceId;
+ };
+
+ const workspaceCollections = await ctx.db
+ .query("collections")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .collect();
+ const collectionCandidatesByName = new Map();
+ for (const collection of workspaceCollections) {
+ if (!canMatchImportSource(collection.importSourceId)) {
+ continue;
+ }
+ const nameKey = buildCollectionNameMatchKey(collection.parentId, collection.name);
+ addMapArrayValue(collectionCandidatesByName, nameKey, collection);
+ }
+
+ const workspaceArticles = await ctx.db
+ .query("articles")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .collect();
+ const articleCandidatesByTitle = new Map();
+ const articleCandidatesBySlug = new Map();
+ for (const article of workspaceArticles) {
+ if (!canMatchImportSource(article.importSourceId)) {
+ continue;
+ }
+ const titleKey = buildArticleTitleMatchKey(article.collectionId, article.title);
+ addMapArrayValue(articleCandidatesByTitle, titleKey, article);
+ const slugKey = buildArticleSlugMatchKey(article.collectionId, article.slug);
+ addMapArrayValue(articleCandidatesBySlug, slugKey, article);
+ }
+
+ const incomingTopLevelSegments = new Set(
+ Array.from(incomingFiles.keys()).map(getFirstPathSegment).filter(Boolean)
+ );
+
+ const existingCollectionByPath = new Map(
+ existingCollections
+ .filter((collection) => typeof collection.importPath === "string" && collection.importPath)
+ .map((collection) => [collection.importPath as string, collection] as const)
+ );
+ const existingCollectionByStrippedPath = new Map<
+ string,
+ (typeof existingCollections)[number]
+ >();
+ for (const collection of existingCollections) {
+ if (!collection.importPath) {
+ continue;
+ }
+ const strippedPath = stripFirstPathSegment(collection.importPath);
+ if (!strippedPath) {
+ continue;
+ }
+ const firstSegment = getFirstPathSegment(collection.importPath);
+ if (incomingTopLevelSegments.has(firstSegment)) {
+ continue;
+ }
+ if (
+ !existingCollectionByPath.has(strippedPath) &&
+ !existingCollectionByStrippedPath.has(strippedPath)
+ ) {
+ existingCollectionByStrippedPath.set(strippedPath, collection);
+ }
+ }
+
+ const existingArticleByPath = new Map(
+ existingArticles
+ .filter((article) => typeof article.importPath === "string" && article.importPath)
+ .map((article) => [article.importPath as string, article] as const)
+ );
+ const existingArticleByStrippedPath = new Map();
+ for (const article of existingArticles) {
+ if (!article.importPath) {
+ continue;
+ }
+ const strippedPath = stripFirstPathSegment(article.importPath);
+ if (!strippedPath) {
+ continue;
+ }
+ const firstSegment = getFirstPathSegment(article.importPath);
+ if (incomingTopLevelSegments.has(firstSegment)) {
+ continue;
+ }
+ if (
+ !existingArticleByPath.has(strippedPath) &&
+ !existingArticleByStrippedPath.has(strippedPath)
+ ) {
+ existingArticleByStrippedPath.set(strippedPath, article);
+ }
+ }
+
+ const desiredCollectionPaths = new Set();
+ for (const file of incomingFiles.values()) {
+ const directoryPath = resolveIncomingCollectionPath(file);
+ if (directoryPath) {
+ addDirectoryAndParents(desiredCollectionPaths, directoryPath);
+ }
+ }
+
+ const sortedDesiredCollections = Array.from(desiredCollectionPaths).sort((a, b) => {
+ const depthDelta = pathDepth(a) - pathDepth(b);
+ if (depthDelta !== 0) {
+ return depthDelta;
+ }
+ return a.localeCompare(b);
+ });
+
+ const collectionPathToId = new Map>();
+ for (const [path, collection] of existingCollectionByPath.entries()) {
+ collectionPathToId.set(path, collection._id);
+ }
+
+ let createdCollections = 0;
+ let updatedCollections = 0;
+ let createdArticles = 0;
+ let updatedArticles = 0;
+ let deletedArticles = 0;
+ let deletedCollections = 0;
+ const createdCollectionPaths: string[] = [];
+ const updatedCollectionPaths: string[] = [];
+ const deletedCollectionPaths: string[] = [];
+ const createdArticlePaths: string[] = [];
+ const updatedArticlePaths: string[] = [];
+ const deletedArticlePaths: string[] = [];
+ const matchedCollectionIds = new Set>();
+ const matchedArticleIds = new Set>();
+ const deletedArticleIdsInRun = new Set>();
+ const deletedCollectionIdsInRun = new Set>();
+
+ const rootCollectionPathSentinel = "__root__";
+ const existingCollectionPathById = new Map, string>();
+ for (const collection of existingCollections) {
+ if (collection.importPath) {
+ existingCollectionPathById.set(collection._id, collection.importPath);
+ }
+ }
+ const getExistingArticleCollectionPath = (
+ article: (typeof existingArticles)[number]
+ ): string | undefined => {
+ if (article.collectionId === args.rootCollectionId) {
+ return rootCollectionPathSentinel;
+ }
+ if (!article.collectionId && !args.rootCollectionId) {
+ return rootCollectionPathSentinel;
+ }
+ if (!article.collectionId) {
+ return undefined;
+ }
+ return existingCollectionPathById.get(article.collectionId);
+ };
+
+ for (const collectionPath of sortedDesiredCollections) {
+ let existingCollection =
+ existingCollectionByPath.get(collectionPath) ??
+ existingCollectionByStrippedPath.get(collectionPath);
+ const segmentName = collectionPath.split("/").pop() ?? collectionPath;
+ const targetName = humanizeName(segmentName);
+ const parentPath = getParentPath(collectionPath);
+ const expectedParentId = parentPath
+ ? collectionPathToId.get(parentPath)
+ : args.rootCollectionId;
+
+ if (parentPath && !expectedParentId) {
+ throw new Error(`Unable to resolve parent collection for "${collectionPath}"`);
+ }
+
+ if (!existingCollection) {
+ const matchKey = buildCollectionNameMatchKey(expectedParentId, targetName);
+ const nameCandidates =
+ collectionCandidatesByName
+ .get(matchKey)
+ ?.filter((candidate) => !matchedCollectionIds.has(candidate._id)) ?? [];
+ if (nameCandidates.length === 1) {
+ existingCollection = nameCandidates[0];
+ }
+ }
+
+ if (existingCollection) {
+ const updates: {
+ name?: string;
+ slug?: string;
+ parentId?: Id<"collections">;
+ importPath?: string;
+ importSourceId?: Id<"helpCenterImportSources">;
+ updatedAt?: number;
+ } = {};
+
+ if (existingCollection.name !== targetName) {
+ updates.name = targetName;
+ if (!dryRun) {
+ updates.slug = await ensureUniqueSlug(
+ ctx.db,
+ "collections",
+ args.workspaceId,
+ generateSlug(targetName),
+ existingCollection._id
+ );
+ }
+ }
+
+ if (existingCollection.parentId !== expectedParentId) {
+ updates.parentId = expectedParentId;
+ }
+
+ if (existingCollection.importPath !== collectionPath) {
+ updates.importPath = collectionPath;
+ }
+
+ if (sourceId && existingCollection.importSourceId !== sourceId) {
+ updates.importSourceId = sourceId;
+ }
+
+ if (Object.keys(updates).length > 0) {
+ if (!dryRun) {
+ updates.updatedAt = now;
+ await ctx.db.patch(existingCollection._id, updates);
+ }
+ updatedCollections += 1;
+ pushPreviewPath(updatedCollectionPaths, collectionPath);
+ }
+
+ matchedCollectionIds.add(existingCollection._id);
+ collectionPathToId.set(collectionPath, existingCollection._id);
+ continue;
+ }
+
+ if (dryRun) {
+ const simulatedCollectionId = `dry-run:${collectionPath}` as unknown as Id<"collections">;
+ collectionPathToId.set(collectionPath, simulatedCollectionId);
+ } else {
+ if (!sourceId) {
+ throw new Error("Failed to resolve import source");
+ }
+ const slug = await ensureUniqueSlug(
+ ctx.db,
+ "collections",
+ args.workspaceId,
+ generateSlug(targetName)
+ );
+ const order = await getNextCollectionOrder(ctx, args.workspaceId, expectedParentId);
+
+ const collectionId = await ctx.db.insert("collections", {
+ workspaceId: args.workspaceId,
+ name: targetName,
+ slug,
+ parentId: expectedParentId,
+ order,
+ importSourceId: sourceId,
+ importPath: collectionPath,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ collectionPathToId.set(collectionPath, collectionId);
+ matchedCollectionIds.add(collectionId);
+ }
+ createdCollections += 1;
+ pushPreviewPath(createdCollectionPaths, collectionPath);
+ }
+
+ const sortedIncomingFiles = Array.from(incomingFiles.values()).sort((a, b) =>
+ a.relativePath.localeCompare(b.relativePath)
+ );
+ const importRunId = `${now}-${Math.random().toString(36).slice(2, 8)}`;
+
+ for (const file of sortedIncomingFiles) {
+ let existingArticle =
+ existingArticleByPath.get(file.relativePath) ??
+ existingArticleByStrippedPath.get(file.relativePath);
+ const collectionPath = resolveIncomingCollectionPath(file);
+ const collectionId = collectionPath
+ ? collectionPathToId.get(collectionPath)
+ : args.rootCollectionId;
+ if (collectionPath && !collectionId) {
+ throw new Error(`Unable to resolve collection for file "${file.relativePath}"`);
+ }
+
+ const title = file.frontmatterTitle ?? inferTitle(file.relativePath, file.content);
+ const preferredSlug = file.frontmatterSlug ?? generateSlug(title);
+ if (!existingArticle) {
+ const titleMatchKey = buildArticleTitleMatchKey(collectionId, title);
+ const titleMatches =
+ articleCandidatesByTitle
+ .get(titleMatchKey)
+ ?.filter((candidate) => !matchedArticleIds.has(candidate._id)) ?? [];
+
+ if (titleMatches.length === 1) {
+ existingArticle = titleMatches[0];
+ } else if (titleMatches.length === 0) {
+ const potentialSlugs = new Set