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 `
      ${items.join("")}
    `; -} - -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> { - const supportSenderIds = Array.from( - new Set( - messages - .filter((message) => message.senderType === "agent" || message.senderType === "user") - .map((message) => message.senderId) - ) - ); - - const supportSenderEntries = await Promise.all( - supportSenderIds.map(async (senderId) => { - try { - const sender = (await ctx.db.get(senderId as Id<"users">)) as Doc<"users"> | null; - return [senderId, sender?.name ?? sender?.email ?? "Support"] as const; - } catch { - return [senderId, "Support"] as const; - } - }) - ); - - return new Map(supportSenderEntries); -} - -function renderConversationThreadHtml(args: { - messages: Doc<"messages">[]; - newMessageIds: Set>; - visitorLabel: string; - supportSenderLabels: Map; -}): string { - if (args.messages.length === 0) { - return "

    No message content available.

    "; - } - - const items = args.messages.map((message) => { - const visitorSide = message.senderType === "visitor"; - const senderLabel = visitorSide - ? args.visitorLabel - : getSupportSenderLabel(message, args.supportSenderLabels); - const createdAt = formatEmailTimestamp(message.createdAt); - const content = formatMessageContentForEmail(message.content); - const isNewMessage = args.newMessageIds.has(message._id); - const bubbleBg = visitorSide ? "#eef2ff" : "#111827"; - const bubbleFg = visitorSide ? "#1f2937" : "#ffffff"; - - return ` - - -
    -

    - ${escapeHtml(senderLabel)} · ${escapeHtml(createdAt)}${isNewMessage ? " · New" : ""} -

    -

    ${content}

    -
    - - - `; - }); - - return `${items.join( - "" - )}
    `; -} - -type NotifyNewMessageMode = "send_member_email" | "send_visitor_email"; - -function isSupportSenderType(senderType: string): boolean { - return senderType === "agent" || senderType === "bot"; -} - -function isRelevantMessageForMode(message: Doc<"messages">, mode: NotifyNewMessageMode): boolean { - if (mode === "send_member_email") { - return message.senderType === "visitor"; - } - - return isSupportSenderType(message.senderType); -} - -function buildDebouncedEmailBatch(args: { - recentMessagesDesc: Doc<"messages">[]; - mode: NotifyNewMessageMode; - triggerMessageId: Id<"messages"> | undefined; - triggerSentAt: number; -}): Doc<"messages">[] { - const latestRelevant = args.recentMessagesDesc.find((message) => - isRelevantMessageForMode(message, args.mode) - ); - - if (!latestRelevant) { - return []; - } - - if (args.triggerMessageId) { - if (latestRelevant._id !== args.triggerMessageId) { - return []; - } - } else if (latestRelevant.createdAt > args.triggerSentAt) { - return []; - } - - const batchDesc: Doc<"messages">[] = []; - let collecting = false; - - for (const message of args.recentMessagesDesc) { - if (!collecting) { - if (message._id !== latestRelevant._id) { - continue; - } - collecting = true; - } - - if (!isRelevantMessageForMode(message, args.mode)) { - break; - } - - if (batchDesc.length > 0) { - const previousMessage = batchDesc[batchDesc.length - 1]; - if (previousMessage.createdAt - message.createdAt > EMAIL_DEBOUNCE_MS) { - break; - } - } - - batchDesc.push(message); - - if (batchDesc.length >= MAX_BATCH_MESSAGES) { - break; - } - } - - return batchDesc.reverse(); -} - -function buildVisitorWebsiteUrl(visitor: Doc<"visitors"> | null): string | null { - return normalizeHttpUrl(visitor?.currentUrl); -} - -async function resolveSupportSender( - ctx: any, - message: Doc<"messages"> -): Promise<{ name: string; email: string | null }> { - if (message.senderType === "bot") { - return { name: "Support bot", email: null }; - } - - if (message.senderType !== "agent") { - return { name: "Support", email: null }; - } - - try { - const sender = (await ctx.db.get(message.senderId as Id<"users">)) as Doc<"users"> | null; - if (!sender) { - return { name: "Support", email: null }; - } - return { - name: sender.name ?? sender.email ?? "Support", - email: sender.email ?? null, - }; - } catch { - return { name: "Support", email: null }; - } -} - -export const notifyNewMessage = internalMutation({ - args: { - conversationId: v.id("conversations"), - messageContent: v.string(), - senderType: v.string(), - messageId: v.optional(v.id("messages")), - senderId: v.optional(v.string()), - sentAt: v.optional(v.number()), - channel: v.optional(v.union(v.literal("chat"), v.literal("email"))), - mode: v.optional(v.union(v.literal("send_member_email"), v.literal("send_visitor_email"))), - }, - handler: async (ctx, args) => { - const conversation = (await ctx.db.get(args.conversationId)) as Doc<"conversations"> | null; - if (!conversation) return; - const triggerSentAt = args.sentAt ?? Date.now(); - const truncatedContent = - args.messageContent.length > 100 - ? args.messageContent.slice(0, 100) + "..." - : args.messageContent; - - if (args.mode === "send_member_email" || args.mode === "send_visitor_email") { - const recentMessagesDesc = await ctx.db - .query("messages") - .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) - .order("desc") - .take(200); - - const batchMessages = buildDebouncedEmailBatch({ - recentMessagesDesc: recentMessagesDesc as Doc<"messages">[], - mode: args.mode, - triggerMessageId: args.messageId, - triggerSentAt, - }); - - if (batchMessages.length === 0) { - return; - } - - const recentThreadMessages = recentMessagesDesc - .slice(0, MAX_THREAD_MESSAGES) - .reverse() as Doc<"messages">[]; - const newMessageIds = new Set(batchMessages.map((message) => message._id)); - const visitor = conversation.visitorId - ? ((await ctx.db.get(conversation.visitorId)) as Doc<"visitors"> | null) - : null; - const visitorLabel = visitor?.name ?? visitor?.email ?? visitor?.readableId ?? "Visitor"; - const supportSenderLabels = await resolveSupportSenderLabels(ctx, recentThreadMessages); - const threadHtml = renderConversationThreadHtml({ - messages: recentThreadMessages, - newMessageIds, - visitorLabel, - supportSenderLabels, - }); - - if (args.mode === "send_member_email") { - const recipients = await ctx.runQuery( - internal.notifications.getMemberRecipientsForNewVisitorMessage, - { - workspaceId: conversation.workspaceId, - } - ); - - if (recipients.emailRecipients.length === 0) { - return; - } - - const senderName = visitor?.name ?? visitor?.email ?? "Visitor"; - const senderEmail = visitor?.email ?? null; - const senderReadableId = visitor?.readableId ?? null; - const sentAtLabel = formatEmailTimestamp(batchMessages[batchMessages.length - 1].createdAt); - const conversationInboxUrl = buildAdminConversationInboxUrl(args.conversationId); - const openConversationHtml = conversationInboxUrl - ? `

    Open this conversation in OpenCom inbox

    ` - : ""; - const detailsHtml = renderMetadataList([ - { label: "Sender", value: senderName }, - { label: "Sender email", value: senderEmail }, - { label: "Visitor ID", value: senderReadableId }, - { label: "Sent at", value: sentAtLabel }, - { label: "Conversation ID", value: String(args.conversationId) }, - { label: "Message count", value: String(batchMessages.length) }, - { label: "Channel", value: args.channel ?? conversation.channel ?? "chat" }, - ]); - const subject = - batchMessages.length > 1 - ? `${batchMessages.length} new messages from ${senderName}` - : `New message from ${senderName}`; - - for (const recipient of recipients.emailRecipients) { - await ctx.scheduler.runAfter(0, internal.notifications.sendNotificationEmail, { - to: recipient, - subject, - html: `

    You have new visitor message activity.

    ${openConversationHtml}${detailsHtml}

    Recent conversation (last ${MAX_THREAD_MESSAGES} messages)

    ${threadHtml}`, - }); - } - - return; - } - - if (!conversation.visitorId) { - return; - } - - const visitorRecipients = await ctx.runQuery( - internal.notifications.getVisitorRecipientsForSupportReply, - { - conversationId: args.conversationId, - channel: args.channel, - } - ); - - if (!visitorRecipients.emailRecipient) { - return; - } - - const visitorWebsiteUrl = buildVisitorWebsiteUrl(visitor); - const openWebsiteChatHtml = visitorWebsiteUrl - ? `

    Open chat on the website

    ` - : ""; - const latestSupportMessage = batchMessages[batchMessages.length - 1]; - const supportSender = await resolveSupportSender(ctx, latestSupportMessage); - const sentAtLabel = formatEmailTimestamp(latestSupportMessage.createdAt); - const detailsHtml = renderMetadataList([ - { label: "Sender", value: supportSender.name }, - { label: "Sender email", value: supportSender.email }, - { label: "Sent at", value: sentAtLabel }, - { label: "Conversation ID", value: String(args.conversationId) }, - { label: "Message count", value: String(batchMessages.length) }, - ]); - const subject = - batchMessages.length > 1 - ? `${batchMessages.length} new messages from support` - : "New message from support"; - - await ctx.scheduler.runAfter(0, internal.notifications.sendNotificationEmail, { - to: visitorRecipients.emailRecipient, - subject, - html: `

    You have new messages from support.

    ${openWebsiteChatHtml}${detailsHtml}

    Recent conversation (last ${MAX_THREAD_MESSAGES} messages)

    ${threadHtml}`, - }); - - return; - } - - // If message is from visitor, notify agents - if (args.senderType === "visitor") { - const recipients = await ctx.runQuery( - internal.notifications.getMemberRecipientsForNewVisitorMessage, - { - workspaceId: conversation.workspaceId, - } - ); - - if (recipients.emailRecipients.length > 0 || recipients.pushRecipients.length > 0) { - let visitor: Doc<"visitors"> | null = null; - let senderName = "Visitor"; - if (conversation.visitorId) { - visitor = (await ctx.db.get(conversation.visitorId)) as Doc<"visitors"> | null; - if (visitor?.name) { - senderName = visitor.name; - } else if (visitor?.email) { - senderName = visitor.email; - } - } - - await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { - eventType: "chat_message", - domain: "chat", - audience: "agent", - workspaceId: conversation.workspaceId, - actorType: "visitor", - actorVisitorId: conversation.visitorId ?? undefined, - conversationId: args.conversationId, - title: `New message from ${senderName}`, - body: truncatedContent, - data: { - conversationId: args.conversationId, - type: "new_message", - }, - ...(args.messageId ? { eventKey: `chat_message:${args.messageId}` } : {}), - }); - - if (recipients.emailRecipients.length > 0) { - await ctx.scheduler.runAfter(EMAIL_DEBOUNCE_MS, internal.notifications.notifyNewMessage, { - conversationId: args.conversationId, - messageContent: args.messageContent, - senderType: args.senderType, - messageId: args.messageId, - senderId: args.senderId, - sentAt: triggerSentAt, - channel: args.channel, - mode: "send_member_email", - }); - } - } - } - - // If message is from agent/bot, notify visitor via Mobile SDK push and email - if (isSupportSenderType(args.senderType)) { - if (conversation.visitorId) { - const visitorRecipients = await ctx.runQuery( - internal.notifications.getVisitorRecipientsForSupportReply, - { - conversationId: args.conversationId, - channel: args.channel, - } - ); - - await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { - eventType: "chat_message", - domain: "chat", - audience: "visitor", - workspaceId: conversation.workspaceId, - actorType: args.senderType === "bot" ? "bot" : "agent", - ...(args.senderType === "agent" && args.senderId - ? { actorUserId: args.senderId as Id<"users"> } - : {}), - conversationId: args.conversationId, - title: "Support", - body: truncatedContent, - data: { - conversationId: args.conversationId, - type: "new_message", - }, - recipientVisitorIds: conversation.visitorId ? [conversation.visitorId] : undefined, - ...(args.messageId ? { eventKey: `chat_message:${args.messageId}` } : {}), - }); - - if (visitorRecipients.emailRecipient) { - await ctx.scheduler.runAfter(EMAIL_DEBOUNCE_MS, internal.notifications.notifyNewMessage, { - conversationId: args.conversationId, - messageContent: args.messageContent, - senderType: args.senderType, - messageId: args.messageId, - senderId: args.senderId, - sentAt: triggerSentAt, - channel: args.channel, - mode: "send_visitor_email", - }); - } - } - } - }, -}); - -export const notifyNewConversation = internalMutation({ - args: { - conversationId: v.id("conversations"), - }, - handler: async (ctx, args) => { - const conversation = (await ctx.db.get(args.conversationId)) as Doc<"conversations"> | null; - if (!conversation) return; - - let visitorInfo = "New visitor"; - if (conversation.visitorId) { - const visitor = (await ctx.db.get(conversation.visitorId)) as Doc<"visitors"> | null; - if (visitor?.name) { - visitorInfo = visitor.name; - } else if (visitor?.email) { - visitorInfo = visitor.email; - } - } - - await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { - eventType: "new_conversation", - domain: "chat", - audience: "agent", - workspaceId: conversation.workspaceId, - actorType: "visitor", - actorVisitorId: conversation.visitorId ?? undefined, - conversationId: args.conversationId, - title: "New conversation", - body: `${visitorInfo} started a conversation`, - data: { - conversationId: args.conversationId, - type: "new_conversation", - }, - eventKey: `new_conversation:${args.conversationId}`, - }); - }, -}); - -export const notifyAssignment = internalMutation({ - args: { - conversationId: v.id("conversations"), - assignedAgentId: v.id("users"), - actorUserId: v.optional(v.id("users")), - }, - handler: async (ctx, args) => { - const conversation = (await ctx.db.get(args.conversationId)) as Doc<"conversations"> | null; - if (!conversation) return; - - let visitorInfo = "a visitor"; - if (conversation.visitorId) { - const visitor = (await ctx.db.get(conversation.visitorId)) as Doc<"visitors"> | null; - if (visitor?.name) { - visitorInfo = visitor.name; - } else if (visitor?.email) { - visitorInfo = visitor.email; - } - } - - await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { - eventType: "assignment", - domain: "chat", - audience: "agent", - workspaceId: conversation.workspaceId, - actorType: args.actorUserId ? "agent" : "system", - actorUserId: args.actorUserId, - conversationId: args.conversationId, - title: "Conversation assigned", - body: `You've been assigned a conversation with ${visitorInfo}`, - data: { - conversationId: args.conversationId, - type: "assignment", - }, - recipientUserIds: [args.assignedAgentId], - eventKey: `assignment:${args.conversationId}:${args.assignedAgentId}`, - }); - }, -}); - -export const notifyTicketCreated = internalMutation({ - args: { - ticketId: v.id("tickets"), - }, - handler: async (ctx, args) => { - const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; - if (!ticket) return; - - let visitorInfo = "A customer"; - if (ticket.visitorId) { - const visitor = (await ctx.db.get(ticket.visitorId)) as Doc<"visitors"> | null; - if (visitor?.name) { - visitorInfo = visitor.name; - } else if (visitor?.email) { - visitorInfo = visitor.email; - } - } - - await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { - eventType: "ticket_created", - domain: "ticket", - audience: "agent", - workspaceId: ticket.workspaceId, - actorType: ticket.visitorId ? "visitor" : "system", - actorVisitorId: ticket.visitorId ?? undefined, - ticketId: args.ticketId, - title: "New ticket created", - body: `${visitorInfo} submitted: ${ticket.subject}`, - data: { - ticketId: args.ticketId, - type: "ticket_created", - }, - eventKey: `ticket_created:${args.ticketId}`, - }); - }, -}); - -export const notifyTicketStatusChanged = internalMutation({ - args: { - ticketId: v.id("tickets"), - oldStatus: v.string(), - newStatus: v.string(), - actorUserId: v.optional(v.id("users")), - changedAt: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; - if (!ticket || !ticket.visitorId) return; - - await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { - eventType: "ticket_status_changed", - domain: "ticket", - audience: "visitor", - workspaceId: ticket.workspaceId, - actorType: args.actorUserId ? "agent" : "system", - actorUserId: args.actorUserId, - ticketId: args.ticketId, - title: "Ticket update", - body: `Your ticket \"${ticket.subject}\" moved to ${args.newStatus.replaceAll("_", " ")}.`, - data: { - ticketId: args.ticketId, - type: "ticket_status_changed", - oldStatus: args.oldStatus, - newStatus: args.newStatus, - }, - recipientVisitorIds: [ticket.visitorId], - eventKey: `ticket_status_changed:${args.ticketId}:${args.newStatus}:${args.changedAt ?? Date.now()}`, - }); - }, -}); - -export const notifyTicketAssigned = internalMutation({ - args: { - ticketId: v.id("tickets"), - assigneeId: v.id("users"), - actorUserId: v.optional(v.id("users")), - }, - handler: async (ctx, args) => { - const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; - if (!ticket) return; - - let visitorInfo = "a customer"; - if (ticket.visitorId) { - const visitor = (await ctx.db.get(ticket.visitorId)) as Doc<"visitors"> | null; - if (visitor?.name) { - visitorInfo = visitor.name; - } else if (visitor?.email) { - visitorInfo = visitor.email; - } - } - - await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { - eventType: "ticket_assigned", - domain: "ticket", - audience: "agent", - workspaceId: ticket.workspaceId, - actorType: args.actorUserId ? "agent" : "system", - actorUserId: args.actorUserId, - ticketId: args.ticketId, - title: "Ticket assigned", - body: `You've been assigned a ticket from ${visitorInfo}: ${ticket.subject}`, - data: { - ticketId: args.ticketId, - type: "ticket_assigned", - }, - recipientUserIds: [args.assigneeId], - eventKey: `ticket_assigned:${args.ticketId}:${args.assigneeId}`, - }); - }, -}); - -export const notifyTicketComment = internalMutation({ - args: { - ticketId: v.id("tickets"), - commentId: v.id("ticketComments"), - actorUserId: v.optional(v.id("users")), - }, - handler: async (ctx, args) => { - const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; - if (!ticket || !ticket.visitorId) return; - - const comment = (await ctx.db.get(args.commentId)) as Doc<"ticketComments"> | null; - if (!comment) return; - - await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { - eventType: "ticket_comment", - domain: "ticket", - audience: "visitor", - workspaceId: ticket.workspaceId, - actorType: args.actorUserId ? "agent" : "system", - actorUserId: args.actorUserId, - ticketId: args.ticketId, - title: "Ticket update", - body: truncatePreview(comment.content, 120), - data: { - ticketId: args.ticketId, - type: "ticket_comment", - commentId: args.commentId, - }, - recipientVisitorIds: [ticket.visitorId], - eventKey: `ticket_comment:${args.commentId}`, - }); - }, -}); - -export const notifyTicketCustomerReply = internalMutation({ - args: { - ticketId: v.id("tickets"), - assigneeId: v.id("users"), - commentId: v.optional(v.id("ticketComments")), - }, - handler: async (ctx, args) => { - const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; - if (!ticket) return; - - let visitorInfo = "Customer"; - if (ticket.visitorId) { - const visitor = (await ctx.db.get(ticket.visitorId)) as Doc<"visitors"> | null; - if (visitor?.name) { - visitorInfo = visitor.name; - } else if (visitor?.email) { - visitorInfo = visitor.email; - } - } - - await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { - eventType: "ticket_customer_reply", - domain: "ticket", - audience: "agent", - workspaceId: ticket.workspaceId, - actorType: "visitor", - actorVisitorId: ticket.visitorId ?? undefined, - ticketId: args.ticketId, - title: "Customer replied to ticket", - body: `${visitorInfo} replied to: ${ticket.subject}`, - data: { - ticketId: args.ticketId, - type: "ticket_customer_reply", - ...(args.commentId ? { commentId: args.commentId } : {}), - }, - recipientUserIds: [args.assigneeId], - eventKey: args.commentId - ? `ticket_customer_reply:${args.commentId}` - : `ticket_customer_reply:${args.ticketId}:${args.assigneeId}`, - }); - }, -}); - -export const notifyTicketResolved = internalMutation({ - args: { - ticketId: v.id("tickets"), - resolutionSummary: v.optional(v.string()), - actorUserId: v.optional(v.id("users")), - }, - handler: async (ctx, args) => { - const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; - if (!ticket || !ticket.visitorId) return; - - await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { - eventType: "ticket_resolved", - domain: "ticket", - audience: "visitor", - workspaceId: ticket.workspaceId, - actorType: args.actorUserId ? "agent" : "system", - actorUserId: args.actorUserId, - ticketId: args.ticketId, - title: "Ticket resolved", - body: args.resolutionSummary - ? truncatePreview(args.resolutionSummary, 140) - : `Your ticket \"${ticket.subject}\" was resolved.`, - data: { - ticketId: args.ticketId, - type: "ticket_resolved", - }, - recipientVisitorIds: [ticket.visitorId], - eventKey: `ticket_resolved:${args.ticketId}`, - }); - }, -}); +export { + getPushTokensForWorkspace, + getMemberRecipientsForNewVisitorMessage, + getVisitorRecipientsForSupportReply, +} from "./notifications/recipients"; +export { + sendPushNotification, + sendNotificationEmail, + logDeliveryOutcome, + dispatchPushAttempts, +} from "./notifications/dispatch"; +export { routeEvent } from "./notifications/routing"; +export { notifyNewMessage, notifyNewConversation, notifyAssignment } from "./notifications/emitters/chat"; +export { + notifyTicketCreated, + notifyTicketStatusChanged, + notifyTicketAssigned, + notifyTicketComment, + notifyTicketCustomerReply, + notifyTicketResolved, +} from "./notifications/emitters/ticket"; diff --git a/packages/convex/convex/notifications/README.md b/packages/convex/convex/notifications/README.md new file mode 100644 index 0000000..9f14932 --- /dev/null +++ b/packages/convex/convex/notifications/README.md @@ -0,0 +1,26 @@ +# Notifications Domain Modules + +This folder contains notification orchestration internals split by responsibility. +`../notifications.ts` remains the stable entrypoint surface for `internal.notifications.*`. + +## Ownership + +- `contracts.ts`: shared validators, constants, and typed payload contracts. +- `helpers.ts`: pure helper utilities (formatting, truncation, metadata rendering, debounce batch selection). +- `recipients.ts`: recipient resolution queries and fallback visitor recipient lookup. +- `dispatch.ts`: delivery logging and push/email channel dispatch actions. +- `routing.ts`: event routing, recipient filtering, dedupe key enforcement, and scheduling. +- `emitters/chat.ts`: chat/conversation/assignment notification emitters. +- `emitters/ticket.ts`: ticket lifecycle notification emitters. + +## Extension Patterns + +- Add new event emitters in `emitters/*` and keep them thin by delegating routing to `routeEvent`. +- Keep routing semantics changes isolated in `routing.ts` so emitter payload changes do not alter dedupe behavior. +- Keep formatting/debounce/template-adjacent logic in `helpers.ts` to preserve testability and reuse. +- Add new recipient policy logic in `recipients.ts` rather than duplicating query logic in emitters. + +## Cross-Surface Notes + +- This refactor is backend-internal and does not change public API contracts for `apps/web`, `apps/widget`, `apps/mobile`, `packages/sdk-core`, or `packages/sdk-react-native`. +- If future work needs shared behavior changes across clients, update shared specs/contracts first, then consume them in each surface. diff --git a/packages/convex/convex/notifications/contracts.ts b/packages/convex/convex/notifications/contracts.ts new file mode 100644 index 0000000..9e58797 --- /dev/null +++ b/packages/convex/convex/notifications/contracts.ts @@ -0,0 +1,64 @@ +import { v } from "convex/values"; +import type { Id } from "../_generated/dataModel"; + +export 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") +); + +export const notificationDomainValidator = v.union( + v.literal("chat"), + v.literal("ticket"), + v.literal("outbound"), + v.literal("campaign") +); + +export const notificationAudienceValidator = v.union( + v.literal("agent"), + v.literal("visitor"), + v.literal("both") +); + +export const notificationActorTypeValidator = v.union( + v.literal("agent"), + v.literal("visitor"), + v.literal("bot"), + v.literal("system") +); + +export const notificationChannelValidator = v.union( + v.literal("push"), + v.literal("email"), + v.literal("web"), + v.literal("widget") +); + +export const notificationRecipientTypeValidator = v.union(v.literal("agent"), v.literal("visitor")); + +export type NotificationRecipientType = "agent" | "visitor"; + +export type NotificationPushAttempt = { + dedupeKey: string; + recipientType: NotificationRecipientType; + userId?: Id<"users">; + visitorId?: Id<"visitors">; + tokens: string[]; +}; + +export type NotifyNewMessageMode = "send_member_email" | "send_visitor_email"; + +export const ADMIN_WEB_APP_BASE_URL = + process.env.OPENCOM_WEB_APP_URL ?? process.env.NEXT_PUBLIC_OPENCOM_WEB_APP_URL ?? ""; +export const EMAIL_DEBOUNCE_MS = 60_000; +export const MAX_BATCH_MESSAGES = 8; +export const MAX_THREAD_MESSAGES = 12; diff --git a/packages/convex/convex/notifications/dispatch.ts b/packages/convex/convex/notifications/dispatch.ts new file mode 100644 index 0000000..205e023 --- /dev/null +++ b/packages/convex/convex/notifications/dispatch.ts @@ -0,0 +1,222 @@ +import { v } from "convex/values"; +import { internalAction, internalMutation } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { jsonRecordValidator } from "../validators"; +import { sendEmail } from "../email"; +import { notificationChannelValidator, notificationRecipientTypeValidator } from "./contracts"; + +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); + }, +}); + +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, + }; + }, +}); diff --git a/packages/convex/convex/notifications/emitters/chat.ts b/packages/convex/convex/notifications/emitters/chat.ts new file mode 100644 index 0000000..428cb56 --- /dev/null +++ b/packages/convex/convex/notifications/emitters/chat.ts @@ -0,0 +1,390 @@ +import { v } from "convex/values"; +import { internalMutation, type MutationCtx, type QueryCtx } from "../../_generated/server"; +import { internal } from "../../_generated/api"; +import type { Doc, Id } from "../../_generated/dataModel"; +import { EMAIL_DEBOUNCE_MS, MAX_THREAD_MESSAGES } from "../contracts"; +import { + buildAdminConversationInboxUrl, + buildDebouncedEmailBatch, + buildVisitorWebsiteUrl, + escapeHtml, + formatEmailTimestamp, + isSupportSenderType, + renderConversationThreadHtml, + renderMetadataList, +} from "../helpers"; + +type SenderLookupCtx = Pick; + +async function resolveSupportSenderLabels( + ctx: SenderLookupCtx, + messages: Doc<"messages">[] +): Promise> { + const supportSenderIds = Array.from( + new Set( + messages + .filter((message) => message.senderType === "agent" || message.senderType === "user") + .map((message) => message.senderId) + ) + ); + + const supportSenderEntries = await Promise.all( + supportSenderIds.map(async (senderId) => { + try { + const sender = (await ctx.db.get(senderId as Id<"users">)) as Doc<"users"> | null; + return [senderId, sender?.name ?? sender?.email ?? "Support"] as const; + } catch { + return [senderId, "Support"] as const; + } + }) + ); + + return new Map(supportSenderEntries); +} + +async function resolveSupportSender( + ctx: SenderLookupCtx, + message: Doc<"messages"> +): Promise<{ name: string; email: string | null }> { + if (message.senderType === "bot") { + return { name: "Support bot", email: null }; + } + + if (message.senderType !== "agent") { + return { name: "Support", email: null }; + } + + try { + const sender = (await ctx.db.get(message.senderId as Id<"users">)) as Doc<"users"> | null; + if (!sender) { + return { name: "Support", email: null }; + } + return { + name: sender.name ?? sender.email ?? "Support", + email: sender.email ?? null, + }; + } catch { + return { name: "Support", email: null }; + } +} + +export const notifyNewMessage = internalMutation({ + args: { + conversationId: v.id("conversations"), + messageContent: v.string(), + senderType: v.string(), + messageId: v.optional(v.id("messages")), + senderId: v.optional(v.string()), + sentAt: v.optional(v.number()), + channel: v.optional(v.union(v.literal("chat"), v.literal("email"))), + mode: v.optional(v.union(v.literal("send_member_email"), v.literal("send_visitor_email"))), + }, + handler: async (ctx, args) => { + const conversation = (await ctx.db.get(args.conversationId)) as Doc<"conversations"> | null; + if (!conversation) return; + const triggerSentAt = args.sentAt ?? Date.now(); + const truncatedContent = + args.messageContent.length > 100 + ? `${args.messageContent.slice(0, 100)}...` + : args.messageContent; + + if (args.mode === "send_member_email" || args.mode === "send_visitor_email") { + const recentMessagesDesc = await ctx.db + .query("messages") + .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) + .order("desc") + .take(200); + + const batchMessages = buildDebouncedEmailBatch({ + recentMessagesDesc: recentMessagesDesc as Doc<"messages">[], + mode: args.mode, + triggerMessageId: args.messageId, + triggerSentAt, + }); + + if (batchMessages.length === 0) { + return; + } + + const recentThreadMessages = recentMessagesDesc + .slice(0, MAX_THREAD_MESSAGES) + .reverse() as Doc<"messages">[]; + const newMessageIds = new Set(batchMessages.map((message) => message._id)); + const visitor = conversation.visitorId + ? ((await ctx.db.get(conversation.visitorId)) as Doc<"visitors"> | null) + : null; + const visitorLabel = visitor?.name ?? visitor?.email ?? visitor?.readableId ?? "Visitor"; + const supportSenderLabels = await resolveSupportSenderLabels(ctx, recentThreadMessages); + const threadHtml = renderConversationThreadHtml({ + messages: recentThreadMessages, + newMessageIds, + visitorLabel, + supportSenderLabels, + }); + + if (args.mode === "send_member_email") { + const recipients = await ctx.runQuery( + internal.notifications.getMemberRecipientsForNewVisitorMessage, + { + workspaceId: conversation.workspaceId, + } + ); + + if (recipients.emailRecipients.length === 0) { + return; + } + + const senderName = visitor?.name ?? visitor?.email ?? "Visitor"; + const senderEmail = visitor?.email ?? null; + const senderReadableId = visitor?.readableId ?? null; + const sentAtLabel = formatEmailTimestamp(batchMessages[batchMessages.length - 1].createdAt); + const conversationInboxUrl = buildAdminConversationInboxUrl(args.conversationId); + const openConversationHtml = conversationInboxUrl + ? `

    Open this conversation in OpenCom inbox

    ` + : ""; + const detailsHtml = renderMetadataList([ + { label: "Sender", value: senderName }, + { label: "Sender email", value: senderEmail }, + { label: "Visitor ID", value: senderReadableId }, + { label: "Sent at", value: sentAtLabel }, + { label: "Conversation ID", value: String(args.conversationId) }, + { label: "Message count", value: String(batchMessages.length) }, + { label: "Channel", value: args.channel ?? conversation.channel ?? "chat" }, + ]); + const subject = + batchMessages.length > 1 + ? `${batchMessages.length} new messages from ${senderName}` + : `New message from ${senderName}`; + + for (const recipient of recipients.emailRecipients) { + await ctx.scheduler.runAfter(0, internal.notifications.sendNotificationEmail, { + to: recipient, + subject, + html: `

    You have new visitor message activity.

    ${openConversationHtml}${detailsHtml}

    Recent conversation (last ${MAX_THREAD_MESSAGES} messages)

    ${threadHtml}`, + }); + } + + return; + } + + if (!conversation.visitorId) { + return; + } + + const visitorRecipients = await ctx.runQuery( + internal.notifications.getVisitorRecipientsForSupportReply, + { + conversationId: args.conversationId, + channel: args.channel, + } + ); + + if (!visitorRecipients.emailRecipient) { + return; + } + + const visitorWebsiteUrl = buildVisitorWebsiteUrl(visitor); + const openWebsiteChatHtml = visitorWebsiteUrl + ? `

    Open chat on the website

    ` + : ""; + const latestSupportMessage = batchMessages[batchMessages.length - 1]; + const supportSender = await resolveSupportSender(ctx, latestSupportMessage); + const sentAtLabel = formatEmailTimestamp(latestSupportMessage.createdAt); + const detailsHtml = renderMetadataList([ + { label: "Sender", value: supportSender.name }, + { label: "Sender email", value: supportSender.email }, + { label: "Sent at", value: sentAtLabel }, + { label: "Conversation ID", value: String(args.conversationId) }, + { label: "Message count", value: String(batchMessages.length) }, + ]); + const subject = + batchMessages.length > 1 + ? `${batchMessages.length} new messages from support` + : "New message from support"; + + await ctx.scheduler.runAfter(0, internal.notifications.sendNotificationEmail, { + to: visitorRecipients.emailRecipient, + subject, + html: `

    You have new messages from support.

    ${openWebsiteChatHtml}${detailsHtml}

    Recent conversation (last ${MAX_THREAD_MESSAGES} messages)

    ${threadHtml}`, + }); + + return; + } + + if (args.senderType === "visitor") { + const recipients = await ctx.runQuery( + internal.notifications.getMemberRecipientsForNewVisitorMessage, + { + workspaceId: conversation.workspaceId, + } + ); + + if (recipients.emailRecipients.length > 0 || recipients.pushRecipients.length > 0) { + let visitor: Doc<"visitors"> | null = null; + let senderName = "Visitor"; + if (conversation.visitorId) { + visitor = (await ctx.db.get(conversation.visitorId)) as Doc<"visitors"> | null; + if (visitor?.name) { + senderName = visitor.name; + } else if (visitor?.email) { + senderName = visitor.email; + } + } + + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "chat_message", + domain: "chat", + audience: "agent", + workspaceId: conversation.workspaceId, + actorType: "visitor", + actorVisitorId: conversation.visitorId ?? undefined, + conversationId: args.conversationId, + title: `New message from ${senderName}`, + body: truncatedContent, + data: { + conversationId: args.conversationId, + type: "new_message", + }, + ...(args.messageId ? { eventKey: `chat_message:${args.messageId}` } : {}), + }); + + if (recipients.emailRecipients.length > 0) { + await ctx.scheduler.runAfter(EMAIL_DEBOUNCE_MS, internal.notifications.notifyNewMessage, { + conversationId: args.conversationId, + messageContent: args.messageContent, + senderType: args.senderType, + messageId: args.messageId, + senderId: args.senderId, + sentAt: triggerSentAt, + channel: args.channel, + mode: "send_member_email", + }); + } + } + } + + if (isSupportSenderType(args.senderType)) { + if (conversation.visitorId) { + const visitorRecipients = await ctx.runQuery( + internal.notifications.getVisitorRecipientsForSupportReply, + { + conversationId: args.conversationId, + channel: args.channel, + } + ); + + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "chat_message", + domain: "chat", + audience: "visitor", + workspaceId: conversation.workspaceId, + actorType: args.senderType === "bot" ? "bot" : "agent", + ...(args.senderType === "agent" && args.senderId + ? { actorUserId: args.senderId as Id<"users"> } + : {}), + conversationId: args.conversationId, + title: "Support", + body: truncatedContent, + data: { + conversationId: args.conversationId, + type: "new_message", + }, + recipientVisitorIds: conversation.visitorId ? [conversation.visitorId] : undefined, + ...(args.messageId ? { eventKey: `chat_message:${args.messageId}` } : {}), + }); + + if (visitorRecipients.emailRecipient) { + await ctx.scheduler.runAfter(EMAIL_DEBOUNCE_MS, internal.notifications.notifyNewMessage, { + conversationId: args.conversationId, + messageContent: args.messageContent, + senderType: args.senderType, + messageId: args.messageId, + senderId: args.senderId, + sentAt: triggerSentAt, + channel: args.channel, + mode: "send_visitor_email", + }); + } + } + } + }, +}); + +export const notifyNewConversation = internalMutation({ + args: { + conversationId: v.id("conversations"), + }, + handler: async (ctx, args) => { + const conversation = (await ctx.db.get(args.conversationId)) as Doc<"conversations"> | null; + if (!conversation) return; + + let visitorInfo = "New visitor"; + if (conversation.visitorId) { + const visitor = (await ctx.db.get(conversation.visitorId)) as Doc<"visitors"> | null; + if (visitor?.name) { + visitorInfo = visitor.name; + } else if (visitor?.email) { + visitorInfo = visitor.email; + } + } + + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "new_conversation", + domain: "chat", + audience: "agent", + workspaceId: conversation.workspaceId, + actorType: "visitor", + actorVisitorId: conversation.visitorId ?? undefined, + conversationId: args.conversationId, + title: "New conversation", + body: `${visitorInfo} started a conversation`, + data: { + conversationId: args.conversationId, + type: "new_conversation", + }, + eventKey: `new_conversation:${args.conversationId}`, + }); + }, +}); + +export const notifyAssignment = internalMutation({ + args: { + conversationId: v.id("conversations"), + assignedAgentId: v.id("users"), + actorUserId: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + const conversation = (await ctx.db.get(args.conversationId)) as Doc<"conversations"> | null; + if (!conversation) return; + + let visitorInfo = "a visitor"; + if (conversation.visitorId) { + const visitor = (await ctx.db.get(conversation.visitorId)) as Doc<"visitors"> | null; + if (visitor?.name) { + visitorInfo = visitor.name; + } else if (visitor?.email) { + visitorInfo = visitor.email; + } + } + + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "assignment", + domain: "chat", + audience: "agent", + workspaceId: conversation.workspaceId, + actorType: args.actorUserId ? "agent" : "system", + actorUserId: args.actorUserId, + conversationId: args.conversationId, + title: "Conversation assigned", + body: `You've been assigned a conversation with ${visitorInfo}`, + data: { + conversationId: args.conversationId, + type: "assignment", + }, + recipientUserIds: [args.assignedAgentId], + eventKey: `assignment:${args.conversationId}:${args.assignedAgentId}`, + }); + }, +}); diff --git a/packages/convex/convex/notifications/emitters/ticket.ts b/packages/convex/convex/notifications/emitters/ticket.ts new file mode 100644 index 0000000..b9ca033 --- /dev/null +++ b/packages/convex/convex/notifications/emitters/ticket.ts @@ -0,0 +1,225 @@ +import { v } from "convex/values"; +import { internalMutation } from "../../_generated/server"; +import { internal } from "../../_generated/api"; +import type { Doc } from "../../_generated/dataModel"; +import { truncatePreview } from "../helpers"; + +export const notifyTicketCreated = internalMutation({ + args: { + ticketId: v.id("tickets"), + }, + handler: async (ctx, args) => { + const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; + if (!ticket) return; + + let visitorInfo = "A customer"; + if (ticket.visitorId) { + const visitor = (await ctx.db.get(ticket.visitorId)) as Doc<"visitors"> | null; + if (visitor?.name) { + visitorInfo = visitor.name; + } else if (visitor?.email) { + visitorInfo = visitor.email; + } + } + + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "ticket_created", + domain: "ticket", + audience: "agent", + workspaceId: ticket.workspaceId, + actorType: ticket.visitorId ? "visitor" : "system", + actorVisitorId: ticket.visitorId ?? undefined, + ticketId: args.ticketId, + title: "New ticket created", + body: `${visitorInfo} submitted: ${ticket.subject}`, + data: { + ticketId: args.ticketId, + type: "ticket_created", + }, + eventKey: `ticket_created:${args.ticketId}`, + }); + }, +}); + +export const notifyTicketStatusChanged = internalMutation({ + args: { + ticketId: v.id("tickets"), + oldStatus: v.string(), + newStatus: v.string(), + actorUserId: v.optional(v.id("users")), + changedAt: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; + if (!ticket || !ticket.visitorId) return; + + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "ticket_status_changed", + domain: "ticket", + audience: "visitor", + workspaceId: ticket.workspaceId, + actorType: args.actorUserId ? "agent" : "system", + actorUserId: args.actorUserId, + ticketId: args.ticketId, + title: "Ticket update", + body: `Your ticket \"${ticket.subject}\" moved to ${args.newStatus.replaceAll("_", " ")}.`, + data: { + ticketId: args.ticketId, + type: "ticket_status_changed", + oldStatus: args.oldStatus, + newStatus: args.newStatus, + }, + recipientVisitorIds: [ticket.visitorId], + eventKey: `ticket_status_changed:${args.ticketId}:${args.newStatus}:${args.changedAt ?? Date.now()}`, + }); + }, +}); + +export const notifyTicketAssigned = internalMutation({ + args: { + ticketId: v.id("tickets"), + assigneeId: v.id("users"), + actorUserId: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; + if (!ticket) return; + + let visitorInfo = "a customer"; + if (ticket.visitorId) { + const visitor = (await ctx.db.get(ticket.visitorId)) as Doc<"visitors"> | null; + if (visitor?.name) { + visitorInfo = visitor.name; + } else if (visitor?.email) { + visitorInfo = visitor.email; + } + } + + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "ticket_assigned", + domain: "ticket", + audience: "agent", + workspaceId: ticket.workspaceId, + actorType: args.actorUserId ? "agent" : "system", + actorUserId: args.actorUserId, + ticketId: args.ticketId, + title: "Ticket assigned", + body: `You've been assigned a ticket from ${visitorInfo}: ${ticket.subject}`, + data: { + ticketId: args.ticketId, + type: "ticket_assigned", + }, + recipientUserIds: [args.assigneeId], + eventKey: `ticket_assigned:${args.ticketId}:${args.assigneeId}`, + }); + }, +}); + +export const notifyTicketComment = internalMutation({ + args: { + ticketId: v.id("tickets"), + commentId: v.id("ticketComments"), + actorUserId: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; + if (!ticket || !ticket.visitorId) return; + + const comment = (await ctx.db.get(args.commentId)) as Doc<"ticketComments"> | null; + if (!comment) return; + + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "ticket_comment", + domain: "ticket", + audience: "visitor", + workspaceId: ticket.workspaceId, + actorType: args.actorUserId ? "agent" : "system", + actorUserId: args.actorUserId, + ticketId: args.ticketId, + title: "Ticket update", + body: truncatePreview(comment.content, 120), + data: { + ticketId: args.ticketId, + type: "ticket_comment", + commentId: args.commentId, + }, + recipientVisitorIds: [ticket.visitorId], + eventKey: `ticket_comment:${args.commentId}`, + }); + }, +}); + +export const notifyTicketCustomerReply = internalMutation({ + args: { + ticketId: v.id("tickets"), + assigneeId: v.id("users"), + commentId: v.optional(v.id("ticketComments")), + }, + handler: async (ctx, args) => { + const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; + if (!ticket) return; + + let visitorInfo = "Customer"; + if (ticket.visitorId) { + const visitor = (await ctx.db.get(ticket.visitorId)) as Doc<"visitors"> | null; + if (visitor?.name) { + visitorInfo = visitor.name; + } else if (visitor?.email) { + visitorInfo = visitor.email; + } + } + + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "ticket_customer_reply", + domain: "ticket", + audience: "agent", + workspaceId: ticket.workspaceId, + actorType: "visitor", + actorVisitorId: ticket.visitorId ?? undefined, + ticketId: args.ticketId, + title: "Customer replied to ticket", + body: `${visitorInfo} replied to: ${ticket.subject}`, + data: { + ticketId: args.ticketId, + type: "ticket_customer_reply", + ...(args.commentId ? { commentId: args.commentId } : {}), + }, + recipientUserIds: [args.assigneeId], + eventKey: args.commentId + ? `ticket_customer_reply:${args.commentId}` + : `ticket_customer_reply:${args.ticketId}:${args.assigneeId}`, + }); + }, +}); + +export const notifyTicketResolved = internalMutation({ + args: { + ticketId: v.id("tickets"), + resolutionSummary: v.optional(v.string()), + actorUserId: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + const ticket = (await ctx.db.get(args.ticketId)) as Doc<"tickets"> | null; + if (!ticket || !ticket.visitorId) return; + + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "ticket_resolved", + domain: "ticket", + audience: "visitor", + workspaceId: ticket.workspaceId, + actorType: args.actorUserId ? "agent" : "system", + actorUserId: args.actorUserId, + ticketId: args.ticketId, + title: "Ticket resolved", + body: args.resolutionSummary + ? truncatePreview(args.resolutionSummary, 140) + : `Your ticket \"${ticket.subject}\" was resolved.`, + data: { + ticketId: args.ticketId, + type: "ticket_resolved", + }, + recipientVisitorIds: [ticket.visitorId], + eventKey: `ticket_resolved:${args.ticketId}`, + }); + }, +}); diff --git a/packages/convex/convex/notifications/helpers.ts b/packages/convex/convex/notifications/helpers.ts new file mode 100644 index 0000000..1675549 --- /dev/null +++ b/packages/convex/convex/notifications/helpers.ts @@ -0,0 +1,228 @@ +import type { Doc, Id } from "../_generated/dataModel"; +import { + ADMIN_WEB_APP_BASE_URL, + EMAIL_DEBOUNCE_MS, + MAX_BATCH_MESSAGES, + NotifyNewMessageMode, +} from "./contracts"; + +export function truncatePreview(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`; +} + +export function buildDefaultEventKey(args: { + eventType: string; + conversationId?: Id<"conversations">; + ticketId?: Id<"tickets">; + outboundMessageId?: Id<"outboundMessages">; + campaignId?: Id<"pushCampaigns">; + actorUserId?: Id<"users">; + actorVisitorId?: Id<"visitors">; +}): string { + 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()}`; +} + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export 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; + } +} + +export 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; + } +} + +export 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"); +} + +export 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 `
      ${items.join("")}
    `; +} + +export function truncateText(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value; +} + +export function formatMessageContentForEmail(content: string): string { + return escapeHtml(truncateText(content, 600)).replace(/\n/g, "
    "); +} + +export 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"; +} + +export function renderConversationThreadHtml(args: { + messages: Doc<"messages">[]; + newMessageIds: Set>; + visitorLabel: string; + supportSenderLabels: Map; +}): string { + if (args.messages.length === 0) { + return "

    No message content available.

    "; + } + + const items = args.messages.map((message) => { + const visitorSide = message.senderType === "visitor"; + const senderLabel = visitorSide + ? args.visitorLabel + : getSupportSenderLabel(message, args.supportSenderLabels); + const createdAt = formatEmailTimestamp(message.createdAt); + const content = formatMessageContentForEmail(message.content); + const isNewMessage = args.newMessageIds.has(message._id); + const bubbleBg = visitorSide ? "#eef2ff" : "#111827"; + const bubbleFg = visitorSide ? "#1f2937" : "#ffffff"; + + return ` + + +
    +

    + ${escapeHtml(senderLabel)} · ${escapeHtml(createdAt)}${isNewMessage ? " · New" : ""} +

    +

    ${content}

    +
    + + + `; + }); + + return `${items.join( + "" + )}
    `; +} + +export function isSupportSenderType(senderType: string): boolean { + return senderType === "agent" || senderType === "bot"; +} + +export function isRelevantMessageForMode(message: Doc<"messages">, mode: NotifyNewMessageMode): boolean { + if (mode === "send_member_email") { + return message.senderType === "visitor"; + } + + return isSupportSenderType(message.senderType); +} + +export function buildDebouncedEmailBatch(args: { + recentMessagesDesc: Doc<"messages">[]; + mode: NotifyNewMessageMode; + triggerMessageId: Id<"messages"> | undefined; + triggerSentAt: number; +}): Doc<"messages">[] { + const latestRelevant = args.recentMessagesDesc.find((message) => + isRelevantMessageForMode(message, args.mode) + ); + + if (!latestRelevant) { + return []; + } + + if (args.triggerMessageId) { + if (latestRelevant._id !== args.triggerMessageId) { + return []; + } + } else if (latestRelevant.createdAt > args.triggerSentAt) { + return []; + } + + const batchDesc: Doc<"messages">[] = []; + let collecting = false; + + for (const message of args.recentMessagesDesc) { + if (!collecting) { + if (message._id !== latestRelevant._id) { + continue; + } + collecting = true; + } + + if (!isRelevantMessageForMode(message, args.mode)) { + break; + } + + if (batchDesc.length > 0) { + const previousMessage = batchDesc[batchDesc.length - 1]; + if (previousMessage.createdAt - message.createdAt > EMAIL_DEBOUNCE_MS) { + break; + } + } + + batchDesc.push(message); + + if (batchDesc.length >= MAX_BATCH_MESSAGES) { + break; + } + } + + return batchDesc.reverse(); +} + +export function buildVisitorWebsiteUrl(visitor: Doc<"visitors"> | null): string | null { + return normalizeHttpUrl(visitor?.currentUrl); +} diff --git a/packages/convex/convex/notifications/recipients.ts b/packages/convex/convex/notifications/recipients.ts new file mode 100644 index 0000000..ad28ea1 --- /dev/null +++ b/packages/convex/convex/notifications/recipients.ts @@ -0,0 +1,215 @@ +import { v } from "convex/values"; +import { internalQuery, type MutationCtx, type QueryCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +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 token of userTokens) { + if (token.notificationsEnabled === false) { + continue; + } + tokens.push({ + token: token.token, + platform: token.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), + }; + }, +}); + +type VisitorRecipientResolutionCtx = Pick; + +export async function resolveDefaultVisitorRecipients( + ctx: VisitorRecipientResolutionCtx, + 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 []; +} diff --git a/packages/convex/convex/notifications/routing.ts b/packages/convex/convex/notifications/routing.ts new file mode 100644 index 0000000..8da5303 --- /dev/null +++ b/packages/convex/convex/notifications/routing.ts @@ -0,0 +1,318 @@ +import { v } from "convex/values"; +import { internalMutation } from "../_generated/server"; +import { internal } from "../_generated/api"; +import type { Doc, Id } from "../_generated/dataModel"; +import { jsonRecordValidator } from "../validators"; +import { + resolveMemberNewVisitorMessagePreference, + resolveWorkspaceNewVisitorMessageDefaults, +} from "../lib/notificationPreferences"; +import { + notificationActorTypeValidator, + notificationAudienceValidator, + notificationDomainValidator, + notificationEventTypeValidator, + NotificationPushAttempt, +} from "./contracts"; +import { buildDefaultEventKey, truncatePreview } from "./helpers"; +import { resolveDefaultVisitorRecipients } from "./recipients"; + +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, + }; + }, +}); diff --git a/packages/convex/tests/notificationEmailBatching.test.ts b/packages/convex/tests/notificationEmailBatching.test.ts new file mode 100644 index 0000000..28c7d33 --- /dev/null +++ b/packages/convex/tests/notificationEmailBatching.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { Doc, Id } from "../convex/_generated/dataModel"; +import { buildDebouncedEmailBatch } from "../convex/notifications/helpers"; + +function message(args: { + id: string; + senderType: Doc<"messages">["senderType"]; + createdAt: number; + content?: string; +}): Doc<"messages"> { + return { + _id: args.id as Id<"messages">, + _creationTime: args.createdAt, + conversationId: "conv-1" as Id<"conversations">, + senderType: args.senderType, + senderId: "sender-1", + content: args.content ?? args.id, + createdAt: args.createdAt, + channel: "chat", + }; +} + +describe("notification email batching", () => { + it("builds support-reply batches from contiguous support messages within debounce window", () => { + const messagesDesc = [ + message({ id: "m3", senderType: "agent", createdAt: 3000 }), + message({ id: "m2", senderType: "bot", createdAt: 2600 }), + message({ id: "m1", senderType: "visitor", createdAt: 2400 }), + ]; + + const batch = buildDebouncedEmailBatch({ + recentMessagesDesc: messagesDesc, + mode: "send_visitor_email", + triggerMessageId: "m3" as Id<"messages">, + triggerSentAt: 3000, + }); + + expect(batch.map((entry) => entry._id)).toEqual(["m2", "m3"]); + }); + + it("suppresses stale debounced support-reply sends when a newer relevant message exists", () => { + const messagesDesc = [ + message({ id: "m3", senderType: "agent", createdAt: 3000 }), + message({ id: "m2", senderType: "agent", createdAt: 2500 }), + message({ id: "m1", senderType: "visitor", createdAt: 2000 }), + ]; + + const staleByMessageId = buildDebouncedEmailBatch({ + recentMessagesDesc: messagesDesc, + mode: "send_visitor_email", + triggerMessageId: "m2" as Id<"messages">, + triggerSentAt: 2500, + }); + expect(staleByMessageId).toEqual([]); + + const staleBySentAt = buildDebouncedEmailBatch({ + recentMessagesDesc: messagesDesc, + mode: "send_visitor_email", + triggerMessageId: undefined, + triggerSentAt: 2500, + }); + expect(staleBySentAt).toEqual([]); + }); +}); diff --git a/packages/convex/tests/notificationRouting.test.ts b/packages/convex/tests/notificationRouting.test.ts index fd0a9b9..ddbca1a 100644 --- a/packages/convex/tests/notificationRouting.test.ts +++ b/packages/convex/tests/notificationRouting.test.ts @@ -229,6 +229,284 @@ describe("notification routing", () => { }); }); + it("routes visitor messages via notifyNewMessage with recipient selection parity", async () => { + const seeded = await t.run(async (ctx) => { + const now = Date.now(); + const workspaceId = await ctx.db.insert("workspaces", { + name: "Notify Message Routing Workspace", + createdAt: now, + }); + + const activeUserId = await ctx.db.insert("users", { + workspaceId, + role: "agent", + createdAt: now, + }); + const mutedUserId = await ctx.db.insert("users", { + workspaceId, + role: "agent", + createdAt: now, + }); + const noTokenUserId = await ctx.db.insert("users", { + workspaceId, + role: "agent", + createdAt: now, + }); + + await ctx.db.insert("pushTokens", { + userId: activeUserId, + token: "ExponentPushToken[notify-message-active]", + platform: "ios", + notificationsEnabled: true, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("pushTokens", { + userId: mutedUserId, + token: "ExponentPushToken[notify-message-muted]", + platform: "ios", + notificationsEnabled: true, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("notificationPreferences", { + userId: mutedUserId, + workspaceId, + muted: false, + events: { + newVisitorMessage: { + push: false, + }, + }, + createdAt: now, + updatedAt: now, + }); + + const visitorId = await ctx.db.insert("visitors", { + workspaceId, + sessionId: "notify-message-session", + createdAt: now, + }); + const conversationId = await ctx.db.insert("conversations", { + workspaceId, + visitorId, + status: "open", + createdAt: now, + updatedAt: now, + }); + const messageId = await ctx.db.insert("messages", { + conversationId, + senderType: "visitor", + senderId: visitorId, + content: "Need help with billing", + channel: "chat", + createdAt: now, + }); + + return { + activeUserId, + mutedUserId, + noTokenUserId, + messageId, + conversationId, + }; + }); + + await t.mutation(internal.notifications.notifyNewMessage, { + conversationId: seeded.conversationId, + messageContent: "Need help with billing", + senderType: "visitor", + messageId: seeded.messageId, + senderId: "visitor", + sentAt: Date.now(), + channel: "chat", + }); + + await t.finishAllScheduledFunctions(() => { + vi.runAllTimers(); + }); + + await t.run(async (ctx) => { + const eventKey = `chat_message:${seeded.messageId}`; + const events = await ctx.db + .query("notificationEvents") + .withIndex("by_event_key", (q) => q.eq("eventKey", eventKey)) + .collect(); + expect(events).toHaveLength(1); + expect(events[0].audience).toBe("agent"); + expect(events[0].actorType).toBe("visitor"); + + const dedupeRows = await ctx.db + .query("notificationDedupeKeys") + .withIndex("by_event", (q) => q.eq("eventId", events[0]._id)) + .collect(); + expect(dedupeRows).toHaveLength(1); + expect(dedupeRows[0].userId).toBe(seeded.activeUserId); + + const deliveries = await ctx.db + .query("notificationDeliveries") + .withIndex("by_event", (q) => q.eq("eventId", events[0]._id)) + .collect(); + const suppressionReasons = new Set( + deliveries + .filter((delivery) => delivery.status === "suppressed") + .map((delivery) => delivery.reason) + ); + + expect(suppressionReasons.has("preference_muted")).toBe(true); + expect(suppressionReasons.has("missing_push_token")).toBe(true); + expect( + deliveries.some( + (delivery) => delivery.userId === seeded.noTokenUserId && delivery.reason === "missing_push_token" + ) + ).toBe(true); + expect( + deliveries.some( + (delivery) => delivery.userId === seeded.mutedUserId && delivery.reason === "preference_muted" + ) + ).toBe(true); + }); + }); + + it("preserves ticket routing parity across assignment, status change, and comment events", async () => { + const seeded = await t.run(async (ctx) => { + const now = Date.now(); + const workspaceId = await ctx.db.insert("workspaces", { + name: "Ticket Routing Workspace", + createdAt: now, + }); + + const assigneeId = await ctx.db.insert("users", { + workspaceId, + role: "agent", + createdAt: now, + }); + const actorUserId = await ctx.db.insert("users", { + workspaceId, + role: "agent", + createdAt: now, + }); + + const visitorId = await ctx.db.insert("visitors", { + workspaceId, + sessionId: "ticket-routing-session", + createdAt: now, + }); + const ticketId = await ctx.db.insert("tickets", { + workspaceId, + visitorId, + subject: "Broken checkout flow", + status: "submitted", + priority: "normal", + createdAt: now, + updatedAt: now, + }); + const commentId = await ctx.db.insert("ticketComments", { + ticketId, + authorType: "agent", + authorId: actorUserId, + content: "We are investigating this now.", + isInternal: false, + createdAt: now, + }); + + return { assigneeId, actorUserId, visitorId, ticketId, commentId }; + }); + + await t.mutation(internal.notifications.notifyTicketAssigned, { + ticketId: seeded.ticketId, + assigneeId: seeded.assigneeId, + actorUserId: seeded.actorUserId, + }); + await t.mutation(internal.notifications.notifyTicketStatusChanged, { + ticketId: seeded.ticketId, + oldStatus: "open", + newStatus: "resolved", + actorUserId: seeded.actorUserId, + changedAt: 123456789, + }); + await t.mutation(internal.notifications.notifyTicketComment, { + ticketId: seeded.ticketId, + commentId: seeded.commentId, + actorUserId: seeded.actorUserId, + }); + + await t.finishAllScheduledFunctions(() => { + vi.runAllTimers(); + }); + + await t.run(async (ctx) => { + const assignedEvent = await ctx.db + .query("notificationEvents") + .withIndex("by_event_key", (q) => + q.eq("eventKey", `ticket_assigned:${seeded.ticketId}:${seeded.assigneeId}`) + ) + .first(); + expect(assignedEvent).toBeTruthy(); + expect(assignedEvent?.audience).toBe("agent"); + + const statusEvent = await ctx.db + .query("notificationEvents") + .withIndex("by_event_key", (q) => + q.eq("eventKey", `ticket_status_changed:${seeded.ticketId}:resolved:123456789`) + ) + .first(); + expect(statusEvent).toBeTruthy(); + expect(statusEvent?.audience).toBe("visitor"); + + const commentEvent = await ctx.db + .query("notificationEvents") + .withIndex("by_event_key", (q) => q.eq("eventKey", `ticket_comment:${seeded.commentId}`)) + .first(); + expect(commentEvent).toBeTruthy(); + expect(commentEvent?.audience).toBe("visitor"); + + const assignedDeliveries = assignedEvent + ? await ctx.db + .query("notificationDeliveries") + .withIndex("by_event", (q) => q.eq("eventId", assignedEvent._id)) + .collect() + : []; + const statusDeliveries = statusEvent + ? await ctx.db + .query("notificationDeliveries") + .withIndex("by_event", (q) => q.eq("eventId", statusEvent._id)) + .collect() + : []; + const commentDeliveries = commentEvent + ? await ctx.db + .query("notificationDeliveries") + .withIndex("by_event", (q) => q.eq("eventId", commentEvent._id)) + .collect() + : []; + + expect( + assignedDeliveries.some( + (delivery) => + delivery.userId === seeded.assigneeId && + delivery.recipientType === "agent" && + delivery.reason === "missing_push_token" + ) + ).toBe(true); + expect( + statusDeliveries.some( + (delivery) => + delivery.visitorId === seeded.visitorId && + delivery.recipientType === "visitor" && + delivery.reason === "missing_push_token" + ) + ).toBe(true); + expect( + commentDeliveries.some( + (delivery) => + delivery.visitorId === seeded.visitorId && + delivery.recipientType === "visitor" && + delivery.reason === "missing_push_token" + ) + ).toBe(true); + }); + }); + it("removes invalid agent and visitor tokens after transport errors", async () => { const seeded = await t.run(async (ctx) => { const now = Date.now(); From 10f5c4519eaca3f186c9089fa425652d0926a76b Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 15:17:02 +0000 Subject: [PATCH 06/91] Harden convex types --- docs/runtime-type-hardening-2026-03-05.md | 42 ++++++++++ .../tasks.md | 18 ++-- packages/convex/convex/_generated/api.d.ts | 2 + packages/convex/convex/events.ts | 8 +- packages/convex/convex/lib/authWrappers.ts | 84 +++++++------------ .../convex/convex/lib/seriesRuntimeAdapter.ts | 62 ++++++++++++++ packages/convex/convex/outboundMessages.ts | 6 +- packages/convex/convex/series.ts | 13 ++- .../tests/runtimeTypeHardeningGuard.test.ts | 29 +++++++ packages/types/src/index.ts | 60 ++++++++++++- 10 files changed, 248 insertions(+), 76 deletions(-) create mode 100644 docs/runtime-type-hardening-2026-03-05.md create mode 100644 packages/convex/convex/lib/seriesRuntimeAdapter.ts create mode 100644 packages/convex/tests/runtimeTypeHardeningGuard.test.ts diff --git a/docs/runtime-type-hardening-2026-03-05.md b/docs/runtime-type-hardening-2026-03-05.md new file mode 100644 index 0000000..f34dab7 --- /dev/null +++ b/docs/runtime-type-hardening-2026-03-05.md @@ -0,0 +1,42 @@ +# Runtime Type Hardening Notes (2026-03-05) + +## Scope + +This pass hardens runtime-critical Convex paths in: + +- `packages/convex/convex/events.ts` +- `packages/convex/convex/series.ts` +- `packages/convex/convex/lib/authWrappers.ts` +- `packages/types/src/index.ts` (series rule payload shapes) + +## Approved Dynamic Escape Hatches + +Dynamic behavior is still allowed at explicit boundaries, but should remain isolated: + +1. Convex internal function references: + - Use typed adapter helpers (`packages/convex/convex/lib/seriesRuntimeAdapter.ts`) instead of inline broad casts. +2. External/untyped payload ingress: + - Keep dynamic validation at validators or parser boundaries and convert to typed structures before runtime orchestration. +3. Generated Convex type boundaries: + - Prefer local typed wrappers/adapters over `as any` in runtime modules. + +## Migration Summary + +- Replaced inline `(internal as any).series.*` calls with typed adapter functions in `events.ts` and `series.ts`. +- Removed broad `as any` returns in `authWrappers.ts` by tightening generic wrapper contracts around typed args. +- Narrowed shared series-facing payload types in `@opencom/types`: + - `Series.entryRules`, `Series.exitRules`, `Series.goalRules` + - `SeriesBlockConfig.rules` + - these now use explicit `AudienceRuleOrSegment` structures instead of `unknown`. + +## Guardrails Added + +- Source-level guard test: `packages/convex/tests/runtimeTypeHardeningGuard.test.ts` + - blocks broad `as any` in covered modules. + - verifies series runtime calls use typed adapter helpers. + +## Follow-up Opportunities + +1. Extend adapter pattern to other dynamic internal invocation hotspots outside `events` and `series`. +2. Gradually replace remaining broad `unknown` fields in shared types where runtime consumers depend on known structure. +3. Add lint-level rule exceptions/allowlist to enforce no-new-`as any` in runtime-critical modules. diff --git a/openspec/changes/tighten-runtime-types-without-any/tasks.md b/openspec/changes/tighten-runtime-types-without-any/tasks.md index 777aaa0..efb6807 100644 --- a/openspec/changes/tighten-runtime-types-without-any/tasks.md +++ b/openspec/changes/tighten-runtime-types-without-any/tasks.md @@ -1,20 +1,20 @@ ## 1. Type Boundary Audit -- [ ] 1.1 Confirm and document targeted runtime-critical cast/unknown hotspots. -- [ ] 1.2 Define typed adapter contracts for dynamic internal invocations. +- [x] 1.1 Confirm and document targeted runtime-critical cast/unknown hotspots. +- [x] 1.2 Define typed adapter contracts for dynamic internal invocations. ## 2. Runtime Module Hardening -- [ ] 2.1 Migrate `events` and `series` dynamic internal calls to typed adapters. -- [ ] 2.2 Tighten `authWrappers` generic contracts where broad casts are currently required. -- [ ] 2.3 Narrow high-impact shared `unknown` fields in `packages/types` consumed by runtime-critical logic. +- [x] 2.1 Migrate `events` and `series` dynamic internal calls to typed adapters. +- [x] 2.2 Tighten `authWrappers` generic contracts where broad casts are currently required. +- [x] 2.3 Narrow high-impact shared `unknown` fields in `packages/types` consumed by runtime-critical logic. ## 3. Guardrails And Validation -- [ ] 3.1 Add targeted guard(s) to prevent broad cast regressions in covered modules. -- [ ] 3.2 Run Convex typecheck/tests and resolve runtime/type regressions. +- [x] 3.1 Add targeted guard(s) to prevent broad cast regressions in covered modules. +- [x] 3.2 Run Convex typecheck/tests and resolve runtime/type regressions. ## 4. Documentation -- [ ] 4.1 Document approved dynamic escape hatches and rationale. -- [ ] 4.2 Record migration notes for future type hardening work. +- [x] 4.1 Document approved dynamic escape hatches and rationale. +- [x] 4.2 Record migration notes for future type hardening work. diff --git a/packages/convex/convex/_generated/api.d.ts b/packages/convex/convex/_generated/api.d.ts index d2f3943..dbb95ea 100644 --- a/packages/convex/convex/_generated/api.d.ts +++ b/packages/convex/convex/_generated/api.d.ts @@ -40,6 +40,7 @@ import type * as knowledge from "../knowledge.js"; import type * as lib_aiGateway from "../lib/aiGateway.js"; import type * as lib_authWrappers from "../lib/authWrappers.js"; import type * as lib_notificationPreferences from "../lib/notificationPreferences.js"; +import type * as lib_seriesRuntimeAdapter from "../lib/seriesRuntimeAdapter.js"; import type * as messages from "../messages.js"; import type * as messengerSettings from "../messengerSettings.js"; import type * as migrations_backfillHelpCenterAccessPolicy from "../migrations/backfillHelpCenterAccessPolicy.js"; @@ -133,6 +134,7 @@ declare const fullApi: ApiFromModules<{ "lib/aiGateway": typeof lib_aiGateway; "lib/authWrappers": typeof lib_authWrappers; "lib/notificationPreferences": typeof lib_notificationPreferences; + "lib/seriesRuntimeAdapter": typeof lib_seriesRuntimeAdapter; messages: typeof messages; messengerSettings: typeof messengerSettings; "migrations/backfillHelpCenterAccessPolicy": typeof migrations_backfillHelpCenterAccessPolicy; diff --git a/packages/convex/convex/events.ts b/packages/convex/convex/events.ts index 17224b6..b823783 100644 --- a/packages/convex/convex/events.ts +++ b/packages/convex/convex/events.ts @@ -6,6 +6,10 @@ import { resolveVisitorFromSession } from "./widgetSessions"; import { getAuthenticatedUserFromSession } from "./auth"; import { hasPermission, requirePermission } from "./permissions"; import { eventPropertiesValidator } from "./validators"; +import { + scheduleSeriesEvaluateEnrollment, + scheduleSeriesResumeWaitingForEvent, +} from "./lib/seriesRuntimeAdapter"; const AUTO_EVENT_TYPES = ["page_view", "screen_view", "session_start", "session_end"] as const; type AutoEventType = (typeof AUTO_EVENT_TYPES)[number]; @@ -22,7 +26,7 @@ async function scheduleSeriesEventRuntime( eventName: string; } ): Promise { - await ctx.scheduler.runAfter(0, (internal as any).series.evaluateEnrollmentForVisitor, { + await scheduleSeriesEvaluateEnrollment(ctx, { workspaceId: args.workspaceId, visitorId: args.visitorId, triggerContext: { @@ -31,7 +35,7 @@ async function scheduleSeriesEventRuntime( }, }); - await ctx.scheduler.runAfter(0, (internal as any).series.resumeWaitingForEvent, { + await scheduleSeriesResumeWaitingForEvent(ctx, { workspaceId: args.workspaceId, visitorId: args.visitorId, eventName: args.eventName, diff --git a/packages/convex/convex/lib/authWrappers.ts b/packages/convex/convex/lib/authWrappers.ts index 7345d03..cdbf5dd 100644 --- a/packages/convex/convex/lib/authWrappers.ts +++ b/packages/convex/convex/lib/authWrappers.ts @@ -18,6 +18,9 @@ export type AuthenticatedUser = NonNullable< Awaited> >; +type WrappedArgs = ObjectType & + Record; + type WorkspaceResolver = ( ctx: Ctx, args: Args, @@ -33,11 +36,11 @@ type WrapperOptions< permission?: Permission; // Allow handlers (typically "get by id" queries) to return null when an entity no longer exists. allowMissingWorkspace?: boolean; - workspaceIdArg?: keyof ObjectType & string; - resolveWorkspaceId?: WorkspaceResolver>; + workspaceIdArg?: keyof WrappedArgs & string; + resolveWorkspaceId?: WorkspaceResolver>; handler: ( ctx: Ctx & { user: AuthenticatedUser }, - args: ObjectType + args: WrappedArgs ) => Promise | ReturnValue; }; @@ -81,31 +84,22 @@ async function getActionUser(ctx: ActionCtx): Promise { export function authMutation( options: WrapperOptions ) { - const workspaceIdArg = - options.workspaceIdArg ?? ("workspaceId" as keyof ObjectType & string); + type Args = WrappedArgs; + const workspaceIdArg = options.workspaceIdArg ?? ("workspaceId" as keyof Args & string); return mutation({ args: options.args, - handler: async (ctx: MutationCtx, args: ObjectType) => { + handler: async (ctx: MutationCtx, args: Args) => { const user = await getAuthenticatedUserFromSession(ctx); if (!user) { throw new Error("Not authenticated"); } if (options.permission) { - const workspaceId = await getWorkspaceIdForPermission( - ctx, - args as ObjectType & Record, - user, - { - workspaceIdArg: workspaceIdArg as keyof (ObjectType & - Record) & - string, - resolveWorkspaceId: options.resolveWorkspaceId as - | WorkspaceResolver & Record> - | undefined, - } - ); + const workspaceId = await getWorkspaceIdForPermission(ctx, args, user, { + workspaceIdArg, + resolveWorkspaceId: options.resolveWorkspaceId, + }); if (!workspaceId) { if (options.allowMissingWorkspace) { return options.handler(withUser(ctx, user), args); @@ -117,37 +111,28 @@ export function authMutation( options: WrapperOptions ) { - const workspaceIdArg = - options.workspaceIdArg ?? ("workspaceId" as keyof ObjectType & string); + type Args = WrappedArgs; + const workspaceIdArg = options.workspaceIdArg ?? ("workspaceId" as keyof Args & string); return query({ args: options.args, - handler: async (ctx: QueryCtx, args: ObjectType) => { + handler: async (ctx: QueryCtx, args: Args) => { const user = await getAuthenticatedUserFromSession(ctx); if (!user) { throw new Error("Not authenticated"); } if (options.permission) { - const workspaceId = await getWorkspaceIdForPermission( - ctx, - args as ObjectType & Record, - user, - { - workspaceIdArg: workspaceIdArg as keyof (ObjectType & - Record) & - string, - resolveWorkspaceId: options.resolveWorkspaceId as - | WorkspaceResolver & Record> - | undefined, - } - ); + const workspaceId = await getWorkspaceIdForPermission(ctx, args, user, { + workspaceIdArg, + resolveWorkspaceId: options.resolveWorkspaceId, + }); if (!workspaceId) { if (options.allowMissingWorkspace) { return options.handler(withUser(ctx, user), args); @@ -159,34 +144,25 @@ export function authQuery( options: WrapperOptions ) { - const workspaceIdArg = - options.workspaceIdArg ?? ("workspaceId" as keyof ObjectType & string); + type Args = WrappedArgs; + const workspaceIdArg = options.workspaceIdArg ?? ("workspaceId" as keyof Args & string); return action({ args: options.args, - handler: async (ctx: ActionCtx, args: ObjectType) => { + handler: async (ctx: ActionCtx, args: Args) => { const user = await getActionUser(ctx); if (options.permission) { - const workspaceId = await getWorkspaceIdForPermission( - ctx, - args as ObjectType & Record, - user, - { - workspaceIdArg: workspaceIdArg as keyof (ObjectType & - Record) & - string, - resolveWorkspaceId: options.resolveWorkspaceId as - | WorkspaceResolver & Record> - | undefined, - } - ); + const workspaceId = await getWorkspaceIdForPermission(ctx, args, user, { + workspaceIdArg, + resolveWorkspaceId: options.resolveWorkspaceId, + }); if (!workspaceId) { if (options.allowMissingWorkspace) { return options.handler(withUser(ctx, user), args); @@ -202,5 +178,5 @@ export function authAction; +}; + +export async function scheduleSeriesEvaluateEnrollment( + ctx: MutationCtx, + args: { + workspaceId: Id<"workspaces">; + visitorId: Id<"visitors">; + triggerContext: SeriesEntryTriggerContext; + } +): Promise { + await ctx.scheduler.runAfter(0, internal.series.evaluateEnrollmentForVisitor, args); +} + +export async function scheduleSeriesResumeWaitingForEvent( + ctx: MutationCtx, + args: { + workspaceId: Id<"workspaces">; + visitorId: Id<"visitors">; + eventName: string; + } +): Promise { + await ctx.scheduler.runAfter(0, internal.series.resumeWaitingForEvent, args); +} + +export async function scheduleSeriesProcessProgress( + ctx: MutationCtx, + args: { + delayMs: number; + progressId: Id<"seriesProgress">; + } +): Promise { + await ctx.scheduler.runAfter(args.delayMs, internal.series.processProgress, { + progressId: args.progressId, + }); +} + +export async function runSeriesEvaluateEntry( + ctx: MutationCtx, + args: { + seriesId: Id<"series">; + visitorId: Id<"visitors">; + triggerContext?: SeriesEntryTriggerContext; + } +): Promise { + return await ctx.runMutation(internal.series.evaluateEntry, args); +} diff --git a/packages/convex/convex/outboundMessages.ts b/packages/convex/convex/outboundMessages.ts index aa6f684..144af1c 100644 --- a/packages/convex/convex/outboundMessages.ts +++ b/packages/convex/convex/outboundMessages.ts @@ -7,7 +7,7 @@ import { authAction, authMutation, authQuery } from "./lib/authWrappers"; import { getAuthenticatedUserFromSession } from "./auth"; import { requirePermission } from "./permissions"; import { resolveVisitorFromSession } from "./widgetSessions"; -import { audienceRulesValidator } from "./validators"; +import { audienceRulesOrSegmentValidator, audienceRulesValidator } from "./validators"; // Task 2.1: Create outbound message export const create = authMutation({ @@ -580,7 +580,7 @@ export const sendPushForCampaign = authAction({ data: { type: "outbound_message", messageId: args.messageId, - imageUrl: message.content.imageUrl, + ...(message.content.imageUrl ? { imageUrl: message.content.imageUrl } : {}), }, recipientVisitorIds: visitorIds, eventKey: `outbound_message:${args.messageId}:${message.updatedAt}`, @@ -599,7 +599,7 @@ export const sendPushForCampaign = authAction({ export const getEligibleVisitorsForPush = authQuery({ args: { workspaceId: v.id("workspaces"), - targeting: v.optional(audienceRulesValidator), + targeting: v.optional(audienceRulesOrSegmentValidator), }, permission: "settings.workspace", handler: async (ctx, args) => { diff --git a/packages/convex/convex/series.ts b/packages/convex/convex/series.ts index 52e449e..725cc89 100644 --- a/packages/convex/convex/series.ts +++ b/packages/convex/convex/series.ts @@ -7,12 +7,15 @@ import { MutationCtx, QueryCtx, } from "./_generated/server"; -import { internal } from "./_generated/api"; import { Doc, Id } from "./_generated/dataModel"; import { evaluateRule, AudienceRule, validateAudienceRule } from "./audienceRules"; import { getAuthenticatedUserFromSession } from "./auth"; import { hasPermission, requirePermission } from "./permissions"; import { audienceRulesOrSegmentValidator, seriesRulesValidator } from "./validators"; +import { + runSeriesEvaluateEntry, + scheduleSeriesProcessProgress, +} from "./lib/seriesRuntimeAdapter"; const DEFAULT_SERIES_LIST_LIMIT = 100; const MAX_SERIES_LIST_LIMIT = 500; @@ -1347,7 +1350,8 @@ async function processProgressRecord( idempotencyKeyContext: `${progress._id}:${block._id}:retry:${attemptCount}`, }); - await ctx.scheduler.runAfter(retryDelay, (internal as any).series.processProgress, { + await scheduleSeriesProcessProgress(ctx, { + delayMs: retryDelay, progressId: progress._id, }); return { processed: true, status: "waiting", reason: "retry_scheduled" }; @@ -1371,7 +1375,8 @@ async function processProgressRecord( if (execution.waitUntil) { const delay = Math.max(0, execution.waitUntil - now); - await ctx.scheduler.runAfter(delay, (internal as any).series.processProgress, { + await scheduleSeriesProcessProgress(ctx, { + delayMs: delay, progressId: progress._id, }); } @@ -1998,7 +2003,7 @@ export const evaluateEnrollmentForVisitor = internalMutation({ let entered = 0; for (const series of activeSeries) { - const result = await ctx.runMutation((internal as any).series.evaluateEntry, { + const result = await runSeriesEvaluateEntry(ctx, { seriesId: series._id, visitorId: args.visitorId, triggerContext: args.triggerContext, diff --git a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts new file mode 100644 index 0000000..a919fc4 --- /dev/null +++ b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const TARGET_FILES = [ + "../convex/events.ts", + "../convex/series.ts", + "../convex/lib/authWrappers.ts", +]; + +describe("runtime type hardening guards", () => { + it("prevents broad any-casts in covered runtime-critical modules", () => { + for (const relativePath of TARGET_FILES) { + const source = readFileSync(new URL(relativePath, import.meta.url), "utf8"); + expect(source).not.toMatch(/\bas any\b/); + } + }); + + it("routes series runtime internal calls through typed adapters", () => { + const eventsSource = readFileSync(new URL("../convex/events.ts", import.meta.url), "utf8"); + const seriesSource = readFileSync(new URL("../convex/series.ts", import.meta.url), "utf8"); + + expect(eventsSource).not.toContain("(internal as any).series"); + expect(seriesSource).not.toContain("(internal as any).series"); + expect(eventsSource).toContain("scheduleSeriesEvaluateEnrollment"); + expect(eventsSource).toContain("scheduleSeriesResumeWaitingForEvent"); + expect(seriesSource).toContain("scheduleSeriesProcessProgress"); + expect(seriesSource).toContain("runSeriesEvaluateEntry"); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9f93a5e..f81d16a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -532,6 +532,58 @@ export interface CarouselImpression { createdAt: number; } +export type AudienceRuleCountOperator = "at_least" | "at_most" | "exactly"; + +export interface AudienceRuleEventFilter { + name: string; + countOperator?: AudienceRuleCountOperator; + count?: number; + withinDays?: number; +} + +export type AudienceRulePropertySource = "system" | "custom" | "event"; + +export interface AudienceRulePropertyReference { + source: AudienceRulePropertySource; + key: string; + eventFilter?: AudienceRuleEventFilter; +} + +export type AudienceRuleOperator = + | "equals" + | "not_equals" + | "contains" + | "not_contains" + | "starts_with" + | "ends_with" + | "greater_than" + | "less_than" + | "greater_than_or_equals" + | "less_than_or_equals" + | "is_set" + | "is_not_set"; + +export interface AudienceRuleCondition { + type: "condition"; + property: AudienceRulePropertyReference; + operator: AudienceRuleOperator; + value?: string | number | boolean; +} + +export interface AudienceRuleGroup { + type: "group"; + operator: "and" | "or"; + conditions: AudienceRule[]; +} + +export type AudienceRule = AudienceRuleCondition | AudienceRuleGroup; + +export interface SegmentReference { + segmentId: string; +} + +export type AudienceRuleOrSegment = AudienceRule | SegmentReference; + // Series Types export type SeriesId = string; export type SeriesBlockId = string; @@ -563,9 +615,9 @@ export interface Series { workspaceId: WorkspaceId; name: string; description?: string; - entryRules?: unknown; - exitRules?: unknown; - goalRules?: unknown; + entryRules?: AudienceRuleOrSegment; + exitRules?: AudienceRuleOrSegment; + goalRules?: AudienceRuleOrSegment; status: SeriesStatus; stats?: SeriesStats; createdAt: number; @@ -578,7 +630,7 @@ export interface SeriesBlockPosition { } export interface SeriesBlockConfig { - rules?: unknown; + rules?: AudienceRuleOrSegment; waitType?: WaitType; waitDuration?: number; waitUnit?: WaitUnit; From c612275fec7c57a59a72fc85f2970c6aca18a7d9 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 15:40:28 +0000 Subject: [PATCH 07/91] archive proposals --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../convex-notification-modularity/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../runtime-type-safety-hardening/spec.md | 0 .../tasks.md | 0 .../convex-notification-modularity/spec.md | 47 +++++++++++++++++++ .../runtime-type-safety-hardening/spec.md | 33 +++++++++++++ 12 files changed, 80 insertions(+) rename openspec/changes/{modularize-convex-notifications-domain => archive/2026-03-05-modularize-convex-notifications-domain}/.openspec.yaml (100%) rename openspec/changes/{modularize-convex-notifications-domain => archive/2026-03-05-modularize-convex-notifications-domain}/design.md (100%) rename openspec/changes/{modularize-convex-notifications-domain => archive/2026-03-05-modularize-convex-notifications-domain}/proposal.md (100%) rename openspec/changes/{modularize-convex-notifications-domain => archive/2026-03-05-modularize-convex-notifications-domain}/specs/convex-notification-modularity/spec.md (100%) rename openspec/changes/{modularize-convex-notifications-domain => archive/2026-03-05-modularize-convex-notifications-domain}/tasks.md (100%) rename openspec/changes/{tighten-runtime-types-without-any => archive/2026-03-05-tighten-runtime-types-without-any}/.openspec.yaml (100%) rename openspec/changes/{tighten-runtime-types-without-any => archive/2026-03-05-tighten-runtime-types-without-any}/design.md (100%) rename openspec/changes/{tighten-runtime-types-without-any => archive/2026-03-05-tighten-runtime-types-without-any}/proposal.md (100%) rename openspec/changes/{tighten-runtime-types-without-any => archive/2026-03-05-tighten-runtime-types-without-any}/specs/runtime-type-safety-hardening/spec.md (100%) rename openspec/changes/{tighten-runtime-types-without-any => archive/2026-03-05-tighten-runtime-types-without-any}/tasks.md (100%) create mode 100644 openspec/specs/convex-notification-modularity/spec.md create mode 100644 openspec/specs/runtime-type-safety-hardening/spec.md diff --git a/openspec/changes/modularize-convex-notifications-domain/.openspec.yaml b/openspec/changes/archive/2026-03-05-modularize-convex-notifications-domain/.openspec.yaml similarity index 100% rename from openspec/changes/modularize-convex-notifications-domain/.openspec.yaml rename to openspec/changes/archive/2026-03-05-modularize-convex-notifications-domain/.openspec.yaml diff --git a/openspec/changes/modularize-convex-notifications-domain/design.md b/openspec/changes/archive/2026-03-05-modularize-convex-notifications-domain/design.md similarity index 100% rename from openspec/changes/modularize-convex-notifications-domain/design.md rename to openspec/changes/archive/2026-03-05-modularize-convex-notifications-domain/design.md diff --git a/openspec/changes/modularize-convex-notifications-domain/proposal.md b/openspec/changes/archive/2026-03-05-modularize-convex-notifications-domain/proposal.md similarity index 100% rename from openspec/changes/modularize-convex-notifications-domain/proposal.md rename to openspec/changes/archive/2026-03-05-modularize-convex-notifications-domain/proposal.md diff --git a/openspec/changes/modularize-convex-notifications-domain/specs/convex-notification-modularity/spec.md b/openspec/changes/archive/2026-03-05-modularize-convex-notifications-domain/specs/convex-notification-modularity/spec.md similarity index 100% rename from openspec/changes/modularize-convex-notifications-domain/specs/convex-notification-modularity/spec.md rename to openspec/changes/archive/2026-03-05-modularize-convex-notifications-domain/specs/convex-notification-modularity/spec.md diff --git a/openspec/changes/modularize-convex-notifications-domain/tasks.md b/openspec/changes/archive/2026-03-05-modularize-convex-notifications-domain/tasks.md similarity index 100% rename from openspec/changes/modularize-convex-notifications-domain/tasks.md rename to openspec/changes/archive/2026-03-05-modularize-convex-notifications-domain/tasks.md diff --git a/openspec/changes/tighten-runtime-types-without-any/.openspec.yaml b/openspec/changes/archive/2026-03-05-tighten-runtime-types-without-any/.openspec.yaml similarity index 100% rename from openspec/changes/tighten-runtime-types-without-any/.openspec.yaml rename to openspec/changes/archive/2026-03-05-tighten-runtime-types-without-any/.openspec.yaml diff --git a/openspec/changes/tighten-runtime-types-without-any/design.md b/openspec/changes/archive/2026-03-05-tighten-runtime-types-without-any/design.md similarity index 100% rename from openspec/changes/tighten-runtime-types-without-any/design.md rename to openspec/changes/archive/2026-03-05-tighten-runtime-types-without-any/design.md diff --git a/openspec/changes/tighten-runtime-types-without-any/proposal.md b/openspec/changes/archive/2026-03-05-tighten-runtime-types-without-any/proposal.md similarity index 100% rename from openspec/changes/tighten-runtime-types-without-any/proposal.md rename to openspec/changes/archive/2026-03-05-tighten-runtime-types-without-any/proposal.md diff --git a/openspec/changes/tighten-runtime-types-without-any/specs/runtime-type-safety-hardening/spec.md b/openspec/changes/archive/2026-03-05-tighten-runtime-types-without-any/specs/runtime-type-safety-hardening/spec.md similarity index 100% rename from openspec/changes/tighten-runtime-types-without-any/specs/runtime-type-safety-hardening/spec.md rename to openspec/changes/archive/2026-03-05-tighten-runtime-types-without-any/specs/runtime-type-safety-hardening/spec.md diff --git a/openspec/changes/tighten-runtime-types-without-any/tasks.md b/openspec/changes/archive/2026-03-05-tighten-runtime-types-without-any/tasks.md similarity index 100% rename from openspec/changes/tighten-runtime-types-without-any/tasks.md rename to openspec/changes/archive/2026-03-05-tighten-runtime-types-without-any/tasks.md diff --git a/openspec/specs/convex-notification-modularity/spec.md b/openspec/specs/convex-notification-modularity/spec.md new file mode 100644 index 0000000..ca5a680 --- /dev/null +++ b/openspec/specs/convex-notification-modularity/spec.md @@ -0,0 +1,47 @@ +# convex-notification-modularity Specification + +## Purpose +TBD - created by archiving change modularize-convex-notifications-domain. Update Purpose after archive. +## Requirements +### Requirement: Notification orchestration MUST be implemented through explicit domain modules + +The notifications domain SHALL separate recipient lookup, routing/deduping, channel dispatch, and event emitters into explicit modules with stable interfaces. + +#### Scenario: Contributor updates recipient resolution logic + +- **WHEN** recipient lookup rules change for a notification type +- **THEN** the change SHALL be contained within recipient-resolution modules +- **AND** routing and channel dispatch modules SHALL not require modification + +#### Scenario: Contributor updates event payload composition + +- **WHEN** a ticket or chat event payload format is adjusted +- **THEN** the change SHALL be implemented in event-emitter modules +- **AND** recipient resolution and channel dispatch modules SHALL remain unaffected + +### Requirement: Notification refactor MUST preserve routing and debounce semantics + +The notifications refactor MUST preserve existing routing outcomes, dedupe behavior, and debounce timing semantics for current notification events. + +#### Scenario: New visitor message triggers agent notifications + +- **WHEN** a visitor sends a new message in a conversation +- **THEN** the system SHALL continue routing agent notifications to the same audience and channels as before refactor +- **AND** event dedupe keys SHALL remain semantically equivalent + +#### Scenario: Support reply triggers debounced visitor email + +- **WHEN** support sends one or more messages within the debounce window +- **THEN** the system SHALL preserve existing batching/debounce behavior for visitor email notifications +- **AND** message-thread content selection SHALL remain behaviorally equivalent + +### Requirement: Notification modules MUST expose typed internal contracts + +Notification helper modules MUST use explicit typed interfaces for shared context and payload contracts to reduce `any`-based coupling. + +#### Scenario: Shared helper is consumed by multiple notification modules + +- **WHEN** a shared helper is imported by emitter and dispatch modules +- **THEN** the helper SHALL expose typed inputs/outputs +- **AND** unsafe broad typing in orchestration-critical helper paths SHALL be reduced + diff --git a/openspec/specs/runtime-type-safety-hardening/spec.md b/openspec/specs/runtime-type-safety-hardening/spec.md new file mode 100644 index 0000000..ee5a29d --- /dev/null +++ b/openspec/specs/runtime-type-safety-hardening/spec.md @@ -0,0 +1,33 @@ +# runtime-type-safety-hardening Specification + +## Purpose +TBD - created by archiving change tighten-runtime-types-without-any. Update Purpose after archive. +## Requirements +### Requirement: Runtime-critical modules MUST use explicit typed contracts at dynamic boundaries + +Covered runtime-critical modules SHALL route dynamic internal calls through typed adapters or constrained interfaces instead of repeated broad inline casts. + +#### Scenario: Runtime schedules internal workflow function + +- **WHEN** a runtime-critical module schedules or invokes an internal workflow handler +- **THEN** the invocation SHALL use a typed adapter contract +- **AND** repeated broad inline cast patterns SHALL not expand in covered files + +### Requirement: Shared types consumed by runtime-critical paths MUST avoid broad unknown payloads when structure is known + +Shared type definitions used by runtime-critical logic MUST model known payload shape with explicit types or constrained unions. + +#### Scenario: Runtime logic consumes shared payload field + +- **WHEN** runtime-critical code reads a shared payload field with known structure +- **THEN** that field SHALL be typed with an explicit shape rather than unconstrained `unknown` + +### Requirement: Type safety hardening MUST preserve existing runtime behavior + +Type tightening changes MUST preserve functional behavior for covered auth, event, and series runtime paths. + +#### Scenario: Covered runtime path executes after hardening + +- **WHEN** covered auth/event/series flows run after type hardening +- **THEN** runtime outcomes SHALL remain behaviorally equivalent to pre-change behavior + From 1efae89062503acec254b7ec3414218d828c8bc2 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 15:40:46 +0000 Subject: [PATCH 08/91] Split convex series engine --- .../split-convex-series-engine/tasks.md | 18 +- packages/convex/convex/_generated/api.d.ts | 12 + packages/convex/convex/events.ts | 2 +- .../convex/convex/lib/seriesRuntimeAdapter.ts | 73 +- packages/convex/convex/series.ts | 2462 +---------------- packages/convex/convex/series/README.md | 26 + packages/convex/convex/series/authoring.ts | 784 ++++++ packages/convex/convex/series/contracts.ts | 124 + packages/convex/convex/series/runtime.ts | 1078 ++++++++ packages/convex/convex/series/scheduler.ts | 89 + packages/convex/convex/series/shared.ts | 183 ++ packages/convex/convex/series/telemetry.ts | 261 ++ packages/convex/convex/testing/helpers.ts | 11 +- packages/convex/convex/visitors.ts | 3 +- .../tests/runtimeTypeHardeningGuard.test.ts | 14 +- 15 files changed, 2625 insertions(+), 2515 deletions(-) create mode 100644 packages/convex/convex/series/README.md create mode 100644 packages/convex/convex/series/authoring.ts create mode 100644 packages/convex/convex/series/contracts.ts create mode 100644 packages/convex/convex/series/runtime.ts create mode 100644 packages/convex/convex/series/scheduler.ts create mode 100644 packages/convex/convex/series/shared.ts create mode 100644 packages/convex/convex/series/telemetry.ts diff --git a/openspec/changes/split-convex-series-engine/tasks.md b/openspec/changes/split-convex-series-engine/tasks.md index c727d10..7b7a0f6 100644 --- a/openspec/changes/split-convex-series-engine/tasks.md +++ b/openspec/changes/split-convex-series-engine/tasks.md @@ -1,20 +1,20 @@ ## 1. Module Boundary Setup -- [ ] 1.1 Define target module layout for series authoring, runtime, scheduler, and telemetry responsibilities. -- [ ] 1.2 Add typed shared contracts used by cross-module runtime and scheduler flows. +- [x] 1.1 Define target module layout for series authoring, runtime, scheduler, and telemetry responsibilities. +- [x] 1.2 Add typed shared contracts used by cross-module runtime and scheduler flows. ## 2. Incremental Extraction -- [ ] 2.1 Extract pure helpers and authoring handlers into dedicated modules while preserving export signatures. -- [ ] 2.2 Extract telemetry updates into a dedicated module and wire through facade exports. -- [ ] 2.3 Extract runtime progression and scheduler integration behind typed adapters. +- [x] 2.1 Extract pure helpers and authoring handlers into dedicated modules while preserving export signatures. +- [x] 2.2 Extract telemetry updates into a dedicated module and wire through facade exports. +- [x] 2.3 Extract runtime progression and scheduler integration behind typed adapters. ## 3. Parity Validation -- [ ] 3.1 Add tests covering retries, wait/resume transitions, and terminal state progression parity. -- [ ] 3.2 Run targeted Convex typecheck/tests for series and event integration paths. +- [x] 3.1 Add tests covering retries, wait/resume transitions, and terminal state progression parity. +- [x] 3.2 Run targeted Convex typecheck/tests for series and event integration paths. ## 4. Cleanup -- [ ] 4.1 Remove obsolete monolith branches from `series.ts` after module migration. -- [ ] 4.2 Document module ownership and extension points for future series changes. +- [x] 4.1 Remove obsolete monolith branches from `series.ts` after module migration. +- [x] 4.2 Document module ownership and extension points for future series changes. diff --git a/packages/convex/convex/_generated/api.d.ts b/packages/convex/convex/_generated/api.d.ts index dbb95ea..523332e 100644 --- a/packages/convex/convex/_generated/api.d.ts +++ b/packages/convex/convex/_generated/api.d.ts @@ -67,6 +67,12 @@ import type * as pushTokens from "../pushTokens.js"; import type * as reporting from "../reporting.js"; import type * as segments from "../segments.js"; import type * as series from "../series.js"; +import type * as series_authoring from "../series/authoring.js"; +import type * as series_contracts from "../series/contracts.js"; +import type * as series_runtime from "../series/runtime.js"; +import type * as series_scheduler from "../series/scheduler.js"; +import type * as series_shared from "../series/shared.js"; +import type * as series_telemetry from "../series/telemetry.js"; import type * as setup from "../setup.js"; import type * as snippets from "../snippets.js"; import type * as suggestions from "../suggestions.js"; @@ -161,6 +167,12 @@ declare const fullApi: ApiFromModules<{ reporting: typeof reporting; segments: typeof segments; series: typeof series; + "series/authoring": typeof series_authoring; + "series/contracts": typeof series_contracts; + "series/runtime": typeof series_runtime; + "series/scheduler": typeof series_scheduler; + "series/shared": typeof series_shared; + "series/telemetry": typeof series_telemetry; setup: typeof setup; snippets: typeof snippets; suggestions: typeof suggestions; diff --git a/packages/convex/convex/events.ts b/packages/convex/convex/events.ts index b823783..4863230 100644 --- a/packages/convex/convex/events.ts +++ b/packages/convex/convex/events.ts @@ -9,7 +9,7 @@ import { eventPropertiesValidator } from "./validators"; import { scheduleSeriesEvaluateEnrollment, scheduleSeriesResumeWaitingForEvent, -} from "./lib/seriesRuntimeAdapter"; +} from "./series/scheduler"; const AUTO_EVENT_TYPES = ["page_view", "screen_view", "session_start", "session_end"] as const; type AutoEventType = (typeof AUTO_EVENT_TYPES)[number]; diff --git a/packages/convex/convex/lib/seriesRuntimeAdapter.ts b/packages/convex/convex/lib/seriesRuntimeAdapter.ts index 2642403..d7a5efd 100644 --- a/packages/convex/convex/lib/seriesRuntimeAdapter.ts +++ b/packages/convex/convex/lib/seriesRuntimeAdapter.ts @@ -1,62 +1,11 @@ -import type { Id } from "../_generated/dataModel"; -import type { MutationCtx } from "../_generated/server"; -import { internal } from "../_generated/api"; - -export type SeriesEntryTriggerContext = { - source: "event" | "auto_event" | "visitor_attribute_changed" | "visitor_state_changed"; - eventName?: string; - attributeKey?: string; - fromValue?: string; - toValue?: string; -}; - -export type SeriesEvaluateEntryResult = { - entered: boolean; - reason?: string; - progressId?: Id<"seriesProgress">; -}; - -export async function scheduleSeriesEvaluateEnrollment( - ctx: MutationCtx, - args: { - workspaceId: Id<"workspaces">; - visitorId: Id<"visitors">; - triggerContext: SeriesEntryTriggerContext; - } -): Promise { - await ctx.scheduler.runAfter(0, internal.series.evaluateEnrollmentForVisitor, args); -} - -export async function scheduleSeriesResumeWaitingForEvent( - ctx: MutationCtx, - args: { - workspaceId: Id<"workspaces">; - visitorId: Id<"visitors">; - eventName: string; - } -): Promise { - await ctx.scheduler.runAfter(0, internal.series.resumeWaitingForEvent, args); -} - -export async function scheduleSeriesProcessProgress( - ctx: MutationCtx, - args: { - delayMs: number; - progressId: Id<"seriesProgress">; - } -): Promise { - await ctx.scheduler.runAfter(args.delayMs, internal.series.processProgress, { - progressId: args.progressId, - }); -} - -export async function runSeriesEvaluateEntry( - ctx: MutationCtx, - args: { - seriesId: Id<"series">; - visitorId: Id<"visitors">; - triggerContext?: SeriesEntryTriggerContext; - } -): Promise { - return await ctx.runMutation(internal.series.evaluateEntry, args); -} +export { + runSeriesEvaluateEntry, + runSeriesEvaluateEnrollmentForVisitor, + runSeriesProcessWaitingProgress, + runSeriesResumeWaitingForEvent, + scheduleSeriesEvaluateEnrollment, + scheduleSeriesProcessProgress, + scheduleSeriesResumeWaitingForEvent, +} from "../series/scheduler"; + +export type { SeriesEntryTriggerContext, SeriesEvaluateEntryResult } from "../series/scheduler"; diff --git a/packages/convex/convex/series.ts b/packages/convex/convex/series.ts index 725cc89..f6b6053 100644 --- a/packages/convex/convex/series.ts +++ b/packages/convex/convex/series.ts @@ -1,2434 +1,28 @@ -import { v } from "convex/values"; -import { - internalMutation, - internalQuery, - mutation, - query, - MutationCtx, - QueryCtx, -} from "./_generated/server"; -import { Doc, Id } from "./_generated/dataModel"; -import { evaluateRule, AudienceRule, validateAudienceRule } from "./audienceRules"; -import { getAuthenticatedUserFromSession } from "./auth"; -import { hasPermission, requirePermission } from "./permissions"; -import { audienceRulesOrSegmentValidator, seriesRulesValidator } from "./validators"; -import { - runSeriesEvaluateEntry, - scheduleSeriesProcessProgress, -} from "./lib/seriesRuntimeAdapter"; - -const DEFAULT_SERIES_LIST_LIMIT = 100; -const MAX_SERIES_LIST_LIMIT = 500; -const DEFAULT_GRAPH_ITEM_LIMIT = 500; -const MAX_GRAPH_ITEM_LIMIT = 2000; -const DEFAULT_HISTORY_LIMIT = 500; -const MAX_HISTORY_LIMIT = 5000; -const DEFAULT_PROGRESS_SCAN_LIMIT = 5000; -const MAX_PROGRESS_SCAN_LIMIT = 20000; -const DEFAULT_WAITING_BATCH_LIMIT = 1000; -const MAX_WAITING_BATCH_LIMIT = 5000; -const MAX_SERIES_EXECUTION_DEPTH = 50; -const MAX_BLOCK_EXECUTION_ATTEMPTS = 3; -const WAIT_RETRY_BASE_DELAY_MS = 30_000; - -const seriesEntryTriggerValidator = 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 seriesBlockConfigValidator = v.object({ - rules: v.optional(seriesRulesValidator), - waitType: v.optional( - v.union(v.literal("duration"), v.literal("until_date"), v.literal("until_event")) - ), - waitDuration: v.optional(v.number()), - waitUnit: v.optional(v.union(v.literal("minutes"), v.literal("hours"), v.literal("days"))), - waitUntilDate: v.optional(v.number()), - waitUntilEvent: v.optional(v.string()), - contentId: v.optional(v.string()), - subject: v.optional(v.string()), - body: v.optional(v.string()), - title: v.optional(v.string()), - tagAction: v.optional(v.union(v.literal("add"), v.literal("remove"))), - tagName: v.optional(v.string()), -}); - -const seriesBlockTypeValidator = v.union( - v.literal("rule"), - v.literal("wait"), - v.literal("email"), - v.literal("push"), - v.literal("chat"), - v.literal("post"), - v.literal("carousel"), - v.literal("tag") -); - -type SeriesProgressStatus = Doc<"seriesProgress">["status"]; -type SeriesEntryTrigger = NonNullable["entryTriggers"]>[number]; - -type SeriesTriggerContext = { - source: SeriesEntryTrigger["source"]; - eventName?: string; - attributeKey?: string; - fromValue?: string; - toValue?: string; -}; - -type ReadinessIssue = { - code: string; - message: string; - remediation: string; - blockId?: Id<"seriesBlocks">; - connectionId?: Id<"seriesConnections">; -}; - -type SeriesReadinessResult = { - blockers: ReadinessIssue[]; - warnings: ReadinessIssue[]; - isReady: boolean; -}; - -type BlockExecutionResult = { - status: "completed" | "waiting" | "failed"; - nextBlockId?: Id<"seriesBlocks">; - waitUntil?: number; - waitEventName?: string; - error?: string; - deliveryAttempted?: boolean; - deliveryFailed?: boolean; - telemetryPatch?: Partial>; -}; - -function nowTs(): number { - return Date.now(); -} - -function normalizeSeriesStats(series: Doc<"series">) { - return { - entered: series.stats?.entered ?? 0, - active: series.stats?.active ?? 0, - waiting: series.stats?.waiting ?? 0, - completed: series.stats?.completed ?? 0, - exited: series.stats?.exited ?? 0, - goalReached: series.stats?.goalReached ?? 0, - failed: series.stats?.failed ?? 0, - }; -} - -function normalizeText(value: unknown): string | undefined { - if (value === undefined || value === null) { - return undefined; - } - return String(value); -} - -function normalizeTagName(value: string): string { - return value.trim().toLowerCase(); -} - -function isTerminalProgressStatus(status: SeriesProgressStatus): boolean { - return ( - status === "completed" || - status === "exited" || - status === "goal_reached" || - status === "failed" - ); -} - -function getRetryDelayMs(attempt: number): number { - return WAIT_RETRY_BASE_DELAY_MS * Math.max(1, attempt); -} - -function sortConnectionsDeterministically(connections: Doc<"seriesConnections">[]) { - return [...connections].sort((left, right) => { - if (left.createdAt !== right.createdAt) { - return left.createdAt - right.createdAt; - } - return left._id.toString().localeCompare(right._id.toString()); - }); -} - -function sortProgressDeterministically(progressList: Doc<"seriesProgress">[]) { - return [...progressList].sort((left, right) => { - const leftWait = left.waitUntil ?? Number.MAX_SAFE_INTEGER; - const rightWait = right.waitUntil ?? Number.MAX_SAFE_INTEGER; - if (leftWait !== rightWait) { - return leftWait - rightWait; - } - return left._id.toString().localeCompare(right._id.toString()); - }); -} - -function clampLimit(limit: number | undefined, defaultValue: number, maxValue: number): number { - const normalized = limit ?? defaultValue; - if (!Number.isFinite(normalized) || normalized <= 0) { - return defaultValue; - } - return Math.min(Math.floor(normalized), maxValue); -} - -async function requireSeriesManagePermission( - ctx: QueryCtx | MutationCtx, - workspaceId: Id<"workspaces"> -) { - const user = await getAuthenticatedUserFromSession(ctx); - if (!user) { - throw new Error("Not authenticated"); - } - await requirePermission(ctx, user._id, workspaceId, "settings.workspace"); -} - -async function canManageSeries(ctx: QueryCtx | MutationCtx, workspaceId: Id<"workspaces">) { - const user = await getAuthenticatedUserFromSession(ctx); - if (!user) { - return false; - } - return await hasPermission(ctx, user._id, workspaceId, "settings.workspace"); -} - -const SERIES_READINESS_BLOCKED_ERROR_CODE = "SERIES_READINESS_BLOCKED"; -const SERIES_ORCHESTRATION_GUARD_ERROR_CODE = "SERIES_ORCHESTRATION_DISABLED_BY_GUARD"; - -type SeriesStatsShape = ReturnType; -type SeriesStatsKey = keyof SeriesStatsShape; - -const SERIES_STATUS_TO_STATS_KEY: Partial> = { - active: "active", - waiting: "waiting", - completed: "completed", - exited: "exited", - goal_reached: "goalReached", - failed: "failed", -}; - -function isSeriesRuntimeEnabled(): boolean { - return process.env.OPENCOM_ENABLE_SERIES_ORCHESTRATION !== "false"; -} - -function clampNonNegative(value: number): number { - return value < 0 ? 0 : value; -} - -function createReadinessIssue( - code: string, - message: string, - remediation: string, - context?: Pick -): ReadinessIssue { - return { - code, - message, - remediation, - ...(context?.blockId ? { blockId: context.blockId } : {}), - ...(context?.connectionId ? { connectionId: context.connectionId } : {}), - }; -} - -function toStringSet(values: Array): Set { - return new Set(values.filter((value): value is string => Boolean(value))); -} - -function getOutgoingConnectionsForBlock( - connections: Doc<"seriesConnections">[], - blockId: Id<"seriesBlocks"> -): Doc<"seriesConnections">[] { - return sortConnectionsDeterministically( - connections.filter((connection) => connection.fromBlockId === blockId) - ); -} - -function findEntryBlocks( - blocks: Doc<"seriesBlocks">[], - connections: Doc<"seriesConnections">[] -): Doc<"seriesBlocks">[] { - const incoming = toStringSet(connections.map((connection) => connection.toBlockId as string)); - return blocks.filter((block) => !incoming.has(block._id as string)); -} - -function hasTextContent(value: unknown): boolean { - return typeof value === "string" && value.trim().length > 0; -} - -function serializeReadinessError(readiness: SeriesReadinessResult): string { - return JSON.stringify({ - code: SERIES_READINESS_BLOCKED_ERROR_CODE, - blockers: readiness.blockers, - warnings: readiness.warnings, - }); -} - -function serializeRuntimeGuardError(): string { - return JSON.stringify({ - code: SERIES_ORCHESTRATION_GUARD_ERROR_CODE, - message: "Series orchestration runtime is currently disabled by guard.", - }); -} - -async function loadSeriesGraph( - ctx: QueryCtx | MutationCtx, - seriesId: Id<"series"> -): Promise<{ blocks: Doc<"seriesBlocks">[]; connections: Doc<"seriesConnections">[] }> { - const blocks = await ctx.db - .query("seriesBlocks") - .withIndex("by_series", (q) => q.eq("seriesId", seriesId)) - .collect(); - - const connections = await ctx.db - .query("seriesConnections") - .withIndex("by_series", (q) => q.eq("seriesId", seriesId)) - .collect(); - - return { - blocks, - connections: sortConnectionsDeterministically(connections), - }; -} - -async function evaluateSeriesReadiness( - ctx: QueryCtx | MutationCtx, - series: Doc<"series"> -): Promise { - const blockers: ReadinessIssue[] = []; - const warnings: ReadinessIssue[] = []; - - const { blocks, connections } = await loadSeriesGraph(ctx, series._id); - - if (blocks.length === 0) { - blockers.push( - createReadinessIssue( - "SERIES_GRAPH_EMPTY", - "Series must contain at least one block before activation.", - "Add a starting block in the builder, then connect downstream steps." - ) - ); - } - - const blockIds = toStringSet(blocks.map((block) => block._id as string)); - const entryBlocks = findEntryBlocks(blocks, connections); - if (entryBlocks.length === 0 && blocks.length > 0) { - blockers.push( - createReadinessIssue( - "SERIES_NO_ENTRY_PATH", - "Series graph has no entry block.", - "Ensure at least one block has no incoming connection." - ) - ); - } - if (entryBlocks.length > 1) { - blockers.push( - createReadinessIssue( - "SERIES_MULTIPLE_ENTRY_PATHS", - "Series graph has multiple entry blocks.", - "Connect the graph so only one block remains as the unique entry point." - ) - ); - } - - for (const connection of connections) { - if ( - !blockIds.has(connection.fromBlockId as string) || - !blockIds.has(connection.toBlockId as string) - ) { - blockers.push( - createReadinessIssue( - "SERIES_INVALID_CONNECTION", - "Series graph contains a connection to a missing block.", - "Delete and recreate the invalid connection.", - { connectionId: connection._id } - ) - ); - } - } - - if (entryBlocks.length === 1) { - const reachable = new Set(); - const queue: Id<"seriesBlocks">[] = [entryBlocks[0]._id]; - while (queue.length > 0) { - const current = queue.shift(); - if (!current) continue; - const key = current as string; - if (reachable.has(key)) continue; - reachable.add(key); - for (const outgoing of getOutgoingConnectionsForBlock(connections, current)) { - queue.push(outgoing.toBlockId); - } - } - - for (const block of blocks) { - if (!reachable.has(block._id as string)) { - blockers.push( - createReadinessIssue( - "SERIES_UNREACHABLE_BLOCK", - `Block ${block.type} is unreachable from the series entry path.`, - "Connect this block into the main path or remove it.", - { blockId: block._id } - ) - ); - } - } - } - - for (const block of blocks) { - const config = block.config ?? {}; - const outgoing = getOutgoingConnectionsForBlock(connections, block._id); - - if (block.type === "rule") { - if (!config.rules || !validateAudienceRule(config.rules)) { - blockers.push( - createReadinessIssue( - "SERIES_RULE_CONFIG_INVALID", - "Rule block is missing valid audience rule conditions.", - "Configure a valid yes/no rule expression in the block editor.", - { blockId: block._id } - ) - ); - } - - const conditioned = outgoing.filter((connection) => connection.condition !== undefined); - const yesCount = conditioned.filter((connection) => connection.condition === "yes").length; - const noCount = conditioned.filter((connection) => connection.condition === "no").length; - const defaultCount = conditioned.filter( - (connection) => connection.condition === "default" - ).length; - - if (yesCount !== 1 || noCount !== 1) { - blockers.push( - createReadinessIssue( - "SERIES_RULE_BRANCHES_REQUIRED", - "Rule blocks require exactly one yes branch and one no branch.", - "Add one yes and one no connection from this rule block.", - { blockId: block._id } - ) - ); - } - - if (defaultCount > 1) { - blockers.push( - createReadinessIssue( - "SERIES_RULE_DEFAULT_BRANCH_DUPLICATE", - "Rule block has multiple default branches.", - "Keep only one default branch for deterministic fallback behavior.", - { blockId: block._id } - ) - ); - } - } else { - const invalidConditionalConnection = outgoing.find( - (connection) => connection.condition === "yes" || connection.condition === "no" - ); - if (invalidConditionalConnection) { - blockers.push( - createReadinessIssue( - "SERIES_NON_RULE_CONDITIONAL_BRANCH", - "Only rule blocks can use yes/no conditional connections.", - "Change this connection condition to default (or remove the condition).", - { connectionId: invalidConditionalConnection._id, blockId: block._id } - ) - ); - } - } - - if (block.type === "wait") { - if (!config.waitType) { - blockers.push( - createReadinessIssue( - "SERIES_WAIT_TYPE_REQUIRED", - "Wait block is missing wait type.", - "Set wait type to duration, until date, or until event.", - { blockId: block._id } - ) - ); - } - if (config.waitType === "duration") { - if ( - !Number.isFinite(config.waitDuration) || - Number(config.waitDuration) <= 0 || - !config.waitUnit - ) { - blockers.push( - createReadinessIssue( - "SERIES_WAIT_DURATION_INVALID", - "Duration wait block requires a positive duration and unit.", - "Set wait duration and choose minutes/hours/days.", - { blockId: block._id } - ) - ); - } - } - if (config.waitType === "until_date" && !Number.isFinite(config.waitUntilDate)) { - blockers.push( - createReadinessIssue( - "SERIES_WAIT_UNTIL_DATE_REQUIRED", - "Until date wait block is missing a target timestamp.", - "Set a valid target date/time for this wait block.", - { blockId: block._id } - ) - ); - } - if (config.waitType === "until_event" && !hasTextContent(config.waitUntilEvent)) { - blockers.push( - createReadinessIssue( - "SERIES_WAIT_UNTIL_EVENT_REQUIRED", - "Until event wait block is missing event name.", - "Set an event name that should resume progress.", - { blockId: block._id } - ) - ); - } - } - - if (block.type === "email") { - if (!hasTextContent(config.subject) || !hasTextContent(config.body)) { - blockers.push( - createReadinessIssue( - "SERIES_EMAIL_CONTENT_REQUIRED", - "Email block requires both subject and body.", - "Fill in email subject and body before activation.", - { blockId: block._id } - ) - ); - } - } - - if (block.type === "push") { - if (!hasTextContent(config.title) || !hasTextContent(config.body)) { - blockers.push( - createReadinessIssue( - "SERIES_PUSH_CONTENT_REQUIRED", - "Push block requires both title and body.", - "Fill in push title and body before activation.", - { blockId: block._id } - ) - ); - } - } - - if ( - (block.type === "chat" || block.type === "post" || block.type === "carousel") && - !hasTextContent(config.body) - ) { - warnings.push( - createReadinessIssue( - "SERIES_CONTENT_BODY_RECOMMENDED", - `${block.type} block has no body configured.`, - "Add body content so visitors receive a meaningful message.", - { blockId: block._id } - ) - ); - } - - if (block.type === "tag") { - if (!config.tagAction || !hasTextContent(config.tagName)) { - blockers.push( - createReadinessIssue( - "SERIES_TAG_CONFIG_REQUIRED", - "Tag block requires both tag action and tag name.", - "Choose add/remove and provide a tag name.", - { blockId: block._id } - ) - ); - } - } - - if (outgoing.length === 0 && block.type !== "wait") { - warnings.push( - createReadinessIssue( - "SERIES_PATH_TERMINATES", - `Block ${block.type} has no outgoing connection and will terminate the series path.`, - "Add a downstream connection if continuation is intended.", - { blockId: block._id } - ) - ); - } - } - - if ((series.entryTriggers?.length ?? 0) === 0) { - warnings.push( - createReadinessIssue( - "SERIES_ENTRY_TRIGGER_RECOMMENDED", - "Series has no entry triggers configured.", - "Define at least one trigger source so matching visitors can enroll automatically." - ) - ); - } - - if (blocks.some((block) => block.type === "email")) { - const emailConfig = await ctx.db - .query("emailConfigs") - .withIndex("by_workspace", (q) => q.eq("workspaceId", series.workspaceId)) - .first(); - if (!emailConfig?.enabled) { - blockers.push( - createReadinessIssue( - "SERIES_EMAIL_CHANNEL_NOT_CONFIGURED", - "Series references email blocks but email channel is not enabled.", - "Configure and enable email channel in workspace integrations settings." - ) - ); - } - } - - if (blocks.some((block) => block.type === "push")) { - const pushToken = await ctx.db - .query("visitorPushTokens") - .withIndex("by_workspace", (q) => q.eq("workspaceId", series.workspaceId)) - .first(); - if (!pushToken) { - warnings.push( - createReadinessIssue( - "SERIES_PUSH_DELIVERY_UNVERIFIED", - "Series references push blocks but no visitor push tokens are currently registered.", - "Verify push token registration in at least one target environment before activation." - ) - ); - } - } - - return { - blockers, - warnings, - isReady: blockers.length === 0, - }; -} - -async function applySeriesStatsDelta( - ctx: MutationCtx, - seriesId: Id<"series">, - delta: Partial> -): Promise { - const series = (await ctx.db.get(seriesId)) as Doc<"series"> | null; - if (!series) { - return; - } - - const stats = normalizeSeriesStats(series); - for (const [key, value] of Object.entries(delta) as Array<[SeriesStatsKey, number | undefined]>) { - if (!value) continue; - stats[key] = clampNonNegative(stats[key] + value); - } - - await ctx.db.patch(seriesId, { - stats, - updatedAt: nowTs(), - }); -} - -async function transitionProgressStatus( - ctx: MutationCtx, - progress: Doc<"seriesProgress">, - nextStatus: SeriesProgressStatus, - patch: Partial> = {} -): Promise { - const now = nowTs(); - const transitionPatch: Partial> = { - ...patch, - status: nextStatus, - }; - - if (nextStatus === "completed" && transitionPatch.completedAt === undefined) { - transitionPatch.completedAt = now; - transitionPatch.currentBlockId = undefined; - } - if (nextStatus === "exited" && transitionPatch.exitedAt === undefined) { - transitionPatch.exitedAt = now; - transitionPatch.currentBlockId = undefined; - } - if (nextStatus === "goal_reached" && transitionPatch.goalReachedAt === undefined) { - transitionPatch.goalReachedAt = now; - transitionPatch.currentBlockId = undefined; - } - if (nextStatus === "failed" && transitionPatch.failedAt === undefined) { - transitionPatch.failedAt = now; - transitionPatch.currentBlockId = undefined; - } - - await ctx.db.patch(progress._id, transitionPatch); - - if (progress.status !== nextStatus) { - const oldKey = SERIES_STATUS_TO_STATS_KEY[progress.status]; - const newKey = SERIES_STATUS_TO_STATS_KEY[nextStatus]; - const delta: Partial> = {}; - if (oldKey) { - delta[oldKey] = (delta[oldKey] ?? 0) - 1; - } - if (newKey) { - delta[newKey] = (delta[newKey] ?? 0) + 1; - } - await applySeriesStatsDelta(ctx, progress.seriesId, delta); - } -} - -async function appendProgressHistory( - ctx: MutationCtx, - progressId: Id<"seriesProgress">, - blockId: Id<"seriesBlocks">, - action: Doc<"seriesProgressHistory">["action"], - result?: Doc<"seriesProgressHistory">["result"] -): Promise { - await ctx.db.insert("seriesProgressHistory", { - progressId, - blockId, - action, - result, - createdAt: nowTs(), - }); -} - -async function upsertBlockTelemetry( - ctx: MutationCtx, - seriesId: Id<"series">, - blockId: Id<"seriesBlocks">, - patch: { - entered?: number; - completed?: number; - skipped?: number; - failed?: number; - deliveryAttempts?: number; - deliveryFailures?: number; - yesBranchCount?: number; - noBranchCount?: number; - defaultBranchCount?: number; - lastResult?: Doc<"seriesBlockTelemetry">["lastResult"]; - } -): Promise { - const existing = await ctx.db - .query("seriesBlockTelemetry") - .withIndex("by_series_block", (q) => q.eq("seriesId", seriesId).eq("blockId", blockId)) - .first(); - - if (!existing) { - await ctx.db.insert("seriesBlockTelemetry", { - seriesId, - blockId, - entered: patch.entered ?? 0, - completed: patch.completed ?? 0, - skipped: patch.skipped ?? 0, - failed: patch.failed ?? 0, - deliveryAttempts: patch.deliveryAttempts ?? 0, - deliveryFailures: patch.deliveryFailures ?? 0, - yesBranchCount: patch.yesBranchCount, - noBranchCount: patch.noBranchCount, - defaultBranchCount: patch.defaultBranchCount, - lastResult: patch.lastResult, - updatedAt: nowTs(), - }); - return; - } - - await ctx.db.patch(existing._id, { - entered: existing.entered + (patch.entered ?? 0), - completed: existing.completed + (patch.completed ?? 0), - skipped: existing.skipped + (patch.skipped ?? 0), - failed: existing.failed + (patch.failed ?? 0), - deliveryAttempts: existing.deliveryAttempts + (patch.deliveryAttempts ?? 0), - deliveryFailures: existing.deliveryFailures + (patch.deliveryFailures ?? 0), - yesBranchCount: (existing.yesBranchCount ?? 0) + (patch.yesBranchCount ?? 0), - noBranchCount: (existing.noBranchCount ?? 0) + (patch.noBranchCount ?? 0), - defaultBranchCount: (existing.defaultBranchCount ?? 0) + (patch.defaultBranchCount ?? 0), - ...(patch.lastResult !== undefined ? { lastResult: patch.lastResult } : {}), - updatedAt: nowTs(), - }); -} - -function getConnectionByCondition( - connections: Doc<"seriesConnections">[], - condition: "yes" | "no" | "default" -): Doc<"seriesConnections"> | undefined { - return connections.find((connection) => connection.condition === condition); -} - -function selectNextConnection( - connections: Doc<"seriesConnections">[], - preferredCondition?: "yes" | "no" | "default" -): Doc<"seriesConnections"> | undefined { - if (connections.length === 0) { - return undefined; - } - - if (preferredCondition) { - const preferred = getConnectionByCondition(connections, preferredCondition); - if (preferred) { - return preferred; - } - } - - const defaultBranch = getConnectionByCondition(connections, "default"); - if (defaultBranch) { - return defaultBranch; - } - - const unlabeled = connections.find((connection) => connection.condition === undefined); - if (unlabeled) { - return unlabeled; - } - - return connections[0]; -} - -function durationToMs(waitDuration: number, waitUnit: string): number { - if (waitUnit === "days") return waitDuration * 24 * 60 * 60 * 1000; - if (waitUnit === "hours") return waitDuration * 60 * 60 * 1000; - return waitDuration * 60 * 1000; -} - -function triggerMatches(trigger: SeriesEntryTrigger, context: SeriesTriggerContext): boolean { - if (trigger.source !== context.source) { - return false; - } - - if (trigger.source === "event" || trigger.source === "auto_event") { - if (!trigger.eventName) { - return true; - } - return trigger.eventName === context.eventName; - } - - if (trigger.attributeKey && trigger.attributeKey !== context.attributeKey) { - return false; - } - - if ( - trigger.fromValue !== undefined && - normalizeText(trigger.fromValue) !== normalizeText(context.fromValue) - ) { - return false; - } - - if ( - trigger.toValue !== undefined && - normalizeText(trigger.toValue) !== normalizeText(context.toValue) - ) { - return false; - } - - return true; -} - -function seriesAcceptsTrigger( - series: Doc<"series">, - triggerContext?: SeriesTriggerContext -): boolean { - if (!series.entryTriggers || series.entryTriggers.length === 0) { - return true; - } - - if (!triggerContext) { - return false; - } - - return series.entryTriggers.some((trigger) => triggerMatches(trigger, triggerContext)); -} - -function toTriggerIdempotencyContext(triggerContext?: SeriesTriggerContext): string | undefined { - if (!triggerContext) { - return undefined; - } - - return [ - triggerContext.source, - triggerContext.eventName ?? "", - triggerContext.attributeKey ?? "", - normalizeText(triggerContext.fromValue) ?? "", - normalizeText(triggerContext.toValue) ?? "", - ].join("|"); -} - -async function resolveLatestConversationIdForVisitor( - ctx: MutationCtx, - visitorId: Id<"visitors"> -): Promise | undefined> { - const conversations = await ctx.db - .query("conversations") - .withIndex("by_visitor", (q) => q.eq("visitorId", visitorId)) - .order("desc") - .take(1); - return conversations[0]?._id; -} - -async function applyTagBlockMutation( - ctx: MutationCtx, - series: Doc<"series">, - visitor: Doc<"visitors">, - action: "add" | "remove", - tagName: string -): Promise { - const normalizedTag = normalizeTagName(tagName); - if (!normalizedTag) { - return; - } - - const existingTag = await ctx.db - .query("tags") - .withIndex("by_workspace_name", (q) => - q.eq("workspaceId", series.workspaceId).eq("name", normalizedTag) - ) - .first(); - - const tagId = - existingTag?._id ?? - (await ctx.db.insert("tags", { - workspaceId: series.workspaceId, - name: normalizedTag, - createdAt: nowTs(), - updatedAt: nowTs(), - })); - - const conversationId = await resolveLatestConversationIdForVisitor(ctx, visitor._id); - if (!conversationId) { - return; - } - - const existingConversationTag = await ctx.db - .query("conversationTags") - .withIndex("by_conversation_tag", (q) => - q.eq("conversationId", conversationId).eq("tagId", tagId) - ) - .first(); - - if (action === "add" && !existingConversationTag) { - await ctx.db.insert("conversationTags", { - conversationId, - tagId, - appliedBy: "auto", - createdAt: nowTs(), - }); - } - - if (action === "remove" && existingConversationTag) { - await ctx.db.delete(existingConversationTag._id); - } -} - -async function runContentBlockAdapter( - ctx: MutationCtx, - series: Doc<"series">, - visitor: Doc<"visitors">, - block: Doc<"seriesBlocks"> -): Promise<{ deliveryAttempted: boolean; deliveryFailed: boolean; error?: string }> { - const config = block.config ?? {}; - - if (block.type === "email") { - if (!hasTextContent(config.subject) || !hasTextContent(config.body)) { - return { - deliveryAttempted: false, - deliveryFailed: true, - error: "Email block requires subject and body.", - }; - } - - if (!hasTextContent(visitor.email)) { - return { - deliveryAttempted: true, - deliveryFailed: true, - error: "Visitor email is required for email block delivery.", - }; - } - - const emailConfig = await ctx.db - .query("emailConfigs") - .withIndex("by_workspace", (q) => q.eq("workspaceId", series.workspaceId)) - .first(); - if (!emailConfig?.enabled) { - return { - deliveryAttempted: true, - deliveryFailed: true, - error: "Email channel is not configured for workspace.", - }; - } - - return { deliveryAttempted: true, deliveryFailed: false }; - } - - if (block.type === "push") { - if (!hasTextContent(config.title) || !hasTextContent(config.body)) { - return { - deliveryAttempted: false, - deliveryFailed: true, - error: "Push block requires title and body.", - }; - } - - const visitorPushToken = await ctx.db - .query("visitorPushTokens") - .withIndex("by_visitor", (q) => q.eq("visitorId", visitor._id)) - .first(); - if (!visitorPushToken) { - return { - deliveryAttempted: true, - deliveryFailed: true, - error: "No visitor push token found.", - }; - } - - return { deliveryAttempted: true, deliveryFailed: false }; - } - - if (block.type === "chat" || block.type === "post" || block.type === "carousel") { - return { - deliveryAttempted: true, - deliveryFailed: false, - }; - } - - return { - deliveryAttempted: false, - deliveryFailed: true, - error: `Unsupported content block type: ${block.type}`, - }; -} - -async function executeCurrentBlock( - ctx: MutationCtx, - series: Doc<"series">, - visitor: Doc<"visitors">, - progress: Doc<"seriesProgress">, - block: Doc<"seriesBlocks"> -): Promise { - const config = block.config ?? {}; - const outgoing = sortConnectionsDeterministically( - await ctx.db - .query("seriesConnections") - .withIndex("by_from_block", (q) => q.eq("fromBlockId", block._id)) - .collect() - ); - - if (block.type === "rule") { - if (!config.rules || !validateAudienceRule(config.rules)) { - return { - status: "failed", - error: "Rule block configuration is invalid.", - }; - } - - const ruleMatch = await evaluateRule(ctx, config.rules as AudienceRule, visitor); - const nextConnection = selectNextConnection(outgoing, ruleMatch ? "yes" : "no"); - const telemetryPatch: BlockExecutionResult["telemetryPatch"] = { - ...(nextConnection?.condition === "yes" ? { yesBranchCount: 1 } : {}), - ...(nextConnection?.condition === "no" ? { noBranchCount: 1 } : {}), - ...(nextConnection?.condition === "default" || !nextConnection - ? { defaultBranchCount: 1 } - : {}), - }; - - return { - status: "completed", - nextBlockId: nextConnection?.toBlockId, - telemetryPatch, - }; - } - - if (block.type === "wait") { - if (config.waitType === "duration") { - const duration = Number(config.waitDuration ?? 0); - const waitUnit = normalizeText(config.waitUnit) ?? "minutes"; - if (!Number.isFinite(duration) || duration <= 0) { - return { - status: "failed", - error: "Duration wait block requires positive duration.", - }; - } - - return { - status: "waiting", - waitUntil: nowTs() + durationToMs(duration, waitUnit), - }; - } - - if (config.waitType === "until_date") { - if (!Number.isFinite(config.waitUntilDate)) { - return { - status: "failed", - error: "Until date wait block requires a valid date.", - }; - } - - return { - status: "waiting", - waitUntil: Number(config.waitUntilDate), - }; - } - - if (config.waitType === "until_event") { - if (!hasTextContent(config.waitUntilEvent)) { - return { - status: "failed", - error: "Until event wait block requires an event name.", - }; - } - - return { - status: "waiting", - waitEventName: String(config.waitUntilEvent), - }; - } - - return { - status: "failed", - error: "Unsupported wait block type.", - }; - } - - if (block.type === "tag") { - const tagAction = config.tagAction; - const tagName = normalizeText(config.tagName); - if ((tagAction !== "add" && tagAction !== "remove") || !hasTextContent(tagName)) { - return { - status: "failed", - error: "Tag block requires tag action and tag name.", - }; - } - - await applyTagBlockMutation(ctx, series, visitor, tagAction, tagName!); - const nextConnection = selectNextConnection(outgoing, "default"); - return { - status: "completed", - nextBlockId: nextConnection?.toBlockId, - }; - } - - if ( - block.type === "email" || - block.type === "push" || - block.type === "chat" || - block.type === "post" || - block.type === "carousel" - ) { - const priorCompletion = await ctx.db - .query("seriesProgressHistory") - .withIndex("by_progress", (q) => q.eq("progressId", progress._id)) - .filter((q) => - q.and(q.eq(q.field("blockId"), block._id), q.eq(q.field("action"), "completed")) - ) - .first(); - - if (priorCompletion) { - const nextConnection = selectNextConnection(outgoing, "default"); - return { - status: "completed", - nextBlockId: nextConnection?.toBlockId, - deliveryAttempted: false, - deliveryFailed: false, - }; - } - - const adapterResult = await runContentBlockAdapter(ctx, series, visitor, block); - if (adapterResult.deliveryFailed) { - return { - status: "failed", - error: adapterResult.error, - deliveryAttempted: adapterResult.deliveryAttempted, - deliveryFailed: true, - }; - } - - const nextConnection = selectNextConnection(outgoing, "default"); - return { - status: "completed", - nextBlockId: nextConnection?.toBlockId, - deliveryAttempted: adapterResult.deliveryAttempted, - deliveryFailed: false, - }; - } - - return { - status: "failed", - error: `Unsupported block type: ${block.type}`, - }; -} - -async function processProgressRecord( - ctx: MutationCtx, - progressId: Id<"seriesProgress">, - maxDepth = MAX_SERIES_EXECUTION_DEPTH -): Promise<{ processed: boolean; status: SeriesProgressStatus | "missing"; reason?: string }> { - let depth = 0; - - while (depth < maxDepth) { - const progress = (await ctx.db.get(progressId)) as Doc<"seriesProgress"> | null; - if (!progress) { - return { processed: false, status: "missing", reason: "progress_not_found" }; - } - - const now = nowTs(); - - if (isTerminalProgressStatus(progress.status)) { - return { processed: false, status: progress.status, reason: "already_terminal" }; - } - - const series = (await ctx.db.get(progress.seriesId)) as Doc<"series"> | null; - if (!series) { - return { processed: false, status: progress.status, reason: "series_not_found" }; - } - if (series.status !== "active") { - return { processed: false, status: progress.status, reason: "series_not_active" }; - } - - let block = progress.currentBlockId - ? ((await ctx.db.get(progress.currentBlockId)) as Doc<"seriesBlocks"> | null) - : null; - - if (progress.status === "waiting") { - if (progress.waitEventName) { - return { processed: false, status: progress.status, reason: "waiting_for_event" }; - } - if (progress.waitUntil !== undefined && progress.waitUntil > now) { - return { processed: false, status: progress.status, reason: "waiting_for_time" }; - } - - if (block && block.type === "wait") { - const waitBlock = block; - await appendProgressHistory(ctx, progress._id, block._id, "completed", { - resumedAt: now, - }); - await upsertBlockTelemetry(ctx, progress.seriesId, block._id, { - completed: 1, - lastResult: { status: "wait_resumed" }, - }); - - const waitConnections = sortConnectionsDeterministically( - await ctx.db - .query("seriesConnections") - .withIndex("by_from_block", (q) => q.eq("fromBlockId", waitBlock._id)) - .collect() - ); - const nextConnection = selectNextConnection(waitConnections, "default"); - - if (!nextConnection) { - await transitionProgressStatus(ctx, progress, "completed", { - completedAt: now, - attemptCount: 0, - waitUntil: undefined, - waitEventName: undefined, - lastExecutionError: undefined, - lastBlockExecutedAt: now, - idempotencyKeyContext: `${progress._id}:${block._id}:wait_completed`, - }); - return { processed: true, status: "completed", reason: "wait_terminal_path" }; - } - - await transitionProgressStatus(ctx, progress, "active", { - currentBlockId: nextConnection.toBlockId, - attemptCount: 0, - waitUntil: undefined, - waitEventName: undefined, - lastExecutionError: undefined, - lastBlockExecutedAt: now, - idempotencyKeyContext: `${progress._id}:${nextConnection.toBlockId}:entered`, - }); - await appendProgressHistory(ctx, progress._id, nextConnection.toBlockId, "entered"); - - depth += 1; - continue; - } - } - - const visitor = (await ctx.db.get(progress.visitorId)) as Doc<"visitors"> | null; - if (!visitor) { - await transitionProgressStatus(ctx, progress, "failed", { - failedAt: now, - lastExecutionError: "Visitor not found for series progress.", - }); - return { processed: true, status: "failed", reason: "visitor_not_found" }; - } - - if (series.exitRules) { - const shouldExit = await evaluateRule(ctx, series.exitRules as AudienceRule, visitor); - if (shouldExit) { - await transitionProgressStatus(ctx, progress, "exited", { - lastBlockExecutedAt: now, - waitUntil: undefined, - waitEventName: undefined, - }); - if (progress.currentBlockId) { - await appendProgressHistory(ctx, progress._id, progress.currentBlockId, "skipped", { - reason: "exit_rules_matched", - }); - await upsertBlockTelemetry(ctx, progress.seriesId, progress.currentBlockId, { - skipped: 1, - lastResult: { reason: "exit_rules_matched" }, - }); - } - return { processed: true, status: "exited" }; - } - } - - if (series.goalRules) { - const goalReached = await evaluateRule(ctx, series.goalRules as AudienceRule, visitor); - if (goalReached) { - await transitionProgressStatus(ctx, progress, "goal_reached", { - lastBlockExecutedAt: now, - waitUntil: undefined, - waitEventName: undefined, - }); - if (progress.currentBlockId) { - await appendProgressHistory(ctx, progress._id, progress.currentBlockId, "skipped", { - reason: "goal_rules_matched", - }); - await upsertBlockTelemetry(ctx, progress.seriesId, progress.currentBlockId, { - skipped: 1, - lastResult: { reason: "goal_rules_matched" }, - }); - } - return { processed: true, status: "goal_reached" }; - } - } - - if (!progress.currentBlockId) { - await transitionProgressStatus(ctx, progress, "completed", { - completedAt: now, - }); - return { processed: true, status: "completed", reason: "no_current_block" }; - } - - block = block ?? ((await ctx.db.get(progress.currentBlockId)) as Doc<"seriesBlocks"> | null); - if (!block) { - await transitionProgressStatus(ctx, progress, "failed", { - failedAt: now, - lastExecutionError: "Current block not found.", - }); - return { processed: true, status: "failed", reason: "block_not_found" }; - } - - await upsertBlockTelemetry(ctx, progress.seriesId, block._id, { - entered: 1, - lastResult: { status: "entered" }, - }); - - const execution = await executeCurrentBlock(ctx, series, visitor, progress, block); - const attemptCount = (progress.attemptCount ?? 0) + 1; - - const telemetryPatch = execution.telemetryPatch; - await upsertBlockTelemetry(ctx, progress.seriesId, block._id, { - completed: execution.status === "completed" ? 1 : 0, - failed: execution.status === "failed" ? 1 : 0, - deliveryAttempts: execution.deliveryAttempted ? 1 : 0, - deliveryFailures: execution.deliveryFailed ? 1 : 0, - ...(telemetryPatch?.yesBranchCount ? { yesBranchCount: telemetryPatch.yesBranchCount } : {}), - ...(telemetryPatch?.noBranchCount ? { noBranchCount: telemetryPatch.noBranchCount } : {}), - ...(telemetryPatch?.defaultBranchCount - ? { defaultBranchCount: telemetryPatch.defaultBranchCount } - : {}), - lastResult: execution.error - ? { status: execution.status, error: execution.error } - : { status: execution.status }, - }); - - if (execution.status === "failed") { - await appendProgressHistory(ctx, progress._id, block._id, "failed", { - error: execution.error ?? "Unknown error", - }); - - if (attemptCount >= MAX_BLOCK_EXECUTION_ATTEMPTS) { - await transitionProgressStatus(ctx, progress, "failed", { - attemptCount, - failedAt: now, - lastExecutionError: execution.error, - lastBlockExecutedAt: now, - waitUntil: undefined, - waitEventName: undefined, - idempotencyKeyContext: `${progress._id}:${block._id}:failed:${attemptCount}`, - }); - return { processed: true, status: "failed", reason: "max_attempts_exceeded" }; - } - - const retryDelay = getRetryDelayMs(attemptCount); - await transitionProgressStatus(ctx, progress, "waiting", { - attemptCount, - waitUntil: now + retryDelay, - waitEventName: undefined, - lastExecutionError: execution.error, - lastBlockExecutedAt: now, - idempotencyKeyContext: `${progress._id}:${block._id}:retry:${attemptCount}`, - }); - - await scheduleSeriesProcessProgress(ctx, { - delayMs: retryDelay, - progressId: progress._id, - }); - return { processed: true, status: "waiting", reason: "retry_scheduled" }; - } - - if (execution.status === "completed") { - await appendProgressHistory(ctx, progress._id, block._id, "completed", { - nextBlockId: execution.nextBlockId ?? null, - }); - } - - if (execution.status === "waiting") { - await transitionProgressStatus(ctx, progress, "waiting", { - attemptCount: 0, - waitUntil: execution.waitUntil, - waitEventName: execution.waitEventName, - lastExecutionError: undefined, - lastBlockExecutedAt: now, - idempotencyKeyContext: `${progress._id}:${block._id}:waiting`, - }); - - if (execution.waitUntil) { - const delay = Math.max(0, execution.waitUntil - now); - await scheduleSeriesProcessProgress(ctx, { - delayMs: delay, - progressId: progress._id, - }); - } - return { processed: true, status: "waiting" }; - } - - if (!execution.nextBlockId) { - await transitionProgressStatus(ctx, progress, "completed", { - completedAt: now, - attemptCount: 0, - waitUntil: undefined, - waitEventName: undefined, - lastExecutionError: undefined, - lastBlockExecutedAt: now, - idempotencyKeyContext: `${progress._id}:${block._id}:completed`, - }); - return { processed: true, status: "completed", reason: "terminal_path" }; - } - - await ctx.db.patch(progress._id, { - currentBlockId: execution.nextBlockId, - status: "active", - attemptCount: 0, - waitUntil: undefined, - waitEventName: undefined, - lastExecutionError: undefined, - lastBlockExecutedAt: now, - idempotencyKeyContext: `${progress._id}:${execution.nextBlockId}:entered`, - }); - await appendProgressHistory(ctx, progress._id, execution.nextBlockId, "entered"); - - depth += 1; - } - - const latest = (await ctx.db.get(progressId)) as Doc<"seriesProgress"> | null; - if (latest && !isTerminalProgressStatus(latest.status)) { - await transitionProgressStatus(ctx, latest, "failed", { - failedAt: nowTs(), - lastExecutionError: "Series execution depth exceeded safety limit.", - lastBlockExecutedAt: nowTs(), - }); - } - - return { - processed: false, - status: latest?.status ?? "missing", - reason: "max_execution_depth_exceeded", - }; -} - -// Task 5.1: Create series -export const create = mutation({ - args: { - workspaceId: v.id("workspaces"), - name: v.string(), - description: v.optional(v.string()), - entryTriggers: v.optional(v.array(seriesEntryTriggerValidator)), - entryRules: v.optional(audienceRulesOrSegmentValidator), - exitRules: v.optional(audienceRulesOrSegmentValidator), - goalRules: v.optional(audienceRulesOrSegmentValidator), - }, - handler: async (ctx, args) => { - await requireSeriesManagePermission(ctx, args.workspaceId); - - // Validate rules if provided - if (args.entryRules !== undefined && !validateAudienceRule(args.entryRules)) { - throw new Error("Invalid entry rules"); - } - if (args.exitRules !== undefined && !validateAudienceRule(args.exitRules)) { - throw new Error("Invalid exit rules"); - } - if (args.goalRules !== undefined && !validateAudienceRule(args.goalRules)) { - throw new Error("Invalid goal rules"); - } - - const now = Date.now(); - return await ctx.db.insert("series", { - workspaceId: args.workspaceId, - name: args.name, - description: args.description, - entryTriggers: args.entryTriggers, - entryRules: args.entryRules, - exitRules: args.exitRules, - goalRules: args.goalRules, - status: "draft", - createdAt: now, - updatedAt: now, - }); - }, -}); - -// Task 5.2: Update series -export const update = mutation({ - args: { - id: v.id("series"), - name: v.optional(v.string()), - description: v.optional(v.string()), - entryTriggers: v.optional(v.array(seriesEntryTriggerValidator)), - entryRules: v.optional(audienceRulesOrSegmentValidator), - exitRules: v.optional(audienceRulesOrSegmentValidator), - goalRules: v.optional(audienceRulesOrSegmentValidator), - }, - handler: async (ctx, args) => { - const { id, ...updates } = args; - const existing = (await ctx.db.get(id)) as Doc<"series"> | null; - if (!existing) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, existing.workspaceId); - - // Validate rules if provided - if (args.entryRules !== undefined && !validateAudienceRule(args.entryRules)) { - throw new Error("Invalid entry rules"); - } - if (args.exitRules !== undefined && !validateAudienceRule(args.exitRules)) { - throw new Error("Invalid exit rules"); - } - if (args.goalRules !== undefined && !validateAudienceRule(args.goalRules)) { - throw new Error("Invalid goal rules"); - } - - await ctx.db.patch(id, { - ...updates, - updatedAt: Date.now(), - }); - return id; - }, -}); - -// Task 5.3: Activate series -export const activate = mutation({ - args: { id: v.id("series") }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, series.workspaceId); - - if (!isSeriesRuntimeEnabled()) { - throw new Error(serializeRuntimeGuardError()); - } - - const readiness = await evaluateSeriesReadiness(ctx, series); - if (!readiness.isReady) { - throw new Error(serializeReadinessError(readiness)); - } - - await ctx.db.patch(args.id, { - status: "active", - updatedAt: nowTs(), - stats: normalizeSeriesStats(series), - }); - }, -}); - -// Pause series -export const pause = mutation({ - args: { id: v.id("series") }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, series.workspaceId); - - await ctx.db.patch(args.id, { status: "paused", updatedAt: Date.now() }); - }, -}); - -// Archive series -export const archive = mutation({ - args: { id: v.id("series") }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, series.workspaceId); - - await ctx.db.patch(args.id, { status: "archived", updatedAt: Date.now() }); - }, -}); - -// List series -export const list = query({ - args: { - workspaceId: v.id("workspaces"), - status: v.optional( - v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived")) - ), - limit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const canManage = await canManageSeries(ctx, args.workspaceId); - if (!canManage) { - return []; - } - - const limit = clampLimit(args.limit, DEFAULT_SERIES_LIST_LIMIT, MAX_SERIES_LIST_LIMIT); - let seriesList; - - if (args.status) { - seriesList = await ctx.db - .query("series") - .withIndex("by_workspace_status", (q) => - q.eq("workspaceId", args.workspaceId).eq("status", args.status!) - ) - .order("desc") - .take(limit); - } else { - seriesList = await ctx.db - .query("series") - .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) - .order("desc") - .take(limit); - } - - return seriesList.sort((a, b) => b.createdAt - a.createdAt); - }, -}); - -// Get single series -export const get = query({ - args: { id: v.id("series") }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; - if (!series) { - return null; - } - const canManage = await canManageSeries(ctx, series.workspaceId); - if (!canManage) { - return null; - } - return series; - }, -}); - -// Runtime readiness diagnostics for activation validation -export const getReadiness = query({ - args: { id: v.id("series") }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; - if (!series) { - return null; - } - - const canManage = await canManageSeries(ctx, series.workspaceId); - if (!canManage) { - throw new Error("Permission denied: settings.workspace"); - } - - return await evaluateSeriesReadiness(ctx, series); - }, -}); - -// Get series with blocks and connections -export const getWithBlocks = query({ - args: { - id: v.id("series"), - blockLimit: v.optional(v.number()), - connectionLimit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; - if (!series) return null; - const canManage = await canManageSeries(ctx, series.workspaceId); - if (!canManage) return null; - - const blockLimit = clampLimit(args.blockLimit, DEFAULT_GRAPH_ITEM_LIMIT, MAX_GRAPH_ITEM_LIMIT); - const connectionLimit = clampLimit( - args.connectionLimit, - DEFAULT_GRAPH_ITEM_LIMIT, - MAX_GRAPH_ITEM_LIMIT - ); - const blocks = await ctx.db - .query("seriesBlocks") - .withIndex("by_series", (q) => q.eq("seriesId", args.id)) - .order("desc") - .take(blockLimit); - - const connections = await ctx.db - .query("seriesConnections") - .withIndex("by_series", (q) => q.eq("seriesId", args.id)) - .order("desc") - .take(connectionLimit); - - return { - ...series, - blocks, - connections, - }; - }, -}); - -// Delete series -export const remove = mutation({ - args: { id: v.id("series") }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, series.workspaceId); - - // Delete blocks - const blocks = await ctx.db - .query("seriesBlocks") - .withIndex("by_series", (q) => q.eq("seriesId", args.id)) - .collect(); - - for (const block of blocks) { - await ctx.db.delete(block._id); - } - - // Delete connections - const connections = await ctx.db - .query("seriesConnections") - .withIndex("by_series", (q) => q.eq("seriesId", args.id)) - .collect(); - - for (const connection of connections) { - await ctx.db.delete(connection._id); - } - - // Delete progress records - const progressRecords = await ctx.db - .query("seriesProgress") - .withIndex("by_series", (q) => q.eq("seriesId", args.id)) - .collect(); - - for (const progress of progressRecords) { - // Delete history - const history = await ctx.db - .query("seriesProgressHistory") - .withIndex("by_progress", (q) => q.eq("progressId", progress._id)) - .collect(); - - for (const h of history) { - await ctx.db.delete(h._id); - } - - await ctx.db.delete(progress._id); - } - - await ctx.db.delete(args.id); - }, -}); - -// Add block to series -export const addBlock = mutation({ - args: { - seriesId: v.id("series"), - type: seriesBlockTypeValidator, - position: v.object({ - x: v.number(), - y: v.number(), - }), - config: seriesBlockConfigValidator, - }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.seriesId)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, series.workspaceId); - - const now = Date.now(); - return await ctx.db.insert("seriesBlocks", { - seriesId: args.seriesId, - type: args.type, - position: args.position, - config: args.config, - createdAt: now, - updatedAt: now, - }); - }, -}); - -// Update block -export const updateBlock = mutation({ - args: { - id: v.id("seriesBlocks"), - position: v.optional( - v.object({ - x: v.number(), - y: v.number(), - }) - ), - config: v.optional(seriesBlockConfigValidator), - }, - handler: async (ctx, args) => { - const { id, ...updates } = args; - const existing = (await ctx.db.get(id)) as Doc<"seriesBlocks"> | null; - if (!existing) throw new Error("Block not found"); - const series = (await ctx.db.get(existing.seriesId)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, series.workspaceId); - - await ctx.db.patch(id, { - ...updates, - updatedAt: Date.now(), - }); - return id; - }, -}); - -// Delete block -export const removeBlock = mutation({ - args: { id: v.id("seriesBlocks") }, - handler: async (ctx, args) => { - const block = (await ctx.db.get(args.id)) as Doc<"seriesBlocks"> | null; - if (!block) throw new Error("Block not found"); - const series = (await ctx.db.get(block.seriesId)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, series.workspaceId); - - // Delete connections involving this block - const fromConnections = await ctx.db - .query("seriesConnections") - .withIndex("by_from_block", (q) => q.eq("fromBlockId", args.id)) - .collect(); - - const toConnections = await ctx.db - .query("seriesConnections") - .withIndex("by_to_block", (q) => q.eq("toBlockId", args.id)) - .collect(); - - for (const conn of [...fromConnections, ...toConnections]) { - await ctx.db.delete(conn._id); - } - - await ctx.db.delete(args.id); - }, -}); - -// Add connection between blocks -export const addConnection = mutation({ - args: { - seriesId: v.id("series"), - fromBlockId: v.id("seriesBlocks"), - toBlockId: v.id("seriesBlocks"), - condition: v.optional(v.union(v.literal("yes"), v.literal("no"), v.literal("default"))), - }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.seriesId)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, series.workspaceId); - - const fromBlock = (await ctx.db.get(args.fromBlockId)) as Doc<"seriesBlocks"> | null; - const toBlock = (await ctx.db.get(args.toBlockId)) as Doc<"seriesBlocks"> | null; - if (!fromBlock || !toBlock) { - throw new Error("Block not found"); - } - if (fromBlock.seriesId !== args.seriesId || toBlock.seriesId !== args.seriesId) { - throw new Error("Blocks must belong to the target series"); - } - - return await ctx.db.insert("seriesConnections", { - seriesId: args.seriesId, - fromBlockId: args.fromBlockId, - toBlockId: args.toBlockId, - condition: args.condition, - createdAt: Date.now(), - }); - }, -}); - -// Delete connection -export const removeConnection = mutation({ - args: { id: v.id("seriesConnections") }, - handler: async (ctx, args) => { - const connection = (await ctx.db.get(args.id)) as Doc<"seriesConnections"> | null; - if (!connection) throw new Error("Connection not found"); - const series = (await ctx.db.get(connection.seriesId)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, series.workspaceId); - - await ctx.db.delete(args.id); - }, -}); - -// Task 5.4: Evaluate series entry for a visitor -export const evaluateEntry = internalMutation({ - args: { - seriesId: v.id("series"), - visitorId: v.id("visitors"), - triggerContext: v.optional(seriesEntryTriggerValidator), - }, - handler: async (ctx, args) => { - if (!isSeriesRuntimeEnabled()) { - return { entered: false, reason: "runtime_disabled" as const }; - } - - const series = (await ctx.db.get(args.seriesId)) as Doc<"series"> | null; - if (!series || series.status !== "active") { - return { entered: false, reason: "series_not_active" as const }; - } - - if (!seriesAcceptsTrigger(series, args.triggerContext)) { - return { entered: false, reason: "entry_trigger_not_matched" as const }; - } - - const visitor = (await ctx.db.get(args.visitorId)) as Doc<"visitors"> | null; - if (!visitor) { - return { entered: false, reason: "visitor_not_found" as const }; - } - - if (visitor.workspaceId !== series.workspaceId) { - return { entered: false, reason: "workspace_mismatch" as const }; - } - - // Check if visitor is already in this series - const existingProgress = await ctx.db - .query("seriesProgress") - .withIndex("by_visitor_series", (q) => - q.eq("visitorId", args.visitorId).eq("seriesId", args.seriesId) - ) - .first(); - - if (existingProgress) { - return { - entered: false, - reason: "already_in_series" as const, - progressId: existingProgress._id, - }; - } - - // Check entry rules - if (series.entryRules) { - const matches = await evaluateRule(ctx, series.entryRules as AudienceRule, visitor); - if (!matches) { - return { entered: false, reason: "entry_rules_not_met" as const }; - } - } - - const { blocks, connections } = await loadSeriesGraph(ctx, args.seriesId); - const entryBlocks = findEntryBlocks(blocks, connections); - if (entryBlocks.length !== 1) { - return { entered: false, reason: "invalid_entry_path" as const }; - } - - const now = nowTs(); - const idempotencyKeyContext = toTriggerIdempotencyContext(args.triggerContext); - - // Create progress record - const progressId = await ctx.db.insert("seriesProgress", { - visitorId: args.visitorId, - seriesId: args.seriesId, - currentBlockId: entryBlocks[0]._id, - status: "active", - attemptCount: 0, - idempotencyKeyContext, - lastTriggerSource: args.triggerContext?.source, - lastTriggerEventName: args.triggerContext?.eventName, - enteredAt: now, - }); - - // Record history - await ctx.db.insert("seriesProgressHistory", { - progressId, - blockId: entryBlocks[0]._id, - action: "entered", - createdAt: now, - }); - - // Enrollment idempotency: keep oldest progress if concurrent enrollment races occur. - const allProgress = await ctx.db - .query("seriesProgress") - .withIndex("by_visitor_series", (q) => - q.eq("visitorId", args.visitorId).eq("seriesId", args.seriesId) - ) - .collect(); - - const ordered = [...allProgress].sort((left, right) => { - if (left.enteredAt !== right.enteredAt) { - return left.enteredAt - right.enteredAt; - } - return left._id.toString().localeCompare(right._id.toString()); - }); - - const survivor = ordered[0]; - for (let index = 1; index < ordered.length; index += 1) { - await ctx.db.delete(ordered[index]._id); - } - - if (!survivor || survivor._id !== progressId) { - return { - entered: false, - reason: "already_in_series" as const, - progressId: survivor?._id, - }; - } - - await applySeriesStatsDelta(ctx, args.seriesId, { - entered: 1, - active: 1, - }); - - await processProgressRecord(ctx, progressId); - - return { entered: true, progressId }; - }, -}); - -export const evaluateEnrollmentForVisitor = internalMutation({ - args: { - workspaceId: v.id("workspaces"), - visitorId: v.id("visitors"), - triggerContext: seriesEntryTriggerValidator, - }, - handler: async (ctx, args) => { - if (!isSeriesRuntimeEnabled()) { - return { - evaluated: 0, - entered: 0, - reason: serializeRuntimeGuardError(), - }; - } - - const visitor = (await ctx.db.get(args.visitorId)) as Doc<"visitors"> | null; - if (!visitor || visitor.workspaceId !== args.workspaceId) { - return { - evaluated: 0, - entered: 0, - reason: "visitor_not_found", - }; - } - - const activeSeries = await ctx.db - .query("series") - .withIndex("by_workspace_status", (q) => - q.eq("workspaceId", args.workspaceId).eq("status", "active") - ) - .collect(); - - let entered = 0; - for (const series of activeSeries) { - const result = await runSeriesEvaluateEntry(ctx, { - seriesId: series._id, - visitorId: args.visitorId, - triggerContext: args.triggerContext, - }); - if (result?.entered) { - entered += 1; - } - } - - return { - evaluated: activeSeries.length, - entered, - }; - }, -}); - -export const resumeWaitingForEvent = internalMutation({ - args: { - workspaceId: v.id("workspaces"), - visitorId: v.id("visitors"), - eventName: v.string(), - }, - handler: async (ctx, args) => { - if (!isSeriesRuntimeEnabled()) { - return { - matched: 0, - resumed: 0, - reason: serializeRuntimeGuardError(), - }; - } - - const waitingProgress = sortProgressDeterministically( - await ctx.db - .query("seriesProgress") - .withIndex("by_visitor_status", (q) => - q.eq("visitorId", args.visitorId).eq("status", "waiting") - ) - .collect() - ); - - let matched = 0; - let resumed = 0; - for (const progress of waitingProgress) { - if (!progress.currentBlockId) { - continue; - } - if (progress.waitEventName !== args.eventName) { - continue; - } - - const series = (await ctx.db.get(progress.seriesId)) as Doc<"series"> | null; - if (!series || series.workspaceId !== args.workspaceId || series.status !== "active") { - continue; - } - - matched += 1; - await ctx.db.patch(progress._id, { - waitEventName: undefined, - waitUntil: nowTs(), - }); - - const result = await processProgressRecord(ctx, progress._id); - if (result.processed) { - resumed += 1; - } - } - - return { - matched, - resumed, - }; - }, -}); - -export const processProgress = internalMutation({ - args: { - progressId: v.id("seriesProgress"), - maxDepth: v.optional(v.number()), - }, - handler: async (ctx, args) => { - if (!isSeriesRuntimeEnabled()) { - return { - processed: false, - status: "missing" as const, - reason: serializeRuntimeGuardError(), - }; - } - - const maxDepth = clampLimit( - args.maxDepth, - MAX_SERIES_EXECUTION_DEPTH, - MAX_SERIES_EXECUTION_DEPTH - ); - return await processProgressRecord(ctx, args.progressId, maxDepth); - }, -}); - -// Task 5.5: Process wait blocks (called by scheduled job) -export const processWaitingProgress = internalMutation({ - args: { - seriesLimit: v.optional(v.number()), - waitingLimitPerSeries: v.optional(v.number()), - }, - handler: async (ctx, args) => { - if (!isSeriesRuntimeEnabled()) { - return { - processed: 0, - reason: serializeRuntimeGuardError(), - }; - } - - const now = nowTs(); - const seriesLimit = clampLimit( - args.seriesLimit, - DEFAULT_PROGRESS_SCAN_LIMIT, - MAX_PROGRESS_SCAN_LIMIT - ); - const waitingLimitPerSeries = clampLimit( - args.waitingLimitPerSeries, - DEFAULT_WAITING_BATCH_LIMIT, - MAX_WAITING_BATCH_LIMIT - ); - - // Find all progress records that are waiting and ready to continue - const allSeries = await ctx.db.query("series").order("desc").take(seriesLimit); - let processed = 0; - let scanned = 0; - - for (const series of allSeries) { - if (series.status !== "active") continue; - - const waitingProgress = sortProgressDeterministically( - await ctx.db - .query("seriesProgress") - .withIndex("by_status", (q) => q.eq("seriesId", series._id).eq("status", "waiting")) - .take(waitingLimitPerSeries) - ); - - for (const progress of waitingProgress) { - scanned += 1; - if (!progress.currentBlockId) continue; - if (progress.waitEventName) continue; - if (progress.waitUntil !== undefined && progress.waitUntil > now) continue; - - const result = await processProgressRecord(ctx, progress._id); - if (result.processed) { - processed += 1; - } - } - } - - return { processed, scanned }; - }, -}); - -// Task 5.8: Track user progress through series -export const getProgress = internalQuery({ - args: { - seriesId: v.id("series"), - visitorId: v.id("visitors"), - historyLimit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.seriesId)) as Doc<"series"> | null; - if (!series) { - return null; - } - - const progress = await ctx.db - .query("seriesProgress") - .withIndex("by_visitor_series", (q) => - q.eq("visitorId", args.visitorId).eq("seriesId", args.seriesId) - ) - .first(); - - if (!progress) return null; - - const historyLimit = clampLimit(args.historyLimit, DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT); - const history = await ctx.db - .query("seriesProgressHistory") - .withIndex("by_progress", (q) => q.eq("progressId", progress._id)) - .order("desc") - .take(historyLimit); - - return { - ...progress, - history: history.sort((a, b) => a.createdAt - b.createdAt), - }; - }, -}); - -// Task 5.9: Exit user from series -export const exitProgress = internalMutation({ - args: { - progressId: v.id("seriesProgress"), - reason: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const progress = (await ctx.db.get(args.progressId)) as Doc<"seriesProgress"> | null; - if (!progress) throw new Error("Progress not found"); - const series = (await ctx.db.get(progress.seriesId)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - - const now = Date.now(); - - await ctx.db.patch(args.progressId, { - status: "exited", - exitedAt: now, - }); - - // Update series stats - if (series.stats) { - await ctx.db.patch(progress.seriesId, { - stats: { - ...series.stats, - exited: series.stats.exited + 1, - }, - }); - } - }, -}); - -// Task 5.10: Mark goal reached -export const markGoalReached = internalMutation({ - args: { - progressId: v.id("seriesProgress"), - }, - handler: async (ctx, args) => { - const progress = (await ctx.db.get(args.progressId)) as Doc<"seriesProgress"> | null; - if (!progress) throw new Error("Progress not found"); - const series = (await ctx.db.get(progress.seriesId)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - - const now = Date.now(); - - await ctx.db.patch(args.progressId, { - status: "goal_reached", - goalReachedAt: now, - }); - - // Update series stats - if (series.stats) { - await ctx.db.patch(progress.seriesId, { - stats: { - ...series.stats, - goalReached: series.stats.goalReached + 1, - }, - }); - } - }, -}); - -// Get series stats -export const getStats = query({ - args: { - id: v.id("series"), - scanLimit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - const canManage = await canManageSeries(ctx, series.workspaceId); - if (!canManage) { - throw new Error("Permission denied: settings.workspace"); - } - - const scanLimit = clampLimit( - args.scanLimit, - DEFAULT_PROGRESS_SCAN_LIMIT, - MAX_PROGRESS_SCAN_LIMIT - ); - const progressRecords = await ctx.db - .query("seriesProgress") - .withIndex("by_series", (q) => q.eq("seriesId", args.id)) - .order("desc") - .take(scanLimit); - const truncated = progressRecords.length >= scanLimit; - - return { - total: progressRecords.length, - active: progressRecords.filter((p) => p.status === "active").length, - waiting: progressRecords.filter((p) => p.status === "waiting").length, - completed: progressRecords.filter((p) => p.status === "completed").length, - exited: progressRecords.filter((p) => p.status === "exited").length, - goalReached: progressRecords.filter((p) => p.status === "goal_reached").length, - failed: progressRecords.filter((p) => p.status === "failed").length, - completionRate: - progressRecords.length > 0 - ? (progressRecords.filter((p) => p.status === "completed").length / - progressRecords.length) * - 100 - : 0, - goalRate: - progressRecords.length > 0 - ? (progressRecords.filter((p) => p.status === "goal_reached").length / - progressRecords.length) * - 100 - : 0, - truncated, - }; - }, -}); - -export const getTelemetry = query({ - args: { - id: v.id("series"), - limit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - - const canManage = await canManageSeries(ctx, series.workspaceId); - if (!canManage) { - throw new Error("Permission denied: settings.workspace"); - } - - const limit = clampLimit(args.limit, DEFAULT_GRAPH_ITEM_LIMIT, MAX_GRAPH_ITEM_LIMIT); - const telemetryRows = await ctx.db - .query("seriesBlockTelemetry") - .withIndex("by_series", (q) => q.eq("seriesId", args.id)) - .order("desc") - .take(limit); - - const blockRows = await ctx.db - .query("seriesBlocks") - .withIndex("by_series", (q) => q.eq("seriesId", args.id)) - .collect(); - const blockMap = new Map(blockRows.map((block) => [block._id, block] as const)); - - const totals = telemetryRows.reduce( - (acc, row) => { - acc.entered += row.entered; - acc.completed += row.completed; - acc.skipped += row.skipped; - acc.failed += row.failed; - acc.deliveryAttempts += row.deliveryAttempts; - acc.deliveryFailures += row.deliveryFailures; - return acc; - }, - { - entered: 0, - completed: 0, - skipped: 0, - failed: 0, - deliveryAttempts: 0, - deliveryFailures: 0, - } - ); - - return { - totals, - blocks: telemetryRows.map((row) => ({ - ...row, - block: blockMap.get(row.blockId) ?? null, - })), - }; - }, -}); - -// Duplicate series -export const duplicate = mutation({ - args: { id: v.id("series") }, - handler: async (ctx, args) => { - const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; - if (!series) throw new Error("Series not found"); - await requireSeriesManagePermission(ctx, series.workspaceId); - - const now = Date.now(); - - // Create new series - const newSeriesId = await ctx.db.insert("series", { - workspaceId: series.workspaceId, - name: `${series.name} (Copy)`, - description: series.description, - entryTriggers: series.entryTriggers, - entryRules: series.entryRules, - exitRules: series.exitRules, - goalRules: series.goalRules, - status: "draft", - createdAt: now, - updatedAt: now, - }); - - // Copy blocks - const blocks = await ctx.db - .query("seriesBlocks") - .withIndex("by_series", (q) => q.eq("seriesId", args.id)) - .collect(); - - const blockIdMap = new Map>(); - - for (const block of blocks) { - const newBlockId = await ctx.db.insert("seriesBlocks", { - seriesId: newSeriesId, - type: block.type, - position: block.position, - config: block.config, - createdAt: now, - updatedAt: now, - }); - blockIdMap.set(block._id as string, newBlockId); - } - - // Copy connections - const connections = await ctx.db - .query("seriesConnections") - .withIndex("by_series", (q) => q.eq("seriesId", args.id)) - .collect(); - - for (const conn of connections) { - const newFromId = blockIdMap.get(conn.fromBlockId as string); - const newToId = blockIdMap.get(conn.toBlockId as string); - - if (newFromId && newToId) { - await ctx.db.insert("seriesConnections", { - seriesId: newSeriesId, - fromBlockId: newFromId, - toBlockId: newToId, - condition: conn.condition, - createdAt: now, - }); - } - } - - return newSeriesId; - }, -}); +export { + activate, + addBlock, + addConnection, + archive, + create, + duplicate, + get, + getReadiness, + getWithBlocks, + list, + pause, + remove, + removeBlock, + removeConnection, + update, + updateBlock, +} from "./series/authoring"; + +export { + evaluateEntry, + evaluateEnrollmentForVisitor, + processProgress, + processWaitingProgress, + resumeWaitingForEvent, +} from "./series/runtime"; + +export { exitProgress, getProgress, getStats, getTelemetry, markGoalReached } from "./series/telemetry"; diff --git a/packages/convex/convex/series/README.md b/packages/convex/convex/series/README.md new file mode 100644 index 0000000..e6c425d --- /dev/null +++ b/packages/convex/convex/series/README.md @@ -0,0 +1,26 @@ +# Series Domain Modules + +This folder contains series orchestration internals split by responsibility. +`../series.ts` remains the stable entrypoint surface for `api.series.*` and `internal.series.*`. + +## Ownership + +- `contracts.ts`: shared validators, limits, runtime/readiness types, and status constants. +- `shared.ts`: cross-cutting helpers (permissions, graph loading, runtime guards, normalization, sorting). +- `authoring.ts`: authoring APIs and activation readiness validation. +- `runtime.ts`: progression engine, trigger evaluation, wait/retry transitions, and scheduler-driven handlers. +- `telemetry.ts`: block telemetry upserts and progress/stats/telemetry queries. +- `scheduler.ts`: typed internal scheduler/runtime adapters. + +## Extension Patterns + +- Keep authoring-only behavior changes in `authoring.ts` so runtime progression logic remains isolated. +- Add progression-state changes in `runtime.ts` and keep scheduler calls routed through `scheduler.ts`. +- Keep analytics counters and read-model aggregation in `telemetry.ts`; runtime should call telemetry helpers instead of patching rows directly. +- Add shared validators/contracts in `contracts.ts` before introducing new cross-module call paths. + +## Cross-Surface Notes + +- This refactor is backend-internal and preserves all existing Convex function names and payload contracts. +- No behavior/API contract changes are required for `apps/web`, `apps/widget`, `apps/mobile`, `packages/sdk-core`, or `packages/sdk-react-native`. +- If future work requires shared client behavior changes, update shared contracts/specs first, then consume those contracts per surface. diff --git a/packages/convex/convex/series/authoring.ts b/packages/convex/convex/series/authoring.ts new file mode 100644 index 0000000..9ed0458 --- /dev/null +++ b/packages/convex/convex/series/authoring.ts @@ -0,0 +1,784 @@ +import { v } from "convex/values"; +import { mutation, query, MutationCtx, QueryCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import { validateAudienceRule } from "../audienceRules"; +import { audienceRulesOrSegmentValidator } from "../validators"; +import { + DEFAULT_GRAPH_ITEM_LIMIT, + DEFAULT_SERIES_LIST_LIMIT, + MAX_GRAPH_ITEM_LIMIT, + MAX_SERIES_LIST_LIMIT, + ReadinessIssue, + SeriesReadinessResult, + seriesBlockConfigValidator, + seriesBlockTypeValidator, + seriesEntryTriggerValidator, +} from "./contracts"; +import { + canManageSeries, + clampLimit, + createReadinessIssue, + findEntryBlocks, + getOutgoingConnectionsForBlock, + hasTextContent, + isSeriesRuntimeEnabled, + loadSeriesGraph, + normalizeSeriesStats, + nowTs, + requireSeriesManagePermission, + serializeReadinessError, + serializeRuntimeGuardError, + toStringSet, +} from "./shared"; + +async function evaluateSeriesReadiness( + ctx: QueryCtx | MutationCtx, + series: Doc<"series"> +): Promise { + const blockers: ReadinessIssue[] = []; + const warnings: ReadinessIssue[] = []; + + const { blocks, connections } = await loadSeriesGraph(ctx, series._id); + + if (blocks.length === 0) { + blockers.push( + createReadinessIssue( + "SERIES_GRAPH_EMPTY", + "Series must contain at least one block before activation.", + "Add a starting block in the builder, then connect downstream steps." + ) + ); + } + + const blockIds = toStringSet(blocks.map((block) => block._id as string)); + const entryBlocks = findEntryBlocks(blocks, connections); + if (entryBlocks.length === 0 && blocks.length > 0) { + blockers.push( + createReadinessIssue( + "SERIES_NO_ENTRY_PATH", + "Series graph has no entry block.", + "Ensure at least one block has no incoming connection." + ) + ); + } + if (entryBlocks.length > 1) { + blockers.push( + createReadinessIssue( + "SERIES_MULTIPLE_ENTRY_PATHS", + "Series graph has multiple entry blocks.", + "Connect the graph so only one block remains as the unique entry point." + ) + ); + } + + for (const connection of connections) { + if (!blockIds.has(connection.fromBlockId as string) || !blockIds.has(connection.toBlockId as string)) { + blockers.push( + createReadinessIssue( + "SERIES_INVALID_CONNECTION", + "Series graph contains a connection to a missing block.", + "Delete and recreate the invalid connection.", + { connectionId: connection._id } + ) + ); + } + } + + if (entryBlocks.length === 1) { + const reachable = new Set(); + const queue = [entryBlocks[0]._id]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + const key = current as string; + if (reachable.has(key)) continue; + reachable.add(key); + for (const outgoing of getOutgoingConnectionsForBlock(connections, current)) { + queue.push(outgoing.toBlockId); + } + } + + for (const block of blocks) { + if (!reachable.has(block._id as string)) { + blockers.push( + createReadinessIssue( + "SERIES_UNREACHABLE_BLOCK", + `Block ${block.type} is unreachable from the series entry path.`, + "Connect this block into the main path or remove it.", + { blockId: block._id } + ) + ); + } + } + } + + for (const block of blocks) { + const config = block.config ?? {}; + const outgoing = getOutgoingConnectionsForBlock(connections, block._id); + + if (block.type === "rule") { + if (!config.rules || !validateAudienceRule(config.rules)) { + blockers.push( + createReadinessIssue( + "SERIES_RULE_CONFIG_INVALID", + "Rule block is missing valid audience rule conditions.", + "Configure a valid yes/no rule expression in the block editor.", + { blockId: block._id } + ) + ); + } + + const conditioned = outgoing.filter((connection) => connection.condition !== undefined); + const yesCount = conditioned.filter((connection) => connection.condition === "yes").length; + const noCount = conditioned.filter((connection) => connection.condition === "no").length; + const defaultCount = conditioned.filter((connection) => connection.condition === "default").length; + + if (yesCount !== 1 || noCount !== 1) { + blockers.push( + createReadinessIssue( + "SERIES_RULE_BRANCHES_REQUIRED", + "Rule blocks require exactly one yes branch and one no branch.", + "Add one yes and one no connection from this rule block.", + { blockId: block._id } + ) + ); + } + + if (defaultCount > 1) { + blockers.push( + createReadinessIssue( + "SERIES_RULE_DEFAULT_BRANCH_DUPLICATE", + "Rule block has multiple default branches.", + "Keep only one default branch for deterministic fallback behavior.", + { blockId: block._id } + ) + ); + } + } else { + const invalidConditionalConnection = outgoing.find( + (connection) => connection.condition === "yes" || connection.condition === "no" + ); + if (invalidConditionalConnection) { + blockers.push( + createReadinessIssue( + "SERIES_NON_RULE_CONDITIONAL_BRANCH", + "Only rule blocks can use yes/no conditional connections.", + "Change this connection condition to default (or remove the condition).", + { connectionId: invalidConditionalConnection._id, blockId: block._id } + ) + ); + } + } + + if (block.type === "wait") { + if (!config.waitType) { + blockers.push( + createReadinessIssue( + "SERIES_WAIT_TYPE_REQUIRED", + "Wait block is missing wait type.", + "Set wait type to duration, until date, or until event.", + { blockId: block._id } + ) + ); + } + if (config.waitType === "duration") { + if (!Number.isFinite(config.waitDuration) || Number(config.waitDuration) <= 0 || !config.waitUnit) { + blockers.push( + createReadinessIssue( + "SERIES_WAIT_DURATION_INVALID", + "Duration wait block requires a positive duration and unit.", + "Set wait duration and choose minutes/hours/days.", + { blockId: block._id } + ) + ); + } + } + if (config.waitType === "until_date" && !Number.isFinite(config.waitUntilDate)) { + blockers.push( + createReadinessIssue( + "SERIES_WAIT_UNTIL_DATE_REQUIRED", + "Until date wait block is missing a target timestamp.", + "Set a valid target date/time for this wait block.", + { blockId: block._id } + ) + ); + } + if (config.waitType === "until_event" && !hasTextContent(config.waitUntilEvent)) { + blockers.push( + createReadinessIssue( + "SERIES_WAIT_UNTIL_EVENT_REQUIRED", + "Until event wait block is missing event name.", + "Set an event name that should resume progress.", + { blockId: block._id } + ) + ); + } + } + + if (block.type === "email") { + if (!hasTextContent(config.subject) || !hasTextContent(config.body)) { + blockers.push( + createReadinessIssue( + "SERIES_EMAIL_CONTENT_REQUIRED", + "Email block requires both subject and body.", + "Fill in email subject and body before activation.", + { blockId: block._id } + ) + ); + } + } + + if (block.type === "push") { + if (!hasTextContent(config.title) || !hasTextContent(config.body)) { + blockers.push( + createReadinessIssue( + "SERIES_PUSH_CONTENT_REQUIRED", + "Push block requires both title and body.", + "Fill in push title and body before activation.", + { blockId: block._id } + ) + ); + } + } + + if ((block.type === "chat" || block.type === "post" || block.type === "carousel") && !hasTextContent(config.body)) { + warnings.push( + createReadinessIssue( + "SERIES_CONTENT_BODY_RECOMMENDED", + `${block.type} block has no body configured.`, + "Add body content so visitors receive a meaningful message.", + { blockId: block._id } + ) + ); + } + + if (block.type === "tag") { + if (!config.tagAction || !hasTextContent(config.tagName)) { + blockers.push( + createReadinessIssue( + "SERIES_TAG_CONFIG_REQUIRED", + "Tag block requires both tag action and tag name.", + "Choose add/remove and provide a tag name.", + { blockId: block._id } + ) + ); + } + } + + if (outgoing.length === 0 && block.type !== "wait") { + warnings.push( + createReadinessIssue( + "SERIES_PATH_TERMINATES", + `Block ${block.type} has no outgoing connection and will terminate the series path.`, + "Add a downstream connection if continuation is intended.", + { blockId: block._id } + ) + ); + } + } + + if ((series.entryTriggers?.length ?? 0) === 0) { + warnings.push( + createReadinessIssue( + "SERIES_ENTRY_TRIGGER_RECOMMENDED", + "Series has no entry triggers configured.", + "Define at least one trigger source so matching visitors can enroll automatically." + ) + ); + } + + if (blocks.some((block) => block.type === "email")) { + const emailConfig = await ctx.db + .query("emailConfigs") + .withIndex("by_workspace", (q) => q.eq("workspaceId", series.workspaceId)) + .first(); + if (!emailConfig?.enabled) { + blockers.push( + createReadinessIssue( + "SERIES_EMAIL_CHANNEL_NOT_CONFIGURED", + "Series references email blocks but email channel is not enabled.", + "Configure and enable email channel in workspace integrations settings." + ) + ); + } + } + + if (blocks.some((block) => block.type === "push")) { + const pushToken = await ctx.db + .query("visitorPushTokens") + .withIndex("by_workspace", (q) => q.eq("workspaceId", series.workspaceId)) + .first(); + if (!pushToken) { + warnings.push( + createReadinessIssue( + "SERIES_PUSH_DELIVERY_UNVERIFIED", + "Series references push blocks but no visitor push tokens are currently registered.", + "Verify push token registration in at least one target environment before activation." + ) + ); + } + } + + return { + blockers, + warnings, + isReady: blockers.length === 0, + }; +} + +export const create = mutation({ + args: { + workspaceId: v.id("workspaces"), + name: v.string(), + description: v.optional(v.string()), + entryTriggers: v.optional(v.array(seriesEntryTriggerValidator)), + entryRules: v.optional(audienceRulesOrSegmentValidator), + exitRules: v.optional(audienceRulesOrSegmentValidator), + goalRules: v.optional(audienceRulesOrSegmentValidator), + }, + handler: async (ctx, args) => { + await requireSeriesManagePermission(ctx, args.workspaceId); + + if (args.entryRules !== undefined && !validateAudienceRule(args.entryRules)) { + throw new Error("Invalid entry rules"); + } + if (args.exitRules !== undefined && !validateAudienceRule(args.exitRules)) { + throw new Error("Invalid exit rules"); + } + if (args.goalRules !== undefined && !validateAudienceRule(args.goalRules)) { + throw new Error("Invalid goal rules"); + } + + const now = nowTs(); + return await ctx.db.insert("series", { + workspaceId: args.workspaceId, + name: args.name, + description: args.description, + entryTriggers: args.entryTriggers, + entryRules: args.entryRules, + exitRules: args.exitRules, + goalRules: args.goalRules, + status: "draft", + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const update = mutation({ + args: { + id: v.id("series"), + name: v.optional(v.string()), + description: v.optional(v.string()), + entryTriggers: v.optional(v.array(seriesEntryTriggerValidator)), + entryRules: v.optional(audienceRulesOrSegmentValidator), + exitRules: v.optional(audienceRulesOrSegmentValidator), + goalRules: v.optional(audienceRulesOrSegmentValidator), + }, + handler: async (ctx, args) => { + const { id, ...updates } = args; + const existing = (await ctx.db.get(id)) as Doc<"series"> | null; + if (!existing) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, existing.workspaceId); + + if (args.entryRules !== undefined && !validateAudienceRule(args.entryRules)) { + throw new Error("Invalid entry rules"); + } + if (args.exitRules !== undefined && !validateAudienceRule(args.exitRules)) { + throw new Error("Invalid exit rules"); + } + if (args.goalRules !== undefined && !validateAudienceRule(args.goalRules)) { + throw new Error("Invalid goal rules"); + } + + await ctx.db.patch(id, { + ...updates, + updatedAt: nowTs(), + }); + return id; + }, +}); + +export const activate = mutation({ + args: { id: v.id("series") }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, series.workspaceId); + + if (!isSeriesRuntimeEnabled()) { + throw new Error(serializeRuntimeGuardError()); + } + + const readiness = await evaluateSeriesReadiness(ctx, series); + if (!readiness.isReady) { + throw new Error(serializeReadinessError(readiness)); + } + + await ctx.db.patch(args.id, { + status: "active", + updatedAt: nowTs(), + stats: normalizeSeriesStats(series), + }); + }, +}); + +export const pause = mutation({ + args: { id: v.id("series") }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, series.workspaceId); + + await ctx.db.patch(args.id, { status: "paused", updatedAt: nowTs() }); + }, +}); + +export const archive = mutation({ + args: { id: v.id("series") }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, series.workspaceId); + + await ctx.db.patch(args.id, { status: "archived", updatedAt: nowTs() }); + }, +}); + +export const list = query({ + args: { + workspaceId: v.id("workspaces"), + status: v.optional(v.union(v.literal("draft"), v.literal("active"), v.literal("paused"), v.literal("archived"))), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const canManage = await canManageSeries(ctx, args.workspaceId); + if (!canManage) { + return []; + } + + const limit = clampLimit(args.limit, DEFAULT_SERIES_LIST_LIMIT, MAX_SERIES_LIST_LIMIT); + let seriesList; + + if (args.status) { + seriesList = await ctx.db + .query("series") + .withIndex("by_workspace_status", (q) => + q.eq("workspaceId", args.workspaceId).eq("status", args.status!) + ) + .order("desc") + .take(limit); + } else { + seriesList = await ctx.db + .query("series") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) + .order("desc") + .take(limit); + } + + return seriesList.sort((a, b) => b.createdAt - a.createdAt); + }, +}); + +export const get = query({ + args: { id: v.id("series") }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; + if (!series) { + return null; + } + const canManage = await canManageSeries(ctx, series.workspaceId); + if (!canManage) { + return null; + } + return series; + }, +}); + +export const getReadiness = query({ + args: { id: v.id("series") }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; + if (!series) { + return null; + } + + const canManage = await canManageSeries(ctx, series.workspaceId); + if (!canManage) { + throw new Error("Permission denied: settings.workspace"); + } + + return await evaluateSeriesReadiness(ctx, series); + }, +}); + +export const getWithBlocks = query({ + args: { + id: v.id("series"), + blockLimit: v.optional(v.number()), + connectionLimit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; + if (!series) return null; + const canManage = await canManageSeries(ctx, series.workspaceId); + if (!canManage) return null; + + const blockLimit = clampLimit(args.blockLimit, DEFAULT_GRAPH_ITEM_LIMIT, MAX_GRAPH_ITEM_LIMIT); + const connectionLimit = clampLimit(args.connectionLimit, DEFAULT_GRAPH_ITEM_LIMIT, MAX_GRAPH_ITEM_LIMIT); + const blocks = await ctx.db + .query("seriesBlocks") + .withIndex("by_series", (q) => q.eq("seriesId", args.id)) + .order("desc") + .take(blockLimit); + + const connections = await ctx.db + .query("seriesConnections") + .withIndex("by_series", (q) => q.eq("seriesId", args.id)) + .order("desc") + .take(connectionLimit); + + return { + ...series, + blocks, + connections, + }; + }, +}); + +export const remove = mutation({ + args: { id: v.id("series") }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, series.workspaceId); + + const blocks = await ctx.db + .query("seriesBlocks") + .withIndex("by_series", (q) => q.eq("seriesId", args.id)) + .collect(); + + for (const block of blocks) { + await ctx.db.delete(block._id); + } + + const connections = await ctx.db + .query("seriesConnections") + .withIndex("by_series", (q) => q.eq("seriesId", args.id)) + .collect(); + + for (const connection of connections) { + await ctx.db.delete(connection._id); + } + + const progressRecords = await ctx.db + .query("seriesProgress") + .withIndex("by_series", (q) => q.eq("seriesId", args.id)) + .collect(); + + for (const progress of progressRecords) { + const history = await ctx.db + .query("seriesProgressHistory") + .withIndex("by_progress", (q) => q.eq("progressId", progress._id)) + .collect(); + + for (const h of history) { + await ctx.db.delete(h._id); + } + + await ctx.db.delete(progress._id); + } + + await ctx.db.delete(args.id); + }, +}); + +export const addBlock = mutation({ + args: { + seriesId: v.id("series"), + type: seriesBlockTypeValidator, + position: v.object({ + x: v.number(), + y: v.number(), + }), + config: seriesBlockConfigValidator, + }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.seriesId)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, series.workspaceId); + + const now = nowTs(); + return await ctx.db.insert("seriesBlocks", { + seriesId: args.seriesId, + type: args.type, + position: args.position, + config: args.config, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const updateBlock = mutation({ + args: { + id: v.id("seriesBlocks"), + position: v.optional( + v.object({ + x: v.number(), + y: v.number(), + }) + ), + config: v.optional(seriesBlockConfigValidator), + }, + handler: async (ctx, args) => { + const { id, ...updates } = args; + const existing = (await ctx.db.get(id)) as Doc<"seriesBlocks"> | null; + if (!existing) throw new Error("Block not found"); + const series = (await ctx.db.get(existing.seriesId)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, series.workspaceId); + + await ctx.db.patch(id, { + ...updates, + updatedAt: nowTs(), + }); + return id; + }, +}); + +export const removeBlock = mutation({ + args: { id: v.id("seriesBlocks") }, + handler: async (ctx, args) => { + const block = (await ctx.db.get(args.id)) as Doc<"seriesBlocks"> | null; + if (!block) throw new Error("Block not found"); + const series = (await ctx.db.get(block.seriesId)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, series.workspaceId); + + const fromConnections = await ctx.db + .query("seriesConnections") + .withIndex("by_from_block", (q) => q.eq("fromBlockId", args.id)) + .collect(); + + const toConnections = await ctx.db + .query("seriesConnections") + .withIndex("by_to_block", (q) => q.eq("toBlockId", args.id)) + .collect(); + + for (const conn of [...fromConnections, ...toConnections]) { + await ctx.db.delete(conn._id); + } + + await ctx.db.delete(args.id); + }, +}); + +export const addConnection = mutation({ + args: { + seriesId: v.id("series"), + fromBlockId: v.id("seriesBlocks"), + toBlockId: v.id("seriesBlocks"), + condition: v.optional(v.union(v.literal("yes"), v.literal("no"), v.literal("default"))), + }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.seriesId)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, series.workspaceId); + + const fromBlock = (await ctx.db.get(args.fromBlockId)) as Doc<"seriesBlocks"> | null; + const toBlock = (await ctx.db.get(args.toBlockId)) as Doc<"seriesBlocks"> | null; + if (!fromBlock || !toBlock) { + throw new Error("Block not found"); + } + if (fromBlock.seriesId !== args.seriesId || toBlock.seriesId !== args.seriesId) { + throw new Error("Blocks must belong to the target series"); + } + + return await ctx.db.insert("seriesConnections", { + seriesId: args.seriesId, + fromBlockId: args.fromBlockId, + toBlockId: args.toBlockId, + condition: args.condition, + createdAt: nowTs(), + }); + }, +}); + +export const removeConnection = mutation({ + args: { id: v.id("seriesConnections") }, + handler: async (ctx, args) => { + const connection = (await ctx.db.get(args.id)) as Doc<"seriesConnections"> | null; + if (!connection) throw new Error("Connection not found"); + const series = (await ctx.db.get(connection.seriesId)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, series.workspaceId); + + await ctx.db.delete(args.id); + }, +}); + +export const duplicate = mutation({ + args: { id: v.id("series") }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + await requireSeriesManagePermission(ctx, series.workspaceId); + + const now = nowTs(); + + const newSeriesId = await ctx.db.insert("series", { + workspaceId: series.workspaceId, + name: `${series.name} (Copy)`, + description: series.description, + entryTriggers: series.entryTriggers, + entryRules: series.entryRules, + exitRules: series.exitRules, + goalRules: series.goalRules, + status: "draft", + createdAt: now, + updatedAt: now, + }); + + const blocks = await ctx.db + .query("seriesBlocks") + .withIndex("by_series", (q) => q.eq("seriesId", args.id)) + .collect(); + + const blockIdMap = new Map>(); + + for (const block of blocks) { + const newBlockId = await ctx.db.insert("seriesBlocks", { + seriesId: newSeriesId, + type: block.type, + position: block.position, + config: block.config, + createdAt: now, + updatedAt: now, + }); + blockIdMap.set(block._id as string, newBlockId); + } + + const connections = await ctx.db + .query("seriesConnections") + .withIndex("by_series", (q) => q.eq("seriesId", args.id)) + .collect(); + + for (const conn of connections) { + const newFromId = blockIdMap.get(conn.fromBlockId as string); + const newToId = blockIdMap.get(conn.toBlockId as string); + + if (newFromId && newToId) { + await ctx.db.insert("seriesConnections", { + seriesId: newSeriesId, + fromBlockId: newFromId, + toBlockId: newToId, + condition: conn.condition, + createdAt: now, + }); + } + } + + return newSeriesId; + }, +}); diff --git a/packages/convex/convex/series/contracts.ts b/packages/convex/convex/series/contracts.ts new file mode 100644 index 0000000..b798e8a --- /dev/null +++ b/packages/convex/convex/series/contracts.ts @@ -0,0 +1,124 @@ +import { v } from "convex/values"; +import type { Doc, Id } from "../_generated/dataModel"; +import { seriesRulesValidator } from "../validators"; + +export const DEFAULT_SERIES_LIST_LIMIT = 100; +export const MAX_SERIES_LIST_LIMIT = 500; +export const DEFAULT_GRAPH_ITEM_LIMIT = 500; +export const MAX_GRAPH_ITEM_LIMIT = 2000; +export const DEFAULT_HISTORY_LIMIT = 500; +export const MAX_HISTORY_LIMIT = 5000; +export const DEFAULT_PROGRESS_SCAN_LIMIT = 5000; +export const MAX_PROGRESS_SCAN_LIMIT = 20000; +export const DEFAULT_WAITING_BATCH_LIMIT = 1000; +export const MAX_WAITING_BATCH_LIMIT = 5000; +export const MAX_SERIES_EXECUTION_DEPTH = 50; +export const MAX_BLOCK_EXECUTION_ATTEMPTS = 3; +export const WAIT_RETRY_BASE_DELAY_MS = 30_000; + +export const SERIES_READINESS_BLOCKED_ERROR_CODE = "SERIES_READINESS_BLOCKED"; +export const SERIES_ORCHESTRATION_GUARD_ERROR_CODE = "SERIES_ORCHESTRATION_DISABLED_BY_GUARD"; + +export const seriesEntryTriggerValidator = 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()), +}); + +export const seriesBlockConfigValidator = v.object({ + rules: v.optional(seriesRulesValidator), + waitType: v.optional( + v.union(v.literal("duration"), v.literal("until_date"), v.literal("until_event")) + ), + waitDuration: v.optional(v.number()), + waitUnit: v.optional(v.union(v.literal("minutes"), v.literal("hours"), v.literal("days"))), + waitUntilDate: v.optional(v.number()), + waitUntilEvent: v.optional(v.string()), + contentId: v.optional(v.string()), + subject: v.optional(v.string()), + body: v.optional(v.string()), + title: v.optional(v.string()), + tagAction: v.optional(v.union(v.literal("add"), v.literal("remove"))), + tagName: v.optional(v.string()), +}); + +export const seriesBlockTypeValidator = v.union( + v.literal("rule"), + v.literal("wait"), + v.literal("email"), + v.literal("push"), + v.literal("chat"), + v.literal("post"), + v.literal("carousel"), + v.literal("tag") +); + +export type SeriesProgressStatus = Doc<"seriesProgress">["status"]; +export type SeriesEntryTrigger = NonNullable["entryTriggers"]>[number]; + +export type SeriesTriggerContext = { + source: SeriesEntryTrigger["source"]; + eventName?: string; + attributeKey?: string; + fromValue?: string; + toValue?: string; +}; + +export type ReadinessIssue = { + code: string; + message: string; + remediation: string; + blockId?: Id<"seriesBlocks">; + connectionId?: Id<"seriesConnections">; +}; + +export type SeriesReadinessResult = { + blockers: ReadinessIssue[]; + warnings: ReadinessIssue[]; + isReady: boolean; +}; + +export type BlockTelemetryPatch = { + yesBranchCount?: number; + noBranchCount?: number; + defaultBranchCount?: number; +}; + +export type BlockExecutionResult = { + status: "completed" | "waiting" | "failed"; + nextBlockId?: Id<"seriesBlocks">; + waitUntil?: number; + waitEventName?: string; + error?: string; + deliveryAttempted?: boolean; + deliveryFailed?: boolean; + telemetryPatch?: BlockTelemetryPatch; +}; + +export type SeriesStatsShape = { + entered: number; + active: number; + waiting: number; + completed: number; + exited: number; + goalReached: number; + failed: number; +}; + +export type SeriesStatsKey = keyof SeriesStatsShape; + +export const SERIES_STATUS_TO_STATS_KEY: Partial> = { + active: "active", + waiting: "waiting", + completed: "completed", + exited: "exited", + goal_reached: "goalReached", + failed: "failed", +}; diff --git a/packages/convex/convex/series/runtime.ts b/packages/convex/convex/series/runtime.ts new file mode 100644 index 0000000..7c5160b --- /dev/null +++ b/packages/convex/convex/series/runtime.ts @@ -0,0 +1,1078 @@ +import { v } from "convex/values"; +import { internalMutation, MutationCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import { evaluateRule, AudienceRule, validateAudienceRule } from "../audienceRules"; +import { + DEFAULT_PROGRESS_SCAN_LIMIT, + DEFAULT_WAITING_BATCH_LIMIT, + MAX_BLOCK_EXECUTION_ATTEMPTS, + MAX_PROGRESS_SCAN_LIMIT, + MAX_SERIES_EXECUTION_DEPTH, + MAX_WAITING_BATCH_LIMIT, + seriesEntryTriggerValidator, + SeriesEntryTrigger, + SeriesProgressStatus, + SeriesStatsKey, + SeriesTriggerContext, + BlockExecutionResult, + SERIES_STATUS_TO_STATS_KEY, +} from "./contracts"; +import { + clampLimit, + clampNonNegative, + findEntryBlocks, + getRetryDelayMs, + hasTextContent, + isSeriesRuntimeEnabled, + isTerminalProgressStatus, + loadSeriesGraph, + normalizeSeriesStats, + normalizeTagName, + normalizeText, + nowTs, + serializeRuntimeGuardError, + sortConnectionsDeterministically, + sortProgressDeterministically, +} from "./shared"; +import { runSeriesEvaluateEntry, scheduleSeriesProcessProgress } from "./scheduler"; +import { upsertBlockTelemetry } from "./telemetry"; + +async function applySeriesStatsDelta( + ctx: MutationCtx, + seriesId: Id<"series">, + delta: Partial> +): Promise { + const series = (await ctx.db.get(seriesId)) as Doc<"series"> | null; + if (!series) { + return; + } + + const stats = normalizeSeriesStats(series); + for (const [key, value] of Object.entries(delta) as Array<[SeriesStatsKey, number | undefined]>) { + if (!value) continue; + stats[key] = clampNonNegative(stats[key] + value); + } + + await ctx.db.patch(seriesId, { + stats, + updatedAt: nowTs(), + }); +} + +async function transitionProgressStatus( + ctx: MutationCtx, + progress: Doc<"seriesProgress">, + nextStatus: SeriesProgressStatus, + patch: Partial> = {} +): Promise { + const now = nowTs(); + const transitionPatch: Partial> = { + ...patch, + status: nextStatus, + }; + + if (nextStatus === "completed" && transitionPatch.completedAt === undefined) { + transitionPatch.completedAt = now; + transitionPatch.currentBlockId = undefined; + } + if (nextStatus === "exited" && transitionPatch.exitedAt === undefined) { + transitionPatch.exitedAt = now; + transitionPatch.currentBlockId = undefined; + } + if (nextStatus === "goal_reached" && transitionPatch.goalReachedAt === undefined) { + transitionPatch.goalReachedAt = now; + transitionPatch.currentBlockId = undefined; + } + if (nextStatus === "failed" && transitionPatch.failedAt === undefined) { + transitionPatch.failedAt = now; + transitionPatch.currentBlockId = undefined; + } + + await ctx.db.patch(progress._id, transitionPatch); + + if (progress.status !== nextStatus) { + const oldKey = SERIES_STATUS_TO_STATS_KEY[progress.status]; + const newKey = SERIES_STATUS_TO_STATS_KEY[nextStatus]; + const delta: Partial> = {}; + if (oldKey) { + delta[oldKey] = (delta[oldKey] ?? 0) - 1; + } + if (newKey) { + delta[newKey] = (delta[newKey] ?? 0) + 1; + } + await applySeriesStatsDelta(ctx, progress.seriesId, delta); + } +} + +async function appendProgressHistory( + ctx: MutationCtx, + progressId: Id<"seriesProgress">, + blockId: Id<"seriesBlocks">, + action: Doc<"seriesProgressHistory">["action"], + result?: Doc<"seriesProgressHistory">["result"] +): Promise { + await ctx.db.insert("seriesProgressHistory", { + progressId, + blockId, + action, + result, + createdAt: nowTs(), + }); +} + +function getConnectionByCondition( + connections: Doc<"seriesConnections">[], + condition: "yes" | "no" | "default" +): Doc<"seriesConnections"> | undefined { + return connections.find((connection) => connection.condition === condition); +} + +function selectNextConnection( + connections: Doc<"seriesConnections">[], + preferredCondition?: "yes" | "no" | "default" +): Doc<"seriesConnections"> | undefined { + if (connections.length === 0) { + return undefined; + } + + if (preferredCondition) { + const preferred = getConnectionByCondition(connections, preferredCondition); + if (preferred) { + return preferred; + } + } + + const defaultBranch = getConnectionByCondition(connections, "default"); + if (defaultBranch) { + return defaultBranch; + } + + const unlabeled = connections.find((connection) => connection.condition === undefined); + if (unlabeled) { + return unlabeled; + } + + return connections[0]; +} + +function durationToMs(waitDuration: number, waitUnit: string): number { + if (waitUnit === "days") return waitDuration * 24 * 60 * 60 * 1000; + if (waitUnit === "hours") return waitDuration * 60 * 60 * 1000; + return waitDuration * 60 * 1000; +} + +function triggerMatches(trigger: SeriesEntryTrigger, context: SeriesTriggerContext): boolean { + if (trigger.source !== context.source) { + return false; + } + + if (trigger.source === "event" || trigger.source === "auto_event") { + if (!trigger.eventName) { + return true; + } + return trigger.eventName === context.eventName; + } + + if (trigger.attributeKey && trigger.attributeKey !== context.attributeKey) { + return false; + } + + if (trigger.fromValue !== undefined && normalizeText(trigger.fromValue) !== normalizeText(context.fromValue)) { + return false; + } + + if (trigger.toValue !== undefined && normalizeText(trigger.toValue) !== normalizeText(context.toValue)) { + return false; + } + + return true; +} + +function seriesAcceptsTrigger(series: Doc<"series">, triggerContext?: SeriesTriggerContext): boolean { + if (!series.entryTriggers || series.entryTriggers.length === 0) { + return true; + } + + if (!triggerContext) { + return false; + } + + return series.entryTriggers.some((trigger) => triggerMatches(trigger, triggerContext)); +} + +function toTriggerIdempotencyContext(triggerContext?: SeriesTriggerContext): string | undefined { + if (!triggerContext) { + return undefined; + } + + return [ + triggerContext.source, + triggerContext.eventName ?? "", + triggerContext.attributeKey ?? "", + normalizeText(triggerContext.fromValue) ?? "", + normalizeText(triggerContext.toValue) ?? "", + ].join("|"); +} + +async function resolveLatestConversationIdForVisitor( + ctx: MutationCtx, + visitorId: Id<"visitors"> +): Promise | undefined> { + const conversations = await ctx.db + .query("conversations") + .withIndex("by_visitor", (q) => q.eq("visitorId", visitorId)) + .order("desc") + .take(1); + return conversations[0]?._id; +} + +async function applyTagBlockMutation( + ctx: MutationCtx, + series: Doc<"series">, + visitor: Doc<"visitors">, + action: "add" | "remove", + tagName: string +): Promise { + const normalizedTag = normalizeTagName(tagName); + if (!normalizedTag) { + return; + } + + const existingTag = await ctx.db + .query("tags") + .withIndex("by_workspace_name", (q) => q.eq("workspaceId", series.workspaceId).eq("name", normalizedTag)) + .first(); + + const tagId = + existingTag?._id ?? + (await ctx.db.insert("tags", { + workspaceId: series.workspaceId, + name: normalizedTag, + createdAt: nowTs(), + updatedAt: nowTs(), + })); + + const conversationId = await resolveLatestConversationIdForVisitor(ctx, visitor._id); + if (!conversationId) { + return; + } + + const existingConversationTag = await ctx.db + .query("conversationTags") + .withIndex("by_conversation_tag", (q) => q.eq("conversationId", conversationId).eq("tagId", tagId)) + .first(); + + if (action === "add" && !existingConversationTag) { + await ctx.db.insert("conversationTags", { + conversationId, + tagId, + appliedBy: "auto", + createdAt: nowTs(), + }); + } + + if (action === "remove" && existingConversationTag) { + await ctx.db.delete(existingConversationTag._id); + } +} + +async function runContentBlockAdapter( + ctx: MutationCtx, + series: Doc<"series">, + visitor: Doc<"visitors">, + block: Doc<"seriesBlocks"> +): Promise<{ deliveryAttempted: boolean; deliveryFailed: boolean; error?: string }> { + const config = block.config ?? {}; + + if (block.type === "email") { + if (!hasTextContent(config.subject) || !hasTextContent(config.body)) { + return { + deliveryAttempted: false, + deliveryFailed: true, + error: "Email block requires subject and body.", + }; + } + + if (!hasTextContent(visitor.email)) { + return { + deliveryAttempted: true, + deliveryFailed: true, + error: "Visitor email is required for email block delivery.", + }; + } + + const emailConfig = await ctx.db + .query("emailConfigs") + .withIndex("by_workspace", (q) => q.eq("workspaceId", series.workspaceId)) + .first(); + if (!emailConfig?.enabled) { + return { + deliveryAttempted: true, + deliveryFailed: true, + error: "Email channel is not configured for workspace.", + }; + } + + return { deliveryAttempted: true, deliveryFailed: false }; + } + + if (block.type === "push") { + if (!hasTextContent(config.title) || !hasTextContent(config.body)) { + return { + deliveryAttempted: false, + deliveryFailed: true, + error: "Push block requires title and body.", + }; + } + + const visitorPushToken = await ctx.db + .query("visitorPushTokens") + .withIndex("by_visitor", (q) => q.eq("visitorId", visitor._id)) + .first(); + if (!visitorPushToken) { + return { + deliveryAttempted: true, + deliveryFailed: true, + error: "No visitor push token found.", + }; + } + + return { deliveryAttempted: true, deliveryFailed: false }; + } + + if (block.type === "chat" || block.type === "post" || block.type === "carousel") { + return { + deliveryAttempted: true, + deliveryFailed: false, + }; + } + + return { + deliveryAttempted: false, + deliveryFailed: true, + error: `Unsupported content block type: ${block.type}`, + }; +} + +async function executeCurrentBlock( + ctx: MutationCtx, + series: Doc<"series">, + visitor: Doc<"visitors">, + progress: Doc<"seriesProgress">, + block: Doc<"seriesBlocks"> +): Promise { + const config = block.config ?? {}; + const outgoing = sortConnectionsDeterministically( + await ctx.db + .query("seriesConnections") + .withIndex("by_from_block", (q) => q.eq("fromBlockId", block._id)) + .collect() + ); + + if (block.type === "rule") { + if (!config.rules || !validateAudienceRule(config.rules)) { + return { + status: "failed", + error: "Rule block configuration is invalid.", + }; + } + + const ruleMatch = await evaluateRule(ctx, config.rules as AudienceRule, visitor); + const nextConnection = selectNextConnection(outgoing, ruleMatch ? "yes" : "no"); + const telemetryPatch: BlockExecutionResult["telemetryPatch"] = { + ...(nextConnection?.condition === "yes" ? { yesBranchCount: 1 } : {}), + ...(nextConnection?.condition === "no" ? { noBranchCount: 1 } : {}), + ...(nextConnection?.condition === "default" || !nextConnection ? { defaultBranchCount: 1 } : {}), + }; + + return { + status: "completed", + nextBlockId: nextConnection?.toBlockId, + telemetryPatch, + }; + } + + if (block.type === "wait") { + if (config.waitType === "duration") { + const duration = Number(config.waitDuration ?? 0); + const waitUnit = normalizeText(config.waitUnit) ?? "minutes"; + if (!Number.isFinite(duration) || duration <= 0) { + return { + status: "failed", + error: "Duration wait block requires positive duration.", + }; + } + + return { + status: "waiting", + waitUntil: nowTs() + durationToMs(duration, waitUnit), + }; + } + + if (config.waitType === "until_date") { + if (!Number.isFinite(config.waitUntilDate)) { + return { + status: "failed", + error: "Until date wait block requires a valid date.", + }; + } + + return { + status: "waiting", + waitUntil: Number(config.waitUntilDate), + }; + } + + if (config.waitType === "until_event") { + if (!hasTextContent(config.waitUntilEvent)) { + return { + status: "failed", + error: "Until event wait block requires an event name.", + }; + } + + return { + status: "waiting", + waitEventName: String(config.waitUntilEvent), + }; + } + + return { + status: "failed", + error: "Unsupported wait block type.", + }; + } + + if (block.type === "tag") { + const tagAction = config.tagAction; + const tagName = normalizeText(config.tagName); + if ((tagAction !== "add" && tagAction !== "remove") || !hasTextContent(tagName)) { + return { + status: "failed", + error: "Tag block requires tag action and tag name.", + }; + } + + await applyTagBlockMutation(ctx, series, visitor, tagAction, tagName!); + const nextConnection = selectNextConnection(outgoing, "default"); + return { + status: "completed", + nextBlockId: nextConnection?.toBlockId, + }; + } + + if ( + block.type === "email" || + block.type === "push" || + block.type === "chat" || + block.type === "post" || + block.type === "carousel" + ) { + const priorCompletion = await ctx.db + .query("seriesProgressHistory") + .withIndex("by_progress", (q) => q.eq("progressId", progress._id)) + .filter((q) => q.and(q.eq(q.field("blockId"), block._id), q.eq(q.field("action"), "completed"))) + .first(); + + if (priorCompletion) { + const nextConnection = selectNextConnection(outgoing, "default"); + return { + status: "completed", + nextBlockId: nextConnection?.toBlockId, + deliveryAttempted: false, + deliveryFailed: false, + }; + } + + const adapterResult = await runContentBlockAdapter(ctx, series, visitor, block); + if (adapterResult.deliveryFailed) { + return { + status: "failed", + error: adapterResult.error, + deliveryAttempted: adapterResult.deliveryAttempted, + deliveryFailed: true, + }; + } + + const nextConnection = selectNextConnection(outgoing, "default"); + return { + status: "completed", + nextBlockId: nextConnection?.toBlockId, + deliveryAttempted: adapterResult.deliveryAttempted, + deliveryFailed: false, + }; + } + + return { + status: "failed", + error: `Unsupported block type: ${block.type}`, + }; +} + +async function processProgressRecord( + ctx: MutationCtx, + progressId: Id<"seriesProgress">, + maxDepth = MAX_SERIES_EXECUTION_DEPTH +): Promise<{ processed: boolean; status: SeriesProgressStatus | "missing"; reason?: string }> { + let depth = 0; + + while (depth < maxDepth) { + const progress = (await ctx.db.get(progressId)) as Doc<"seriesProgress"> | null; + if (!progress) { + return { processed: false, status: "missing", reason: "progress_not_found" }; + } + + const now = nowTs(); + + if (isTerminalProgressStatus(progress.status)) { + return { processed: false, status: progress.status, reason: "already_terminal" }; + } + + const series = (await ctx.db.get(progress.seriesId)) as Doc<"series"> | null; + if (!series) { + return { processed: false, status: progress.status, reason: "series_not_found" }; + } + if (series.status !== "active") { + return { processed: false, status: progress.status, reason: "series_not_active" }; + } + + let block = progress.currentBlockId + ? ((await ctx.db.get(progress.currentBlockId)) as Doc<"seriesBlocks"> | null) + : null; + + if (progress.status === "waiting") { + if (progress.waitEventName) { + return { processed: false, status: progress.status, reason: "waiting_for_event" }; + } + if (progress.waitUntil !== undefined && progress.waitUntil > now) { + return { processed: false, status: progress.status, reason: "waiting_for_time" }; + } + + if (block && block.type === "wait") { + const waitBlock = block; + await appendProgressHistory(ctx, progress._id, block._id, "completed", { + resumedAt: now, + }); + await upsertBlockTelemetry(ctx, progress.seriesId, block._id, { + completed: 1, + lastResult: { status: "wait_resumed" }, + }); + + const waitConnections = sortConnectionsDeterministically( + await ctx.db + .query("seriesConnections") + .withIndex("by_from_block", (q) => q.eq("fromBlockId", waitBlock._id)) + .collect() + ); + const nextConnection = selectNextConnection(waitConnections, "default"); + + if (!nextConnection) { + await transitionProgressStatus(ctx, progress, "completed", { + completedAt: now, + attemptCount: 0, + waitUntil: undefined, + waitEventName: undefined, + lastExecutionError: undefined, + lastBlockExecutedAt: now, + idempotencyKeyContext: `${progress._id}:${block._id}:wait_completed`, + }); + return { processed: true, status: "completed", reason: "wait_terminal_path" }; + } + + await transitionProgressStatus(ctx, progress, "active", { + currentBlockId: nextConnection.toBlockId, + attemptCount: 0, + waitUntil: undefined, + waitEventName: undefined, + lastExecutionError: undefined, + lastBlockExecutedAt: now, + idempotencyKeyContext: `${progress._id}:${nextConnection.toBlockId}:entered`, + }); + await appendProgressHistory(ctx, progress._id, nextConnection.toBlockId, "entered"); + + depth += 1; + continue; + } + } + + const visitor = (await ctx.db.get(progress.visitorId)) as Doc<"visitors"> | null; + if (!visitor) { + await transitionProgressStatus(ctx, progress, "failed", { + failedAt: now, + lastExecutionError: "Visitor not found for series progress.", + }); + return { processed: true, status: "failed", reason: "visitor_not_found" }; + } + + if (series.exitRules) { + const shouldExit = await evaluateRule(ctx, series.exitRules as AudienceRule, visitor); + if (shouldExit) { + await transitionProgressStatus(ctx, progress, "exited", { + lastBlockExecutedAt: now, + waitUntil: undefined, + waitEventName: undefined, + }); + if (progress.currentBlockId) { + await appendProgressHistory(ctx, progress._id, progress.currentBlockId, "skipped", { + reason: "exit_rules_matched", + }); + await upsertBlockTelemetry(ctx, progress.seriesId, progress.currentBlockId, { + skipped: 1, + lastResult: { reason: "exit_rules_matched" }, + }); + } + return { processed: true, status: "exited" }; + } + } + + if (series.goalRules) { + const goalReached = await evaluateRule(ctx, series.goalRules as AudienceRule, visitor); + if (goalReached) { + await transitionProgressStatus(ctx, progress, "goal_reached", { + lastBlockExecutedAt: now, + waitUntil: undefined, + waitEventName: undefined, + }); + if (progress.currentBlockId) { + await appendProgressHistory(ctx, progress._id, progress.currentBlockId, "skipped", { + reason: "goal_rules_matched", + }); + await upsertBlockTelemetry(ctx, progress.seriesId, progress.currentBlockId, { + skipped: 1, + lastResult: { reason: "goal_rules_matched" }, + }); + } + return { processed: true, status: "goal_reached" }; + } + } + + if (!progress.currentBlockId) { + await transitionProgressStatus(ctx, progress, "completed", { + completedAt: now, + }); + return { processed: true, status: "completed", reason: "no_current_block" }; + } + + block = block ?? ((await ctx.db.get(progress.currentBlockId)) as Doc<"seriesBlocks"> | null); + if (!block) { + await transitionProgressStatus(ctx, progress, "failed", { + failedAt: now, + lastExecutionError: "Current block not found.", + }); + return { processed: true, status: "failed", reason: "block_not_found" }; + } + + await upsertBlockTelemetry(ctx, progress.seriesId, block._id, { + entered: 1, + lastResult: { status: "entered" }, + }); + + const execution = await executeCurrentBlock(ctx, series, visitor, progress, block); + const attemptCount = (progress.attemptCount ?? 0) + 1; + + const telemetryPatch = execution.telemetryPatch; + await upsertBlockTelemetry(ctx, progress.seriesId, block._id, { + completed: execution.status === "completed" ? 1 : 0, + failed: execution.status === "failed" ? 1 : 0, + deliveryAttempts: execution.deliveryAttempted ? 1 : 0, + deliveryFailures: execution.deliveryFailed ? 1 : 0, + ...(telemetryPatch?.yesBranchCount ? { yesBranchCount: telemetryPatch.yesBranchCount } : {}), + ...(telemetryPatch?.noBranchCount ? { noBranchCount: telemetryPatch.noBranchCount } : {}), + ...(telemetryPatch?.defaultBranchCount ? { defaultBranchCount: telemetryPatch.defaultBranchCount } : {}), + lastResult: execution.error + ? { status: execution.status, error: execution.error } + : { status: execution.status }, + }); + + if (execution.status === "failed") { + await appendProgressHistory(ctx, progress._id, block._id, "failed", { + error: execution.error ?? "Unknown error", + }); + + if (attemptCount >= MAX_BLOCK_EXECUTION_ATTEMPTS) { + await transitionProgressStatus(ctx, progress, "failed", { + attemptCount, + failedAt: now, + lastExecutionError: execution.error, + lastBlockExecutedAt: now, + waitUntil: undefined, + waitEventName: undefined, + idempotencyKeyContext: `${progress._id}:${block._id}:failed:${attemptCount}`, + }); + return { processed: true, status: "failed", reason: "max_attempts_exceeded" }; + } + + const retryDelay = getRetryDelayMs(attemptCount); + await transitionProgressStatus(ctx, progress, "waiting", { + attemptCount, + waitUntil: now + retryDelay, + waitEventName: undefined, + lastExecutionError: execution.error, + lastBlockExecutedAt: now, + idempotencyKeyContext: `${progress._id}:${block._id}:retry:${attemptCount}`, + }); + + await scheduleSeriesProcessProgress(ctx, { + delayMs: retryDelay, + progressId: progress._id, + }); + return { processed: true, status: "waiting", reason: "retry_scheduled" }; + } + + if (execution.status === "completed") { + await appendProgressHistory(ctx, progress._id, block._id, "completed", { + nextBlockId: execution.nextBlockId ?? null, + }); + } + + if (execution.status === "waiting") { + await transitionProgressStatus(ctx, progress, "waiting", { + attemptCount: 0, + waitUntil: execution.waitUntil, + waitEventName: execution.waitEventName, + lastExecutionError: undefined, + lastBlockExecutedAt: now, + idempotencyKeyContext: `${progress._id}:${block._id}:waiting`, + }); + + if (execution.waitUntil) { + const delay = Math.max(0, execution.waitUntil - now); + await scheduleSeriesProcessProgress(ctx, { + delayMs: delay, + progressId: progress._id, + }); + } + return { processed: true, status: "waiting" }; + } + + if (!execution.nextBlockId) { + await transitionProgressStatus(ctx, progress, "completed", { + completedAt: now, + attemptCount: 0, + waitUntil: undefined, + waitEventName: undefined, + lastExecutionError: undefined, + lastBlockExecutedAt: now, + idempotencyKeyContext: `${progress._id}:${block._id}:completed`, + }); + return { processed: true, status: "completed", reason: "terminal_path" }; + } + + await ctx.db.patch(progress._id, { + currentBlockId: execution.nextBlockId, + status: "active", + attemptCount: 0, + waitUntil: undefined, + waitEventName: undefined, + lastExecutionError: undefined, + lastBlockExecutedAt: now, + idempotencyKeyContext: `${progress._id}:${execution.nextBlockId}:entered`, + }); + await appendProgressHistory(ctx, progress._id, execution.nextBlockId, "entered"); + + depth += 1; + } + + const latest = (await ctx.db.get(progressId)) as Doc<"seriesProgress"> | null; + if (latest && !isTerminalProgressStatus(latest.status)) { + await transitionProgressStatus(ctx, latest, "failed", { + failedAt: nowTs(), + lastExecutionError: "Series execution depth exceeded safety limit.", + lastBlockExecutedAt: nowTs(), + }); + } + + return { + processed: false, + status: latest?.status ?? "missing", + reason: "max_execution_depth_exceeded", + }; +} + +export const evaluateEntry = internalMutation({ + args: { + seriesId: v.id("series"), + visitorId: v.id("visitors"), + triggerContext: v.optional(seriesEntryTriggerValidator), + }, + handler: async (ctx, args) => { + if (!isSeriesRuntimeEnabled()) { + return { entered: false, reason: "runtime_disabled" as const }; + } + + const series = (await ctx.db.get(args.seriesId)) as Doc<"series"> | null; + if (!series || series.status !== "active") { + return { entered: false, reason: "series_not_active" as const }; + } + + if (!seriesAcceptsTrigger(series, args.triggerContext)) { + return { entered: false, reason: "entry_trigger_not_matched" as const }; + } + + const visitor = (await ctx.db.get(args.visitorId)) as Doc<"visitors"> | null; + if (!visitor) { + return { entered: false, reason: "visitor_not_found" as const }; + } + + if (visitor.workspaceId !== series.workspaceId) { + return { entered: false, reason: "workspace_mismatch" as const }; + } + + const existingProgress = await ctx.db + .query("seriesProgress") + .withIndex("by_visitor_series", (q) => q.eq("visitorId", args.visitorId).eq("seriesId", args.seriesId)) + .first(); + + if (existingProgress) { + return { + entered: false, + reason: "already_in_series" as const, + progressId: existingProgress._id, + }; + } + + if (series.entryRules) { + const matches = await evaluateRule(ctx, series.entryRules as AudienceRule, visitor); + if (!matches) { + return { entered: false, reason: "entry_rules_not_met" as const }; + } + } + + const { blocks, connections } = await loadSeriesGraph(ctx, args.seriesId); + const entryBlocks = findEntryBlocks(blocks, connections); + if (entryBlocks.length !== 1) { + return { entered: false, reason: "invalid_entry_path" as const }; + } + + const now = nowTs(); + const idempotencyKeyContext = toTriggerIdempotencyContext(args.triggerContext); + + const progressId = await ctx.db.insert("seriesProgress", { + visitorId: args.visitorId, + seriesId: args.seriesId, + currentBlockId: entryBlocks[0]._id, + status: "active", + attemptCount: 0, + idempotencyKeyContext, + lastTriggerSource: args.triggerContext?.source, + lastTriggerEventName: args.triggerContext?.eventName, + enteredAt: now, + }); + + await ctx.db.insert("seriesProgressHistory", { + progressId, + blockId: entryBlocks[0]._id, + action: "entered", + createdAt: now, + }); + + const allProgress = await ctx.db + .query("seriesProgress") + .withIndex("by_visitor_series", (q) => q.eq("visitorId", args.visitorId).eq("seriesId", args.seriesId)) + .collect(); + + const ordered = [...allProgress].sort((left, right) => { + if (left.enteredAt !== right.enteredAt) { + return left.enteredAt - right.enteredAt; + } + return left._id.toString().localeCompare(right._id.toString()); + }); + + const survivor = ordered[0]; + for (let index = 1; index < ordered.length; index += 1) { + await ctx.db.delete(ordered[index]._id); + } + + if (!survivor || survivor._id !== progressId) { + return { + entered: false, + reason: "already_in_series" as const, + progressId: survivor?._id, + }; + } + + await applySeriesStatsDelta(ctx, args.seriesId, { + entered: 1, + active: 1, + }); + + await processProgressRecord(ctx, progressId); + + return { entered: true, progressId }; + }, +}); + +export const evaluateEnrollmentForVisitor = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + visitorId: v.id("visitors"), + triggerContext: seriesEntryTriggerValidator, + }, + handler: async (ctx, args) => { + if (!isSeriesRuntimeEnabled()) { + return { + evaluated: 0, + entered: 0, + reason: serializeRuntimeGuardError(), + }; + } + + const visitor = (await ctx.db.get(args.visitorId)) as Doc<"visitors"> | null; + if (!visitor || visitor.workspaceId !== args.workspaceId) { + return { + evaluated: 0, + entered: 0, + reason: "visitor_not_found", + }; + } + + const activeSeries = await ctx.db + .query("series") + .withIndex("by_workspace_status", (q) => q.eq("workspaceId", args.workspaceId).eq("status", "active")) + .collect(); + + let entered = 0; + for (const series of activeSeries) { + const result = await runSeriesEvaluateEntry(ctx, { + seriesId: series._id, + visitorId: args.visitorId, + triggerContext: args.triggerContext, + }); + if (result?.entered) { + entered += 1; + } + } + + return { + evaluated: activeSeries.length, + entered, + }; + }, +}); + +export const resumeWaitingForEvent = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + visitorId: v.id("visitors"), + eventName: v.string(), + }, + handler: async (ctx, args) => { + if (!isSeriesRuntimeEnabled()) { + return { + matched: 0, + resumed: 0, + reason: serializeRuntimeGuardError(), + }; + } + + const waitingProgress = sortProgressDeterministically( + await ctx.db + .query("seriesProgress") + .withIndex("by_visitor_status", (q) => q.eq("visitorId", args.visitorId).eq("status", "waiting")) + .collect() + ); + + let matched = 0; + let resumed = 0; + for (const progress of waitingProgress) { + if (!progress.currentBlockId) { + continue; + } + if (progress.waitEventName !== args.eventName) { + continue; + } + + const series = (await ctx.db.get(progress.seriesId)) as Doc<"series"> | null; + if (!series || series.workspaceId !== args.workspaceId || series.status !== "active") { + continue; + } + + matched += 1; + await ctx.db.patch(progress._id, { + waitEventName: undefined, + waitUntil: nowTs(), + }); + + const result = await processProgressRecord(ctx, progress._id); + if (result.processed) { + resumed += 1; + } + } + + return { + matched, + resumed, + }; + }, +}); + +export const processProgress = internalMutation({ + args: { + progressId: v.id("seriesProgress"), + maxDepth: v.optional(v.number()), + }, + handler: async (ctx, args) => { + if (!isSeriesRuntimeEnabled()) { + return { + processed: false, + status: "missing" as const, + reason: serializeRuntimeGuardError(), + }; + } + + const maxDepth = clampLimit(args.maxDepth, MAX_SERIES_EXECUTION_DEPTH, MAX_SERIES_EXECUTION_DEPTH); + return await processProgressRecord(ctx, args.progressId, maxDepth); + }, +}); + +export const processWaitingProgress = internalMutation({ + args: { + seriesLimit: v.optional(v.number()), + waitingLimitPerSeries: v.optional(v.number()), + }, + handler: async (ctx, args) => { + if (!isSeriesRuntimeEnabled()) { + return { + processed: 0, + reason: serializeRuntimeGuardError(), + }; + } + + const now = nowTs(); + const seriesLimit = clampLimit(args.seriesLimit, DEFAULT_PROGRESS_SCAN_LIMIT, MAX_PROGRESS_SCAN_LIMIT); + const waitingLimitPerSeries = clampLimit( + args.waitingLimitPerSeries, + DEFAULT_WAITING_BATCH_LIMIT, + MAX_WAITING_BATCH_LIMIT + ); + + const allSeries = await ctx.db.query("series").order("desc").take(seriesLimit); + let processed = 0; + let scanned = 0; + + for (const series of allSeries) { + if (series.status !== "active") continue; + + const waitingProgress = sortProgressDeterministically( + await ctx.db + .query("seriesProgress") + .withIndex("by_status", (q) => q.eq("seriesId", series._id).eq("status", "waiting")) + .take(waitingLimitPerSeries) + ); + + for (const progress of waitingProgress) { + scanned += 1; + if (!progress.currentBlockId) continue; + if (progress.waitEventName) continue; + if (progress.waitUntil !== undefined && progress.waitUntil > now) continue; + + const result = await processProgressRecord(ctx, progress._id); + if (result.processed) { + processed += 1; + } + } + } + + return { processed, scanned }; + }, +}); diff --git a/packages/convex/convex/series/scheduler.ts b/packages/convex/convex/series/scheduler.ts new file mode 100644 index 0000000..8a62307 --- /dev/null +++ b/packages/convex/convex/series/scheduler.ts @@ -0,0 +1,89 @@ +import type { Id } from "../_generated/dataModel"; +import type { MutationCtx } from "../_generated/server"; +import { internal } from "../_generated/api"; +import type { SeriesTriggerContext } from "./contracts"; + +export type SeriesEntryTriggerContext = SeriesTriggerContext; + +export type SeriesEvaluateEntryResult = { + entered: boolean; + reason?: string; + progressId?: Id<"seriesProgress">; +}; + +export async function scheduleSeriesEvaluateEnrollment( + ctx: MutationCtx, + args: { + workspaceId: Id<"workspaces">; + visitorId: Id<"visitors">; + triggerContext: SeriesEntryTriggerContext; + } +): Promise { + await ctx.scheduler.runAfter(0, internal.series.evaluateEnrollmentForVisitor, args); +} + +export async function scheduleSeriesResumeWaitingForEvent( + ctx: MutationCtx, + args: { + workspaceId: Id<"workspaces">; + visitorId: Id<"visitors">; + eventName: string; + } +): Promise { + await ctx.scheduler.runAfter(0, internal.series.resumeWaitingForEvent, args); +} + +export async function scheduleSeriesProcessProgress( + ctx: MutationCtx, + args: { + delayMs: number; + progressId: Id<"seriesProgress">; + } +): Promise { + await ctx.scheduler.runAfter(args.delayMs, internal.series.processProgress, { + progressId: args.progressId, + }); +} + +export async function runSeriesEvaluateEntry( + ctx: MutationCtx, + args: { + seriesId: Id<"series">; + visitorId: Id<"visitors">; + triggerContext?: SeriesEntryTriggerContext; + } +): Promise { + return await ctx.runMutation(internal.series.evaluateEntry, args); +} + +export async function runSeriesEvaluateEnrollmentForVisitor( + ctx: MutationCtx, + args: { + workspaceId: Id<"workspaces">; + visitorId: Id<"visitors">; + triggerContext: SeriesEntryTriggerContext; + } +): Promise { + return await ctx.runMutation(internal.series.evaluateEnrollmentForVisitor, args); +} + +export async function runSeriesResumeWaitingForEvent( + ctx: MutationCtx, + args: { + workspaceId: Id<"workspaces">; + visitorId: Id<"visitors">; + eventName: string; + } +): Promise { + return await ctx.runMutation(internal.series.resumeWaitingForEvent, args); +} + +export async function runSeriesProcessWaitingProgress( + ctx: MutationCtx, + args: { + seriesLimit?: number; + waitingLimitPerSeries?: number; + } +): Promise { + return await ctx.runMutation(internal.series.processWaitingProgress, args); +} diff --git a/packages/convex/convex/series/shared.ts b/packages/convex/convex/series/shared.ts new file mode 100644 index 0000000..b493109 --- /dev/null +++ b/packages/convex/convex/series/shared.ts @@ -0,0 +1,183 @@ +import type { Doc, Id } from "../_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "../_generated/server"; +import { getAuthenticatedUserFromSession } from "../auth"; +import { hasPermission, requirePermission } from "../permissions"; +import { + SERIES_ORCHESTRATION_GUARD_ERROR_CODE, + SERIES_READINESS_BLOCKED_ERROR_CODE, + SeriesProgressStatus, + SeriesReadinessResult, + SeriesStatsShape, + WAIT_RETRY_BASE_DELAY_MS, + ReadinessIssue, +} from "./contracts"; + +export function nowTs(): number { + return Date.now(); +} + +export function normalizeSeriesStats(series: Doc<"series">): SeriesStatsShape { + return { + entered: series.stats?.entered ?? 0, + active: series.stats?.active ?? 0, + waiting: series.stats?.waiting ?? 0, + completed: series.stats?.completed ?? 0, + exited: series.stats?.exited ?? 0, + goalReached: series.stats?.goalReached ?? 0, + failed: series.stats?.failed ?? 0, + }; +} + +export function normalizeText(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + return String(value); +} + +export function normalizeTagName(value: string): string { + return value.trim().toLowerCase(); +} + +export function isTerminalProgressStatus(status: SeriesProgressStatus): boolean { + return ( + status === "completed" || + status === "exited" || + status === "goal_reached" || + status === "failed" + ); +} + +export function getRetryDelayMs(attempt: number): number { + return WAIT_RETRY_BASE_DELAY_MS * Math.max(1, attempt); +} + +export function sortConnectionsDeterministically(connections: Doc<"seriesConnections">[]) { + return [...connections].sort((left, right) => { + if (left.createdAt !== right.createdAt) { + return left.createdAt - right.createdAt; + } + return left._id.toString().localeCompare(right._id.toString()); + }); +} + +export function sortProgressDeterministically(progressList: Doc<"seriesProgress">[]) { + return [...progressList].sort((left, right) => { + const leftWait = left.waitUntil ?? Number.MAX_SAFE_INTEGER; + const rightWait = right.waitUntil ?? Number.MAX_SAFE_INTEGER; + if (leftWait !== rightWait) { + return leftWait - rightWait; + } + return left._id.toString().localeCompare(right._id.toString()); + }); +} + +export function clampLimit(limit: number | undefined, defaultValue: number, maxValue: number): number { + const normalized = limit ?? defaultValue; + if (!Number.isFinite(normalized) || normalized <= 0) { + return defaultValue; + } + return Math.min(Math.floor(normalized), maxValue); +} + +export async function requireSeriesManagePermission( + ctx: QueryCtx | MutationCtx, + workspaceId: Id<"workspaces"> +) { + const user = await getAuthenticatedUserFromSession(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + await requirePermission(ctx, user._id, workspaceId, "settings.workspace"); +} + +export async function canManageSeries(ctx: QueryCtx | MutationCtx, workspaceId: Id<"workspaces">) { + const user = await getAuthenticatedUserFromSession(ctx); + if (!user) { + return false; + } + return await hasPermission(ctx, user._id, workspaceId, "settings.workspace"); +} + +export function isSeriesRuntimeEnabled(): boolean { + return process.env.OPENCOM_ENABLE_SERIES_ORCHESTRATION !== "false"; +} + +export function clampNonNegative(value: number): number { + return value < 0 ? 0 : value; +} + +export function createReadinessIssue( + code: string, + message: string, + remediation: string, + context?: Pick +): ReadinessIssue { + return { + code, + message, + remediation, + ...(context?.blockId ? { blockId: context.blockId } : {}), + ...(context?.connectionId ? { connectionId: context.connectionId } : {}), + }; +} + +export function toStringSet(values: Array): Set { + return new Set(values.filter((value): value is string => Boolean(value))); +} + +export function getOutgoingConnectionsForBlock( + connections: Doc<"seriesConnections">[], + blockId: Id<"seriesBlocks"> +): Doc<"seriesConnections">[] { + return sortConnectionsDeterministically( + connections.filter((connection) => connection.fromBlockId === blockId) + ); +} + +export function findEntryBlocks( + blocks: Doc<"seriesBlocks">[], + connections: Doc<"seriesConnections">[] +): Doc<"seriesBlocks">[] { + const incoming = toStringSet(connections.map((connection) => connection.toBlockId as string)); + return blocks.filter((block) => !incoming.has(block._id as string)); +} + +export function hasTextContent(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +export function serializeReadinessError(readiness: SeriesReadinessResult): string { + return JSON.stringify({ + code: SERIES_READINESS_BLOCKED_ERROR_CODE, + blockers: readiness.blockers, + warnings: readiness.warnings, + }); +} + +export function serializeRuntimeGuardError(): string { + return JSON.stringify({ + code: SERIES_ORCHESTRATION_GUARD_ERROR_CODE, + message: "Series orchestration runtime is currently disabled by guard.", + }); +} + +export async function loadSeriesGraph( + ctx: QueryCtx | MutationCtx, + seriesId: Id<"series"> +): Promise<{ blocks: Doc<"seriesBlocks">[]; connections: Doc<"seriesConnections">[] }> { + const blocks = await ctx.db + .query("seriesBlocks") + .withIndex("by_series", (q) => q.eq("seriesId", seriesId)) + .collect(); + + const connections = await ctx.db + .query("seriesConnections") + .withIndex("by_series", (q) => q.eq("seriesId", seriesId)) + .collect(); + + return { + blocks, + connections: sortConnectionsDeterministically(connections), + }; +} diff --git a/packages/convex/convex/series/telemetry.ts b/packages/convex/convex/series/telemetry.ts new file mode 100644 index 0000000..4c49857 --- /dev/null +++ b/packages/convex/convex/series/telemetry.ts @@ -0,0 +1,261 @@ +import { v } from "convex/values"; +import { internalMutation, internalQuery, query, MutationCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import { + DEFAULT_GRAPH_ITEM_LIMIT, + DEFAULT_HISTORY_LIMIT, + DEFAULT_PROGRESS_SCAN_LIMIT, + MAX_GRAPH_ITEM_LIMIT, + MAX_HISTORY_LIMIT, + MAX_PROGRESS_SCAN_LIMIT, +} from "./contracts"; +import { canManageSeries, clampLimit, nowTs } from "./shared"; + +export async function upsertBlockTelemetry( + ctx: MutationCtx, + seriesId: Id<"series">, + blockId: Id<"seriesBlocks">, + patch: { + entered?: number; + completed?: number; + skipped?: number; + failed?: number; + deliveryAttempts?: number; + deliveryFailures?: number; + yesBranchCount?: number; + noBranchCount?: number; + defaultBranchCount?: number; + lastResult?: Doc<"seriesBlockTelemetry">["lastResult"]; + } +): Promise { + const existing = await ctx.db + .query("seriesBlockTelemetry") + .withIndex("by_series_block", (q) => q.eq("seriesId", seriesId).eq("blockId", blockId)) + .first(); + + if (!existing) { + await ctx.db.insert("seriesBlockTelemetry", { + seriesId, + blockId, + entered: patch.entered ?? 0, + completed: patch.completed ?? 0, + skipped: patch.skipped ?? 0, + failed: patch.failed ?? 0, + deliveryAttempts: patch.deliveryAttempts ?? 0, + deliveryFailures: patch.deliveryFailures ?? 0, + yesBranchCount: patch.yesBranchCount, + noBranchCount: patch.noBranchCount, + defaultBranchCount: patch.defaultBranchCount, + lastResult: patch.lastResult, + updatedAt: nowTs(), + }); + return; + } + + await ctx.db.patch(existing._id, { + entered: existing.entered + (patch.entered ?? 0), + completed: existing.completed + (patch.completed ?? 0), + skipped: existing.skipped + (patch.skipped ?? 0), + failed: existing.failed + (patch.failed ?? 0), + deliveryAttempts: existing.deliveryAttempts + (patch.deliveryAttempts ?? 0), + deliveryFailures: existing.deliveryFailures + (patch.deliveryFailures ?? 0), + yesBranchCount: (existing.yesBranchCount ?? 0) + (patch.yesBranchCount ?? 0), + noBranchCount: (existing.noBranchCount ?? 0) + (patch.noBranchCount ?? 0), + defaultBranchCount: (existing.defaultBranchCount ?? 0) + (patch.defaultBranchCount ?? 0), + ...(patch.lastResult !== undefined ? { lastResult: patch.lastResult } : {}), + updatedAt: nowTs(), + }); +} + +export const getProgress = internalQuery({ + args: { + seriesId: v.id("series"), + visitorId: v.id("visitors"), + historyLimit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.seriesId)) as Doc<"series"> | null; + if (!series) { + return null; + } + + const progress = await ctx.db + .query("seriesProgress") + .withIndex("by_visitor_series", (q) => + q.eq("visitorId", args.visitorId).eq("seriesId", args.seriesId) + ) + .first(); + + if (!progress) return null; + + const historyLimit = clampLimit(args.historyLimit, DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT); + const history = await ctx.db + .query("seriesProgressHistory") + .withIndex("by_progress", (q) => q.eq("progressId", progress._id)) + .order("desc") + .take(historyLimit); + + return { + ...progress, + history: history.sort((a, b) => a.createdAt - b.createdAt), + }; + }, +}); + +export const exitProgress = internalMutation({ + args: { + progressId: v.id("seriesProgress"), + reason: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const progress = (await ctx.db.get(args.progressId)) as Doc<"seriesProgress"> | null; + if (!progress) throw new Error("Progress not found"); + const series = (await ctx.db.get(progress.seriesId)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + + const now = nowTs(); + + await ctx.db.patch(args.progressId, { + status: "exited", + exitedAt: now, + }); + + if (series.stats) { + await ctx.db.patch(progress.seriesId, { + stats: { + ...series.stats, + exited: series.stats.exited + 1, + }, + }); + } + }, +}); + +export const markGoalReached = internalMutation({ + args: { + progressId: v.id("seriesProgress"), + }, + handler: async (ctx, args) => { + const progress = (await ctx.db.get(args.progressId)) as Doc<"seriesProgress"> | null; + if (!progress) throw new Error("Progress not found"); + const series = (await ctx.db.get(progress.seriesId)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + + const now = nowTs(); + + await ctx.db.patch(args.progressId, { + status: "goal_reached", + goalReachedAt: now, + }); + + if (series.stats) { + await ctx.db.patch(progress.seriesId, { + stats: { + ...series.stats, + goalReached: series.stats.goalReached + 1, + }, + }); + } + }, +}); + +export const getStats = query({ + args: { + id: v.id("series"), + scanLimit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + const canManage = await canManageSeries(ctx, series.workspaceId); + if (!canManage) { + throw new Error("Permission denied: settings.workspace"); + } + + const scanLimit = clampLimit(args.scanLimit, DEFAULT_PROGRESS_SCAN_LIMIT, MAX_PROGRESS_SCAN_LIMIT); + const progressRecords = await ctx.db + .query("seriesProgress") + .withIndex("by_series", (q) => q.eq("seriesId", args.id)) + .order("desc") + .take(scanLimit); + const truncated = progressRecords.length >= scanLimit; + + return { + total: progressRecords.length, + active: progressRecords.filter((p) => p.status === "active").length, + waiting: progressRecords.filter((p) => p.status === "waiting").length, + completed: progressRecords.filter((p) => p.status === "completed").length, + exited: progressRecords.filter((p) => p.status === "exited").length, + goalReached: progressRecords.filter((p) => p.status === "goal_reached").length, + failed: progressRecords.filter((p) => p.status === "failed").length, + completionRate: + progressRecords.length > 0 + ? (progressRecords.filter((p) => p.status === "completed").length / progressRecords.length) * + 100 + : 0, + goalRate: + progressRecords.length > 0 + ? (progressRecords.filter((p) => p.status === "goal_reached").length / progressRecords.length) * + 100 + : 0, + truncated, + }; + }, +}); + +export const getTelemetry = query({ + args: { + id: v.id("series"), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const series = (await ctx.db.get(args.id)) as Doc<"series"> | null; + if (!series) throw new Error("Series not found"); + + const canManage = await canManageSeries(ctx, series.workspaceId); + if (!canManage) { + throw new Error("Permission denied: settings.workspace"); + } + + const limit = clampLimit(args.limit, DEFAULT_GRAPH_ITEM_LIMIT, MAX_GRAPH_ITEM_LIMIT); + const telemetryRows = await ctx.db + .query("seriesBlockTelemetry") + .withIndex("by_series", (q) => q.eq("seriesId", args.id)) + .order("desc") + .take(limit); + + const blockRows = await ctx.db + .query("seriesBlocks") + .withIndex("by_series", (q) => q.eq("seriesId", args.id)) + .collect(); + const blockMap = new Map(blockRows.map((block) => [block._id, block] as const)); + + const totals = telemetryRows.reduce( + (acc, row) => { + acc.entered += row.entered; + acc.completed += row.completed; + acc.skipped += row.skipped; + acc.failed += row.failed; + acc.deliveryAttempts += row.deliveryAttempts; + acc.deliveryFailures += row.deliveryFailures; + return acc; + }, + { + entered: 0, + completed: 0, + skipped: 0, + failed: 0, + deliveryAttempts: 0, + deliveryFailures: 0, + } + ); + + return { + totals, + blocks: telemetryRows.map((row) => ({ + ...row, + block: blockMap.get(row.blockId) ?? null, + })), + }; + }, +}); diff --git a/packages/convex/convex/testing/helpers.ts b/packages/convex/convex/testing/helpers.ts index 97e2b00..4804635 100644 --- a/packages/convex/convex/testing/helpers.ts +++ b/packages/convex/convex/testing/helpers.ts @@ -3,6 +3,11 @@ 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( @@ -94,7 +99,7 @@ export const runSeriesEvaluateEnrollmentForVisitor: ReturnType => { - return await ctx.runMutation((internal as any).series.evaluateEnrollmentForVisitor, args); + return await runSeriesEvaluateEnrollmentForVisitorInternal(ctx, args); }, }); @@ -109,7 +114,7 @@ export const runSeriesResumeWaitingForEvent: ReturnType eventName: v.string(), }, handler: async (ctx, args): Promise => { - return await ctx.runMutation((internal as any).series.resumeWaitingForEvent, args); + return await runSeriesResumeWaitingForEventInternal(ctx, args); }, } ); @@ -124,7 +129,7 @@ export const runSeriesProcessWaitingProgress: ReturnType => { - return await ctx.runMutation((internal as any).series.processWaitingProgress, args); + return await runSeriesProcessWaitingProgressInternal(ctx, args); }, }); diff --git a/packages/convex/convex/visitors.ts b/packages/convex/convex/visitors.ts index 12addf6..80dedfc 100644 --- a/packages/convex/convex/visitors.ts +++ b/packages/convex/convex/visitors.ts @@ -9,6 +9,7 @@ import { resolveVisitorFromSession } from "./widgetSessions"; import { customAttributesValidator } from "./validators"; import { formatReadableVisitorId } from "./visitorReadableId"; import { logAudit } from "./auditLogs"; +import { scheduleSeriesEvaluateEnrollment } from "./series/scheduler"; const locationValidator = v.optional( v.object({ @@ -160,7 +161,7 @@ async function scheduleSeriesTriggerChanges( } ): Promise { for (const change of args.changes) { - await ctx.scheduler.runAfter(0, (internal as any).series.evaluateEnrollmentForVisitor, { + await scheduleSeriesEvaluateEnrollment(ctx, { workspaceId: args.workspaceId, visitorId: args.visitorId, triggerContext: { diff --git a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts index a919fc4..0526fa9 100644 --- a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts +++ b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it } from "vitest"; const TARGET_FILES = [ "../convex/events.ts", - "../convex/series.ts", + "../convex/series/runtime.ts", + "../convex/series/scheduler.ts", "../convex/lib/authWrappers.ts", ]; @@ -17,13 +18,16 @@ describe("runtime type hardening guards", () => { it("routes series runtime internal calls through typed adapters", () => { const eventsSource = readFileSync(new URL("../convex/events.ts", import.meta.url), "utf8"); - const seriesSource = readFileSync(new URL("../convex/series.ts", import.meta.url), "utf8"); + const seriesRuntimeSource = readFileSync( + new URL("../convex/series/runtime.ts", import.meta.url), + "utf8" + ); expect(eventsSource).not.toContain("(internal as any).series"); - expect(seriesSource).not.toContain("(internal as any).series"); + expect(seriesRuntimeSource).not.toContain("(internal as any).series"); expect(eventsSource).toContain("scheduleSeriesEvaluateEnrollment"); expect(eventsSource).toContain("scheduleSeriesResumeWaitingForEvent"); - expect(seriesSource).toContain("scheduleSeriesProcessProgress"); - expect(seriesSource).toContain("runSeriesEvaluateEntry"); + expect(seriesRuntimeSource).toContain("scheduleSeriesProcessProgress"); + expect(seriesRuntimeSource).toContain("runSeriesEvaluateEntry"); }); }); From d8a82370d2e132e96ce4092a49dd36308d51775f Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 16:18:03 +0000 Subject: [PATCH 09/91] Archive proposals --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../convex-series-engine-modularity/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../convex-test-fixture-modularity/spec.md | 0 .../tasks.md | 20 ++++++++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../spec.md | 0 .../tasks.md | 19 ++++++++ .../split-convex-test-helper-modules/tasks.md | 20 -------- .../tasks.md | 19 -------- .../convex-series-engine-modularity/spec.md | 46 +++++++++++++++++++ .../convex-test-fixture-modularity/spec.md | 35 ++++++++++++++ .../spec.md | 34 ++++++++++++++ 20 files changed, 154 insertions(+), 39 deletions(-) rename openspec/changes/{split-convex-series-engine => archive/2026-03-05-split-convex-series-engine}/.openspec.yaml (100%) rename openspec/changes/{split-convex-series-engine => archive/2026-03-05-split-convex-series-engine}/design.md (100%) rename openspec/changes/{split-convex-series-engine => archive/2026-03-05-split-convex-series-engine}/proposal.md (100%) rename openspec/changes/{split-convex-series-engine => archive/2026-03-05-split-convex-series-engine}/specs/convex-series-engine-modularity/spec.md (100%) rename openspec/changes/{split-convex-series-engine => archive/2026-03-05-split-convex-series-engine}/tasks.md (100%) rename openspec/changes/{split-convex-test-helper-modules => archive/2026-03-05-split-convex-test-helper-modules}/.openspec.yaml (100%) rename openspec/changes/{split-convex-test-helper-modules => archive/2026-03-05-split-convex-test-helper-modules}/design.md (100%) rename openspec/changes/{split-convex-test-helper-modules => archive/2026-03-05-split-convex-test-helper-modules}/proposal.md (100%) rename openspec/changes/{split-convex-test-helper-modules => archive/2026-03-05-split-convex-test-helper-modules}/specs/convex-test-fixture-modularity/spec.md (100%) create mode 100644 openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/tasks.md rename openspec/changes/{standardize-frontend-error-feedback => archive/2026-03-05-standardize-frontend-error-feedback}/.openspec.yaml (100%) rename openspec/changes/{standardize-frontend-error-feedback => archive/2026-03-05-standardize-frontend-error-feedback}/design.md (100%) rename openspec/changes/{standardize-frontend-error-feedback => archive/2026-03-05-standardize-frontend-error-feedback}/proposal.md (100%) rename openspec/changes/{standardize-frontend-error-feedback => archive/2026-03-05-standardize-frontend-error-feedback}/specs/frontend-error-feedback-standardization/spec.md (100%) create mode 100644 openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/tasks.md delete mode 100644 openspec/changes/split-convex-test-helper-modules/tasks.md delete mode 100644 openspec/changes/standardize-frontend-error-feedback/tasks.md create mode 100644 openspec/specs/convex-series-engine-modularity/spec.md create mode 100644 openspec/specs/convex-test-fixture-modularity/spec.md create mode 100644 openspec/specs/frontend-error-feedback-standardization/spec.md diff --git a/openspec/changes/split-convex-series-engine/.openspec.yaml b/openspec/changes/archive/2026-03-05-split-convex-series-engine/.openspec.yaml similarity index 100% rename from openspec/changes/split-convex-series-engine/.openspec.yaml rename to openspec/changes/archive/2026-03-05-split-convex-series-engine/.openspec.yaml diff --git a/openspec/changes/split-convex-series-engine/design.md b/openspec/changes/archive/2026-03-05-split-convex-series-engine/design.md similarity index 100% rename from openspec/changes/split-convex-series-engine/design.md rename to openspec/changes/archive/2026-03-05-split-convex-series-engine/design.md diff --git a/openspec/changes/split-convex-series-engine/proposal.md b/openspec/changes/archive/2026-03-05-split-convex-series-engine/proposal.md similarity index 100% rename from openspec/changes/split-convex-series-engine/proposal.md rename to openspec/changes/archive/2026-03-05-split-convex-series-engine/proposal.md diff --git a/openspec/changes/split-convex-series-engine/specs/convex-series-engine-modularity/spec.md b/openspec/changes/archive/2026-03-05-split-convex-series-engine/specs/convex-series-engine-modularity/spec.md similarity index 100% rename from openspec/changes/split-convex-series-engine/specs/convex-series-engine-modularity/spec.md rename to openspec/changes/archive/2026-03-05-split-convex-series-engine/specs/convex-series-engine-modularity/spec.md diff --git a/openspec/changes/split-convex-series-engine/tasks.md b/openspec/changes/archive/2026-03-05-split-convex-series-engine/tasks.md similarity index 100% rename from openspec/changes/split-convex-series-engine/tasks.md rename to openspec/changes/archive/2026-03-05-split-convex-series-engine/tasks.md diff --git a/openspec/changes/split-convex-test-helper-modules/.openspec.yaml b/openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/.openspec.yaml similarity index 100% rename from openspec/changes/split-convex-test-helper-modules/.openspec.yaml rename to openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/.openspec.yaml diff --git a/openspec/changes/split-convex-test-helper-modules/design.md b/openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/design.md similarity index 100% rename from openspec/changes/split-convex-test-helper-modules/design.md rename to openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/design.md diff --git a/openspec/changes/split-convex-test-helper-modules/proposal.md b/openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/proposal.md similarity index 100% rename from openspec/changes/split-convex-test-helper-modules/proposal.md rename to openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/proposal.md diff --git a/openspec/changes/split-convex-test-helper-modules/specs/convex-test-fixture-modularity/spec.md b/openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/specs/convex-test-fixture-modularity/spec.md similarity index 100% rename from openspec/changes/split-convex-test-helper-modules/specs/convex-test-fixture-modularity/spec.md rename to openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/specs/convex-test-fixture-modularity/spec.md diff --git a/openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/tasks.md b/openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/tasks.md new file mode 100644 index 0000000..dabe719 --- /dev/null +++ b/openspec/changes/archive/2026-03-05-split-convex-test-helper-modules/tasks.md @@ -0,0 +1,20 @@ +## 1. Domain Module Structure + +- [x] 1.1 Define helper/fixture domain boundaries and create target module layout. +- [x] 1.2 Add compatibility barrel entry points for incremental migration. + +## 2. Extraction + +- [x] 2.1 Move helper utilities into domain-focused modules. +- [x] 2.2 Move seed/test data blocks into domain-focused modules. +- [x] 2.3 Update key tests to consume domain module imports. + +## 3. Validation + +- [x] 3.1 Run Convex test suites that depend on migrated helpers. +- [x] 3.2 Verify deterministic fixture behavior in migrated domains. + +## 4. Cleanup + +- [x] 4.1 Remove obsolete monolithic helper/data files once migration is complete. +- [x] 4.2 Document fixture ownership conventions for future contributions. diff --git a/openspec/changes/standardize-frontend-error-feedback/.openspec.yaml b/openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/.openspec.yaml similarity index 100% rename from openspec/changes/standardize-frontend-error-feedback/.openspec.yaml rename to openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/.openspec.yaml diff --git a/openspec/changes/standardize-frontend-error-feedback/design.md b/openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/design.md similarity index 100% rename from openspec/changes/standardize-frontend-error-feedback/design.md rename to openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/design.md diff --git a/openspec/changes/standardize-frontend-error-feedback/proposal.md b/openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/proposal.md similarity index 100% rename from openspec/changes/standardize-frontend-error-feedback/proposal.md rename to openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/proposal.md diff --git a/openspec/changes/standardize-frontend-error-feedback/specs/frontend-error-feedback-standardization/spec.md b/openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/specs/frontend-error-feedback-standardization/spec.md similarity index 100% rename from openspec/changes/standardize-frontend-error-feedback/specs/frontend-error-feedback-standardization/spec.md rename to openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/specs/frontend-error-feedback-standardization/spec.md diff --git a/openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/tasks.md b/openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/tasks.md new file mode 100644 index 0000000..24adfb5 --- /dev/null +++ b/openspec/changes/archive/2026-03-05-standardize-frontend-error-feedback/tasks.md @@ -0,0 +1,19 @@ +## 1. Shared Error Feedback Foundation + +- [x] 1.1 Define shared error feedback primitives/utilities for covered frontend surfaces. +- [x] 1.2 Implement shared unknown-error normalization helper(s). + +## 2. Path Migration + +- [x] 2.1 Replace raw alerts in web settings error paths with standardized feedback. +- [x] 2.2 Replace raw alerts in widget action paths with standardized feedback. + +## 3. Verification + +- [x] 3.1 Add targeted frontend tests for error display and actionable messaging in migrated paths. +- [x] 3.2 Run web/widget typecheck/tests for touched paths. + +## 4. Cleanup + +- [x] 4.1 Remove remaining raw alert usages in covered scope. +- [x] 4.2 Document standard error feedback usage guidelines for contributors. diff --git a/openspec/changes/split-convex-test-helper-modules/tasks.md b/openspec/changes/split-convex-test-helper-modules/tasks.md deleted file mode 100644 index 5929785..0000000 --- a/openspec/changes/split-convex-test-helper-modules/tasks.md +++ /dev/null @@ -1,20 +0,0 @@ -## 1. Domain Module Structure - -- [ ] 1.1 Define helper/fixture domain boundaries and create target module layout. -- [ ] 1.2 Add compatibility barrel entry points for incremental migration. - -## 2. Extraction - -- [ ] 2.1 Move helper utilities into domain-focused modules. -- [ ] 2.2 Move seed/test data blocks into domain-focused modules. -- [ ] 2.3 Update key tests to consume domain module imports. - -## 3. Validation - -- [ ] 3.1 Run Convex test suites that depend on migrated helpers. -- [ ] 3.2 Verify deterministic fixture behavior in migrated domains. - -## 4. Cleanup - -- [ ] 4.1 Remove obsolete monolithic helper/data files once migration is complete. -- [ ] 4.2 Document fixture ownership conventions for future contributions. diff --git a/openspec/changes/standardize-frontend-error-feedback/tasks.md b/openspec/changes/standardize-frontend-error-feedback/tasks.md deleted file mode 100644 index 6769685..0000000 --- a/openspec/changes/standardize-frontend-error-feedback/tasks.md +++ /dev/null @@ -1,19 +0,0 @@ -## 1. Shared Error Feedback Foundation - -- [ ] 1.1 Define shared error feedback primitives/utilities for covered frontend surfaces. -- [ ] 1.2 Implement shared unknown-error normalization helper(s). - -## 2. Path Migration - -- [ ] 2.1 Replace raw alerts in web settings error paths with standardized feedback. -- [ ] 2.2 Replace raw alerts in widget action paths with standardized feedback. - -## 3. Verification - -- [ ] 3.1 Add targeted frontend tests for error display and actionable messaging in migrated paths. -- [ ] 3.2 Run web/widget typecheck/tests for touched paths. - -## 4. Cleanup - -- [ ] 4.1 Remove remaining raw alert usages in covered scope. -- [ ] 4.2 Document standard error feedback usage guidelines for contributors. diff --git a/openspec/specs/convex-series-engine-modularity/spec.md b/openspec/specs/convex-series-engine-modularity/spec.md new file mode 100644 index 0000000..951ca8d --- /dev/null +++ b/openspec/specs/convex-series-engine-modularity/spec.md @@ -0,0 +1,46 @@ +# convex-series-engine-modularity Specification + +## Purpose +TBD - created by archiving change split-convex-series-engine. Update Purpose after archive. + +## Requirements + +### Requirement: Series engine MUST separate authoring, runtime, scheduler, and telemetry responsibilities + +The series engine SHALL implement authoring APIs, runtime progression, scheduler/event orchestration, and telemetry updates in explicit modules with stable interfaces. + +#### Scenario: Authoring logic changes + +- **WHEN** a contributor updates series authoring validation or persistence behavior +- **THEN** the change SHALL be contained in the authoring module +- **AND** runtime progression and telemetry modules SHALL not require edits + +#### Scenario: Telemetry logic changes + +- **WHEN** a contributor updates block telemetry aggregation behavior +- **THEN** the change SHALL be contained in the telemetry module +- **AND** authoring and runtime modules SHALL remain unaffected + +### Requirement: Refactor MUST preserve progression and scheduling semantics + +The modularized implementation MUST preserve existing series progression behavior for retries, wait states, trigger-driven resume, and terminal status transitions. + +#### Scenario: Retry scheduling after transient failure + +- **WHEN** runtime progression fails and schedules a retry +- **THEN** retry timing and target internal handler SHALL remain behaviorally equivalent to pre-refactor behavior + +#### Scenario: Resume from wait-for-event state + +- **WHEN** a qualifying event resumes a waiting series progress record +- **THEN** the same next block transition and status updates SHALL occur as before refactor + +### Requirement: Runtime scheduler integrations MUST use typed internal adapters + +Scheduler/internal runtime call sites SHALL be routed through typed adapters rather than repeated broad casts in progression logic. + +#### Scenario: Runtime schedules internal progression handler + +- **WHEN** progression logic enqueues an internal follow-up run +- **THEN** the call SHALL use the typed scheduler adapter +- **AND** runtime-critical paths SHALL avoid new unsafe cast expansion diff --git a/openspec/specs/convex-test-fixture-modularity/spec.md b/openspec/specs/convex-test-fixture-modularity/spec.md new file mode 100644 index 0000000..c912059 --- /dev/null +++ b/openspec/specs/convex-test-fixture-modularity/spec.md @@ -0,0 +1,35 @@ +# convex-test-fixture-modularity Specification + +## Purpose +TBD - created by archiving change split-convex-test-helper-modules. Update Purpose after archive. + +## Requirements + +### Requirement: Convex test helpers and fixtures MUST be organized by domain modules + +Convex test helper and fixture code SHALL be split into domain-focused modules rather than consolidated mega-files. + +#### Scenario: Contributor adds new AI fixture helper + +- **WHEN** a contributor adds a new AI-related test helper +- **THEN** the helper SHALL be added to the AI testing module +- **AND** unrelated domains SHALL not require edits + +### Requirement: Migration MUST provide compatibility exports for existing tests + +Refactor SHALL provide compatibility exports during migration so existing tests continue to run while imports are updated incrementally. + +#### Scenario: Existing test imports legacy helper entry point + +- **WHEN** a test still imports from legacy helper paths during migration +- **THEN** the helper SHALL resolve through compatibility exports +- **AND** behavior SHALL remain equivalent + +### Requirement: Modularization MUST preserve fixture determinism + +Domain modularization MUST preserve deterministic fixture setup behavior currently relied on by Convex tests. + +#### Scenario: Test suite seeds baseline data + +- **WHEN** test helpers seed baseline fixture data +- **THEN** deterministic IDs/relations and expected defaults SHALL match pre-refactor behavior diff --git a/openspec/specs/frontend-error-feedback-standardization/spec.md b/openspec/specs/frontend-error-feedback-standardization/spec.md new file mode 100644 index 0000000..75ccc9f --- /dev/null +++ b/openspec/specs/frontend-error-feedback-standardization/spec.md @@ -0,0 +1,34 @@ +# frontend-error-feedback-standardization Specification + +## Purpose +TBD - created by archiving change standardize-frontend-error-feedback. Update Purpose after archive. + +## Requirements + +### Requirement: Covered frontend paths MUST use standardized non-blocking error feedback + +Covered web and widget paths SHALL present errors through standardized non-blocking feedback components/utilities rather than browser alerts. + +#### Scenario: Settings save fails + +- **WHEN** a settings mutation fails in a covered settings page +- **THEN** the UI SHALL display standardized non-blocking error feedback +- **AND** it SHALL not invoke raw browser alert dialogs + +### Requirement: Error feedback MUST provide actionable user messaging + +Standardized feedback SHALL include clear user-facing messages and, where applicable, guidance for retry or next action. + +#### Scenario: File upload validation fails + +- **WHEN** file validation rejects user input +- **THEN** feedback SHALL explain the validation reason and expected corrective action + +### Requirement: Unknown error mapping MUST be centralized for covered paths + +Covered frontend paths SHALL map unknown thrown values to user-safe messages through shared normalization utilities. + +#### Scenario: Unexpected runtime exception is thrown + +- **WHEN** a covered action throws an unknown error value +- **THEN** the UI SHALL surface a safe standardized message derived from shared normalization utilities From bd310b0a4ca9c08c755a9cd93ab52a862fab1192 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 16:19:06 +0000 Subject: [PATCH 10/91] Shared utility updates --- .../src/app/settings/HomeSettingsSection.tsx | 12 +- .../MessengerSettingsSection.test.tsx | 100 + .../app/settings/MessengerSettingsSection.tsx | 37 +- .../settings/NotificationSettingsSection.tsx | 20 +- apps/web/src/app/settings/page.tsx | 55 +- .../src/components/ErrorFeedbackBanner.tsx | 21 + apps/widget/src/AuthoringOverlay.tsx | 17 +- apps/widget/src/TooltipAuthoringOverlay.tsx | 16 +- apps/widget/src/Widget.tsx | 18 +- .../src/components/ErrorFeedbackBanner.tsx | 16 + apps/widget/src/components/TicketCreate.tsx | 5 + apps/widget/src/styles.css | 30 + .../test/widgetTicketErrorFeedback.test.tsx | 331 ++ packages/convex/convex/_generated/api.d.ts | 26 + packages/convex/convex/testData.ts | 3376 +---------------- packages/convex/convex/testData/README.md | 14 + packages/convex/convex/testData/cleanup.ts | 644 ++++ .../convex/convex/testData/demoWorkspace.ts | 969 +++++ packages/convex/convex/testData/landing.ts | 1079 ++++++ packages/convex/convex/testData/seeds.ts | 723 ++++ packages/convex/convex/testing/helpers.ts | 2716 +------------ .../convex/convex/testing/helpers/README.md | 19 + packages/convex/convex/testing/helpers/ai.ts | 182 + .../convex/convex/testing/helpers/cleanup.ts | 429 +++ .../convex/convex/testing/helpers/content.ts | 514 +++ .../convex/testing/helpers/conversations.ts | 328 ++ .../convex/convex/testing/helpers/email.ts | 189 + .../convex/testing/helpers/notifications.ts | 278 ++ .../convex/convex/testing/helpers/series.ts | 178 + .../convex/convex/testing/helpers/tickets.ts | 97 + .../convex/testing/helpers/workspace.ts | 550 +++ packages/web-shared/README.md | 16 + packages/web-shared/src/errorFeedback.test.ts | 42 + packages/web-shared/src/errorFeedback.ts | 47 + packages/web-shared/src/index.ts | 5 + 35 files changed, 7102 insertions(+), 5997 deletions(-) create mode 100644 apps/web/src/app/settings/MessengerSettingsSection.test.tsx create mode 100644 apps/web/src/components/ErrorFeedbackBanner.tsx create mode 100644 apps/widget/src/components/ErrorFeedbackBanner.tsx create mode 100644 apps/widget/src/test/widgetTicketErrorFeedback.test.tsx create mode 100644 packages/convex/convex/testData/README.md create mode 100644 packages/convex/convex/testData/cleanup.ts create mode 100644 packages/convex/convex/testData/demoWorkspace.ts create mode 100644 packages/convex/convex/testData/landing.ts create mode 100644 packages/convex/convex/testData/seeds.ts create mode 100644 packages/convex/convex/testing/helpers/README.md create mode 100644 packages/convex/convex/testing/helpers/ai.ts create mode 100644 packages/convex/convex/testing/helpers/cleanup.ts create mode 100644 packages/convex/convex/testing/helpers/content.ts create mode 100644 packages/convex/convex/testing/helpers/conversations.ts create mode 100644 packages/convex/convex/testing/helpers/email.ts create mode 100644 packages/convex/convex/testing/helpers/notifications.ts create mode 100644 packages/convex/convex/testing/helpers/series.ts create mode 100644 packages/convex/convex/testing/helpers/tickets.ts create mode 100644 packages/convex/convex/testing/helpers/workspace.ts create mode 100644 packages/web-shared/src/errorFeedback.test.ts create mode 100644 packages/web-shared/src/errorFeedback.ts diff --git a/apps/web/src/app/settings/HomeSettingsSection.tsx b/apps/web/src/app/settings/HomeSettingsSection.tsx index a833c3a..c630267 100644 --- a/apps/web/src/app/settings/HomeSettingsSection.tsx +++ b/apps/web/src/app/settings/HomeSettingsSection.tsx @@ -3,9 +3,11 @@ import { useState, useEffect } from "react"; import { useQuery, useMutation } from "convex/react"; import { Button, Card } from "@opencom/ui"; +import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared"; import { Home, Plus, X, GripVertical, Search, MessageSquare, FileText, Bell } from "lucide-react"; import { api } from "@opencom/convex"; import type { Id } from "@opencom/convex/dataModel"; +import { ErrorFeedbackBanner } from "@/components/ErrorFeedbackBanner"; // Card type definitions for Home settings const CARD_TYPES = [ @@ -154,6 +156,7 @@ export function HomeSettingsSection({ const [isSaving, setIsSaving] = useState(false); const [showAddCard, setShowAddCard] = useState(false); const [draggedIndex, setDraggedIndex] = useState(null); + const [errorFeedback, setErrorFeedback] = useState(null); useEffect(() => { if (homeConfig) { @@ -166,6 +169,7 @@ export function HomeSettingsSection({ const handleSave = async () => { if (!workspaceId) return; + setErrorFeedback(null); setIsSaving(true); try { await updateHomeConfig({ @@ -179,7 +183,12 @@ export function HomeSettingsSection({ }); } catch (error) { console.error("Failed to save home settings:", error); - alert(error instanceof Error ? error.message : "Failed to save settings"); + setErrorFeedback( + normalizeUnknownError(error, { + fallbackMessage: "Failed to save home settings", + nextAction: "Review home tab/card changes and try again.", + }) + ); } finally { setIsSaving(false); } @@ -283,6 +292,7 @@ export function HomeSettingsSection({ Configure a customizable Home space as the default entry point for your messenger. Add cards to help visitors find answers and take action.

    + {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 `![${alt}](${rewrittenTarget})`; - } - ); - - 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 `![${alt}](${rewrittenTarget})`; + } + ); + + 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([ + 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), + }, + }, + }; +} diff --git a/packages/convex/convex/schema.ts b/packages/convex/convex/schema.ts index 09dfda8..9f62133 100644 --- a/packages/convex/convex/schema.ts +++ b/packages/convex/convex/schema.ts @@ -1844,6 +1844,7 @@ export default defineSchema({ type: v.string(), id: v.string(), title: v.string(), + articleId: v.optional(v.string()), }) ) ), @@ -1853,6 +1854,7 @@ export default defineSchema({ type: v.string(), id: v.string(), title: v.string(), + articleId: v.optional(v.string()), }) ), confidence: v.number(), diff --git a/packages/convex/convex/testing/helpers/ai.ts b/packages/convex/convex/testing/helpers/ai.ts index f1db74d..4c1b03a 100644 --- a/packages/convex/convex/testing/helpers/ai.ts +++ b/packages/convex/convex/testing/helpers/ai.ts @@ -13,6 +13,7 @@ const seedTestAIResponse = internalMutation({ type: v.string(), id: v.string(), title: v.string(), + articleId: v.optional(v.string()), }) ) ), @@ -27,6 +28,7 @@ const seedTestAIResponse = internalMutation({ type: v.string(), id: v.string(), title: v.string(), + articleId: v.optional(v.string()), }) ) ), diff --git a/packages/convex/package.json b/packages/convex/package.json index 063f7ee..0150792 100644 --- a/packages/convex/package.json +++ b/packages/convex/package.json @@ -19,6 +19,7 @@ "scripts": { "dev": "convex dev", "deploy": "convex deploy", + "lint": "eslint convex/helpCenterImports.ts convex/helpCenterImports --ext .ts", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/convex/tests/helpCenterImportRewriteParity.test.ts b/packages/convex/tests/helpCenterImportRewriteParity.test.ts new file mode 100644 index 0000000..cab6e1b --- /dev/null +++ b/packages/convex/tests/helpCenterImportRewriteParity.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { + rewriteAssetReferencesForExport, + rewriteMarkdownImageReferences, +} from "../convex/helpCenterImports/referenceRewrite"; + +describe("help center import/export rewrite parity", () => { + it("rewrites markdown and html image references from fixture cases", () => { + const fixtures: Array<{ + name: string; + markdownPath: string; + content: string; + assetReferenceByPath: Map; + expectedContent: string; + expectedUnresolved: string[]; + }> = [ + { + name: "rewrites markdown + html relative images", + markdownPath: "docs/guides/install.md", + content: [ + "![Diagram](./images/diagram.png)", + "\"Chart\"", + ].join("\n"), + assetReferenceByPath: new Map([ + ["docs/guides/images/diagram.png", "oc-asset://diagram-1"], + ["docs/assets/chart.png", "oc-asset://chart-1"], + ]), + expectedContent: [ + "![Diagram](oc-asset://diagram-1)", + "\"Chart\"", + ].join("\n"), + expectedUnresolved: [], + }, + { + name: "keeps wrapped paths and title suffixes", + markdownPath: "docs/guides/install.md", + content: "![Preview](<../assets/diagram one.png> \"Large\")", + assetReferenceByPath: new Map([ + ["docs/assets/diagram one.png", "oc-asset://diagram-with-space"], + ]), + expectedContent: "![Preview]( \"Large\")", + expectedUnresolved: [], + }, + { + name: "reports unresolved references with file context", + markdownPath: "docs/guides/install.md", + content: [ + "![Missing](../images/not-found.png)", + "", + "![Also Missing](images/other-missing.png)", + "![External](https://example.com/static.png)", + ].join("\n"), + assetReferenceByPath: new Map(), + expectedContent: [ + "![Missing](../images/not-found.png)", + "", + "![Also Missing](images/other-missing.png)", + "![External](https://example.com/static.png)", + ].join("\n"), + expectedUnresolved: [ + "docs/guides/install.md: ../images/not-found.png", + "docs/guides/install.md: images/other-missing.png", + ], + }, + ]; + + for (const fixture of fixtures) { + const rewritten = rewriteMarkdownImageReferences( + fixture.content, + fixture.markdownPath, + fixture.assetReferenceByPath + ); + + expect(rewritten.content, fixture.name).toBe(fixture.expectedContent); + expect(rewritten.unresolvedReferences, fixture.name).toEqual(fixture.expectedUnresolved); + } + }); + + it("rewrites exported asset references to portable relative paths", () => { + const markdownPath = "guides/nested/with-image.md"; + const content = + "![One](oc-asset://asset-1)\n![Two](oc-asset://asset-2)\n![Missing](oc-asset://missing)"; + const exported = rewriteAssetReferencesForExport( + content, + markdownPath, + new Map([ + ["asset-1", "_assets/images/one.png"], + ["asset-2", "_assets/guides/two.png"], + ]) + ); + + expect(exported).toContain("![One](../../_assets/images/one.png)"); + expect(exported).toContain("![Two](../../_assets/guides/two.png)"); + expect(exported).toContain("![Missing](oc-asset://missing)"); + }); + + it("preserves re-import fidelity across import -> export -> import rewrite round trip", () => { + const markdownPath = "guides/with-image.md"; + const original = "# Guide\n\n![Diagram](images/diagram.png)\n"; + + const firstImport = rewriteMarkdownImageReferences( + original, + markdownPath, + new Map([["guides/images/diagram.png", "oc-asset://asset-a"]]) + ); + + expect(firstImport.unresolvedReferences).toEqual([]); + expect(firstImport.content).toContain("oc-asset://asset-a"); + + const exported = rewriteAssetReferencesForExport( + firstImport.content, + markdownPath, + new Map([["asset-a", "_assets/guides/images/diagram.png"]]) + ); + + expect(exported).toContain("../_assets/guides/images/diagram.png"); + expect(exported).not.toContain("oc-asset://asset-a"); + + const secondImport = rewriteMarkdownImageReferences( + exported, + markdownPath, + new Map([["_assets/guides/images/diagram.png", "oc-asset://asset-b"]]) + ); + + expect(secondImport.unresolvedReferences).toEqual([]); + expect(secondImport.content).toContain("oc-asset://asset-b"); + }); +}); diff --git a/packages/react-native-sdk/src/hooks/useAIAgent.ts b/packages/react-native-sdk/src/hooks/useAIAgent.ts index 0ce040f..a17ecec 100644 --- a/packages/react-native-sdk/src/hooks/useAIAgent.ts +++ b/packages/react-native-sdk/src/hooks/useAIAgent.ts @@ -16,6 +16,7 @@ export interface AIResponseData { type: string; id: string; title: string; + articleId?: string; }>; confidence: number; handedOff: boolean; diff --git a/packages/sdk-core/src/api/aiAgent.ts b/packages/sdk-core/src/api/aiAgent.ts index c5e0826..6f11f37 100644 --- a/packages/sdk-core/src/api/aiAgent.ts +++ b/packages/sdk-core/src/api/aiAgent.ts @@ -31,6 +31,7 @@ export interface AIResponseData { type: string; id: string; title: string; + articleId?: string; }>; confidence: number; handedOff: boolean; diff --git a/packages/web-shared/src/aiSourceLinks.test.ts b/packages/web-shared/src/aiSourceLinks.test.ts new file mode 100644 index 0000000..9e6d41e --- /dev/null +++ b/packages/web-shared/src/aiSourceLinks.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { resolveArticleSourceId, type AISourceMetadata } from "./aiSourceLinks"; + +describe("resolveArticleSourceId", () => { + it("prefers explicit articleId metadata when present", () => { + const source: AISourceMetadata = { + type: "article", + id: "legacy-id", + title: "Getting Started", + articleId: "article-explicit", + }; + + expect(resolveArticleSourceId(source)).toBe("article-explicit"); + }); + + it("falls back to source id for legacy article records", () => { + const source: AISourceMetadata = { + type: "article", + id: "article-legacy", + title: "Legacy", + }; + + expect(resolveArticleSourceId(source)).toBe("article-legacy"); + }); + + it("returns null for non-article sources", () => { + const source: AISourceMetadata = { + type: "snippet", + id: "snippet-1", + title: "Snippet", + }; + + expect(resolveArticleSourceId(source)).toBeNull(); + }); +}); diff --git a/packages/web-shared/src/aiSourceLinks.ts b/packages/web-shared/src/aiSourceLinks.ts new file mode 100644 index 0000000..b1934d7 --- /dev/null +++ b/packages/web-shared/src/aiSourceLinks.ts @@ -0,0 +1,20 @@ +export interface AISourceMetadata { + type: string; + id: string; + title: string; + articleId?: string; +} + +export function resolveArticleSourceId(source: AISourceMetadata): string | null { + const explicitArticleId = source.articleId?.trim(); + if (explicitArticleId) { + return explicitArticleId; + } + + if (source.type === "article") { + const fallbackId = source.id.trim(); + return fallbackId.length > 0 ? fallbackId : null; + } + + return null; +} diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 4ca003e..b3d9916 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -18,3 +18,4 @@ export { type ErrorFeedbackMessage, type NormalizeUnknownErrorOptions, } from "./errorFeedback"; +export { resolveArticleSourceId, type AISourceMetadata } from "./aiSourceLinks"; From dc3b4d894521c92e58169d07d989c06449a28748 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 17:48:55 +0000 Subject: [PATCH 13/91] Archive proposals --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../rn-sdk-messenger-type-contracts/spec.md | 0 .../tasks.md | 20 +++++++++++ .../tasks.md | 20 ----------- .../rn-sdk-messenger-type-contracts/spec.md | 35 +++++++++++++++++++ 7 files changed, 55 insertions(+), 20 deletions(-) rename openspec/changes/{tighten-react-native-sdk-messenger-types => archive/2026-03-05-tighten-react-native-sdk-messenger-types}/.openspec.yaml (100%) rename openspec/changes/{tighten-react-native-sdk-messenger-types => archive/2026-03-05-tighten-react-native-sdk-messenger-types}/design.md (100%) rename openspec/changes/{tighten-react-native-sdk-messenger-types => archive/2026-03-05-tighten-react-native-sdk-messenger-types}/proposal.md (100%) rename openspec/changes/{tighten-react-native-sdk-messenger-types => archive/2026-03-05-tighten-react-native-sdk-messenger-types}/specs/rn-sdk-messenger-type-contracts/spec.md (100%) create mode 100644 openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/tasks.md delete mode 100644 openspec/changes/tighten-react-native-sdk-messenger-types/tasks.md create mode 100644 openspec/specs/rn-sdk-messenger-type-contracts/spec.md diff --git a/openspec/changes/tighten-react-native-sdk-messenger-types/.openspec.yaml b/openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/.openspec.yaml similarity index 100% rename from openspec/changes/tighten-react-native-sdk-messenger-types/.openspec.yaml rename to openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/.openspec.yaml diff --git a/openspec/changes/tighten-react-native-sdk-messenger-types/design.md b/openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/design.md similarity index 100% rename from openspec/changes/tighten-react-native-sdk-messenger-types/design.md rename to openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/design.md diff --git a/openspec/changes/tighten-react-native-sdk-messenger-types/proposal.md b/openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/proposal.md similarity index 100% rename from openspec/changes/tighten-react-native-sdk-messenger-types/proposal.md rename to openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/proposal.md diff --git a/openspec/changes/tighten-react-native-sdk-messenger-types/specs/rn-sdk-messenger-type-contracts/spec.md b/openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/specs/rn-sdk-messenger-type-contracts/spec.md similarity index 100% rename from openspec/changes/tighten-react-native-sdk-messenger-types/specs/rn-sdk-messenger-type-contracts/spec.md rename to openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/specs/rn-sdk-messenger-type-contracts/spec.md diff --git a/openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/tasks.md b/openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/tasks.md new file mode 100644 index 0000000..cf39874 --- /dev/null +++ b/openspec/changes/archive/2026-03-05-tighten-react-native-sdk-messenger-types/tasks.md @@ -0,0 +1,20 @@ +## 1. Contract Definition + +- [x] 1.1 Define canonical messenger composition prop interfaces in a shared type module. +- [x] 1.2 Identify and document current cast-based prop mismatch points. + +## 2. Migration + +- [x] 2.1 Update `OpencomMessenger` to consume canonical prop contracts. +- [x] 2.2 Update `MessengerContent` and related composers to pass typed props without broad casts. +- [x] 2.3 Add typed adapter mapping where compatibility transforms are required. + +## 3. Verification + +- [x] 3.1 Run RN SDK typecheck and targeted tests for composed messenger flows. +- [x] 3.2 Add guardrails/tests to prevent reintroduction of broad cast escapes. + +## 4. Cleanup + +- [x] 4.1 Remove obsolete local prop interfaces replaced by canonical contracts. +- [x] 4.2 Document ownership and extension rules for messenger composition types. diff --git a/openspec/changes/tighten-react-native-sdk-messenger-types/tasks.md b/openspec/changes/tighten-react-native-sdk-messenger-types/tasks.md deleted file mode 100644 index 2c17ecc..0000000 --- a/openspec/changes/tighten-react-native-sdk-messenger-types/tasks.md +++ /dev/null @@ -1,20 +0,0 @@ -## 1. Contract Definition - -- [ ] 1.1 Define canonical messenger composition prop interfaces in a shared type module. -- [ ] 1.2 Identify and document current cast-based prop mismatch points. - -## 2. Migration - -- [ ] 2.1 Update `OpencomMessenger` to consume canonical prop contracts. -- [ ] 2.2 Update `MessengerContent` and related composers to pass typed props without broad casts. -- [ ] 2.3 Add typed adapter mapping where compatibility transforms are required. - -## 3. Verification - -- [ ] 3.1 Run RN SDK typecheck and targeted tests for composed messenger flows. -- [ ] 3.2 Add guardrails/tests to prevent reintroduction of broad cast escapes. - -## 4. Cleanup - -- [ ] 4.1 Remove obsolete local prop interfaces replaced by canonical contracts. -- [ ] 4.2 Document ownership and extension rules for messenger composition types. diff --git a/openspec/specs/rn-sdk-messenger-type-contracts/spec.md b/openspec/specs/rn-sdk-messenger-type-contracts/spec.md new file mode 100644 index 0000000..f9c249f --- /dev/null +++ b/openspec/specs/rn-sdk-messenger-type-contracts/spec.md @@ -0,0 +1,35 @@ +# rn-sdk-messenger-type-contracts Specification + +## Purpose +TBD - created by archiving change tighten-react-native-sdk-messenger-types. Update Purpose after archive. + +## Requirements + +### Requirement: Messenger composition MUST use explicit shared prop contracts + +RN SDK messenger composition SHALL use canonical shared prop interfaces between composition layers. + +#### Scenario: MessengerContent passes conversation state props + +- **WHEN** `MessengerContent` passes conversation-related props to `OpencomMessenger` +- **THEN** props SHALL conform to canonical shared interfaces +- **AND** broad cast escapes SHALL not be required + +### Requirement: Broad cast escapes MUST be removed from covered messenger composition paths + +Covered messenger composition files MUST avoid `as any` in core prop wiring paths. + +#### Scenario: Type mismatch exists between composer and consumer + +- **WHEN** caller and callee prop shapes differ +- **THEN** the implementation SHALL use a typed adapter transform +- **AND** it SHALL not rely on broad cast suppression + +### Requirement: Type hardening MUST preserve runtime messenger behavior + +Type contract refactors SHALL preserve existing runtime behavior of composed messenger views. + +#### Scenario: Messenger renders and handles conversation changes + +- **WHEN** the composed messenger view renders and conversation selection changes +- **THEN** UI and callback behavior SHALL remain equivalent to pre-hardening behavior From 05d1366462fb9d25256e39b373890aea4bda3fd3 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 17:49:45 +0000 Subject: [PATCH 14/91] split-react-native-sdk-orchestrator & decompose-react-native-sdk-messenger-containers --- .../tasks.md | 18 +- .../tasks.md | 20 +- packages/react-native-sdk/src/OpencomSDK.ts | 412 +------- .../src/components/MessengerContent.tsx | 29 +- .../src/components/Opencom.tsx | 13 +- .../src/components/OpencomMessenger.tsx | 750 ++------------- .../src/components/OpencomSurvey.tsx | 903 ++---------------- .../messenger/ConversationDetailView.tsx | 173 ++++ .../messenger/ConversationListView.tsx | 115 +++ .../src/components/messenger/messengerFlow.ts | 95 ++ .../src/components/messenger/styles.ts | 284 ++++++ .../useConversationDetailController.ts | 120 +++ .../useConversationListController.ts | 33 + .../messenger/useMessengerShellController.ts | 74 ++ .../messengerCompositionContract.md | 38 + .../components/messengerCompositionTypes.ts | 28 + .../messengerSurveyModuleOwnership.md | 44 + .../survey/SurveyQuestionRenderer.tsx | 334 +++++++ .../src/components/survey/SurveyStepViews.tsx | 131 +++ .../src/components/survey/styles.ts | 246 +++++ .../src/components/survey/surveyFlow.ts | 141 +++ .../src/components/survey/types.ts | 51 + .../components/survey/useSurveyController.ts | 161 ++++ .../src/opencomSdk/contracts.ts | 33 + .../src/opencomSdk/lifecycleService.ts | 121 +++ .../src/opencomSdk/moduleOwnership.md | 37 + .../src/opencomSdk/pushService.ts | 30 + .../src/opencomSdk/sessionService.ts | 183 ++++ .../react-native-sdk/src/opencomSdk/state.ts | 26 + .../src/opencomSdk/storageService.ts | 68 ++ .../messengerCompositionContracts.test.ts | 37 + .../tests/messengerFlowParity.test.ts | 80 ++ .../orchestratorModularityContracts.test.ts | 76 ++ .../tests/surveyFlowParity.test.ts | 96 ++ 34 files changed, 3058 insertions(+), 1942 deletions(-) create mode 100644 packages/react-native-sdk/src/components/messenger/ConversationDetailView.tsx create mode 100644 packages/react-native-sdk/src/components/messenger/ConversationListView.tsx create mode 100644 packages/react-native-sdk/src/components/messenger/messengerFlow.ts create mode 100644 packages/react-native-sdk/src/components/messenger/styles.ts create mode 100644 packages/react-native-sdk/src/components/messenger/useConversationDetailController.ts create mode 100644 packages/react-native-sdk/src/components/messenger/useConversationListController.ts create mode 100644 packages/react-native-sdk/src/components/messenger/useMessengerShellController.ts create mode 100644 packages/react-native-sdk/src/components/messengerCompositionContract.md create mode 100644 packages/react-native-sdk/src/components/messengerCompositionTypes.ts create mode 100644 packages/react-native-sdk/src/components/messengerSurveyModuleOwnership.md create mode 100644 packages/react-native-sdk/src/components/survey/SurveyQuestionRenderer.tsx create mode 100644 packages/react-native-sdk/src/components/survey/SurveyStepViews.tsx create mode 100644 packages/react-native-sdk/src/components/survey/styles.ts create mode 100644 packages/react-native-sdk/src/components/survey/surveyFlow.ts create mode 100644 packages/react-native-sdk/src/components/survey/types.ts create mode 100644 packages/react-native-sdk/src/components/survey/useSurveyController.ts create mode 100644 packages/react-native-sdk/src/opencomSdk/contracts.ts create mode 100644 packages/react-native-sdk/src/opencomSdk/lifecycleService.ts create mode 100644 packages/react-native-sdk/src/opencomSdk/moduleOwnership.md create mode 100644 packages/react-native-sdk/src/opencomSdk/pushService.ts create mode 100644 packages/react-native-sdk/src/opencomSdk/sessionService.ts create mode 100644 packages/react-native-sdk/src/opencomSdk/state.ts create mode 100644 packages/react-native-sdk/src/opencomSdk/storageService.ts create mode 100644 packages/react-native-sdk/tests/messengerCompositionContracts.test.ts create mode 100644 packages/react-native-sdk/tests/messengerFlowParity.test.ts create mode 100644 packages/react-native-sdk/tests/orchestratorModularityContracts.test.ts create mode 100644 packages/react-native-sdk/tests/surveyFlowParity.test.ts diff --git a/openspec/changes/decompose-react-native-sdk-messenger-containers/tasks.md b/openspec/changes/decompose-react-native-sdk-messenger-containers/tasks.md index 6638926..1d134d5 100644 --- a/openspec/changes/decompose-react-native-sdk-messenger-containers/tasks.md +++ b/openspec/changes/decompose-react-native-sdk-messenger-containers/tasks.md @@ -1,20 +1,20 @@ ## 1. Domain Boundary Definition -- [ ] 1.1 Define messenger and survey orchestration domains and target module layout. -- [ ] 1.2 Extract presentational subcomponents with stable prop contracts. +- [x] 1.1 Define messenger and survey orchestration domains and target module layout. +- [x] 1.2 Extract presentational subcomponents with stable prop contracts. ## 2. Hook/Controller Extraction -- [ ] 2.1 Extract messenger orchestration hooks (message flow, tab state, AI feedback, article suggestions). -- [ ] 2.2 Extract survey orchestration hooks (question progression, validation, submission state). -- [ ] 2.3 Recompose shell containers using extracted hooks/components. +- [x] 2.1 Extract messenger orchestration hooks (message flow, tab state, AI feedback, article suggestions). +- [x] 2.2 Extract survey orchestration hooks (question progression, validation, submission state). +- [x] 2.3 Recompose shell containers using extracted hooks/components. ## 3. Parity Tests -- [ ] 3.1 Add tests for messenger flow parity (send, status, tab navigation, AI cues). -- [ ] 3.2 Add tests for survey progression parity (step transitions, completion, retry/error states). +- [x] 3.1 Add tests for messenger flow parity (send, status, tab navigation, AI cues). +- [x] 3.2 Add tests for survey progression parity (step transitions, completion, retry/error states). ## 4. Cleanup -- [ ] 4.1 Remove obsolete monolithic logic from container files. -- [ ] 4.2 Document module ownership and extension conventions. +- [x] 4.1 Remove obsolete monolithic logic from container files. +- [x] 4.2 Document module ownership and extension conventions. diff --git a/openspec/changes/split-react-native-sdk-orchestrator/tasks.md b/openspec/changes/split-react-native-sdk-orchestrator/tasks.md index 97b290d..f01e9f5 100644 --- a/openspec/changes/split-react-native-sdk-orchestrator/tasks.md +++ b/openspec/changes/split-react-native-sdk-orchestrator/tasks.md @@ -1,21 +1,21 @@ ## 1. Internal Service Setup -- [ ] 1.1 Define internal module interfaces for session, storage, push, and lifecycle responsibilities. -- [ ] 1.2 Introduce a shared state container used by orchestrator services. +- [x] 1.1 Define internal module interfaces for session, storage, push, and lifecycle responsibilities. +- [x] 1.2 Introduce a shared state container used by orchestrator services. ## 2. Incremental Extraction -- [ ] 2.1 Move session initialization/identify/logout logic into session service. -- [ ] 2.2 Move persistence logic into storage service. -- [ ] 2.3 Move push registration/unregistration logic into push service. -- [ ] 2.4 Move app lifecycle/timer orchestration into lifecycle service. +- [x] 2.1 Move session initialization/identify/logout logic into session service. +- [x] 2.2 Move persistence logic into storage service. +- [x] 2.3 Move push registration/unregistration logic into push service. +- [x] 2.4 Move app lifecycle/timer orchestration into lifecycle service. ## 3. Parity Verification -- [ ] 3.1 Add/extend RN SDK tests for public API behavior parity across extracted services. -- [ ] 3.2 Run RN SDK typecheck/tests and fix regressions. +- [x] 3.1 Add/extend RN SDK tests for public API behavior parity across extracted services. +- [x] 3.2 Run RN SDK typecheck/tests and fix regressions. ## 4. Cleanup -- [ ] 4.1 Remove obsolete monolithic branches from `OpencomSDK.ts`. -- [ ] 4.2 Document internal module ownership and extension guidance. +- [x] 4.1 Remove obsolete monolithic branches from `OpencomSDK.ts`. +- [x] 4.2 Document internal module ownership and extension guidance. diff --git a/packages/react-native-sdk/src/OpencomSDK.ts b/packages/react-native-sdk/src/OpencomSDK.ts index 10f72ce..91ddbab 100644 --- a/packages/react-native-sdk/src/OpencomSDK.ts +++ b/packages/react-native-sdk/src/OpencomSDK.ts @@ -1,30 +1,10 @@ -import { Platform } from "react-native"; -import { AppState, type AppStateStatus } from "react-native"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import * as SecureStore from "expo-secure-store"; -import { registerForPushNotifications, unregisterPushNotifications } from "./push"; import { - initializeClient, isInitialized, resetClient, - bootSession as bootSessionApi, - refreshSession as refreshSessionApi, - revokeSession as revokeSessionApi, - identifyVisitor as identifyVisitorApi, trackEvent as trackEventApi, trackAutoEvent as trackAutoEventApi, - heartbeat, - setStorageAdapter, - setVisitorId, - setSessionId, - setSessionToken, - setSessionExpiresAt, - clearSessionToken, - setUser, - clearUser, resetVisitorState, getVisitorState, - generateSessionId, emitEvent, addEventListener, getOrCreateConversation as getOrCreateConversationApi, @@ -35,38 +15,24 @@ import { type SDKConfig, type UserIdentification, type EventProperties, - type DeviceInfo, - type VisitorId, type SDKEventListener, type ConversationId, } from "@opencom/sdk-core"; - -// Storage adapter using AsyncStorage -const storageAdapter = { - getItem: (key: string) => AsyncStorage.getItem(key), - setItem: (key: string, value: string) => AsyncStorage.setItem(key, value), - removeItem: (key: string) => AsyncStorage.removeItem(key), -}; - -function getStorage() { - return storageAdapter; -} - -let isSDKInitialized = false; -let heartbeatInterval: ReturnType | null = null; -let refreshTimer: ReturnType | null = null; -let appStateSubscription: { remove: () => void } | null = null; -let sessionStartTracked = false; -const surveyTriggerListeners = new Set<(eventName: string) => void>(); - -const HEARTBEAT_INTERVAL_MS = 30000; // 30 seconds -const REFRESH_MARGIN_MS = 60000; // refresh 60s before expiry -const SESSION_TOKEN_KEY = "opencom_session_token"; -const SESSION_EXPIRES_AT_KEY = "opencom_session_expires_at"; -const REACT_NATIVE_SDK_VERSION = "0.1.0"; +import { + registerForPush as registerForPushService, + unregisterFromPush as unregisterFromPushService, +} from "./opencomSdk/pushService"; +import { initializeSession, identifyUser, logoutSession } from "./opencomSdk/sessionService"; +import { cleanupAppStateListener, stopHeartbeat, stopRefreshTimer } from "./opencomSdk/lifecycleService"; +import { + clearPersistedSessionId, + clearPersistedSessionToken, + clearPersistedVisitorId, +} from "./opencomSdk/storageService"; +import { opencomSDKState, resetOpencomSDKState } from "./opencomSdk/state"; function emitSurveyTriggerEvent(eventName: string): void { - for (const listener of surveyTriggerListeners) { + for (const listener of opencomSDKState.surveyTriggerListeners) { try { listener(eventName); } catch (error) { @@ -75,71 +41,12 @@ function emitSurveyTriggerEvent(eventName: string): void { } } -function getDeviceInfo(): DeviceInfo { - return { - os: Platform.OS, - platform: Platform.OS as "ios" | "android", - deviceType: "mobile", - }; -} - -async function getOrCreateSessionId(): Promise { - const storage = await getStorage(); - const storedSessionId = await storage.getItem("opencom_session_id"); - if (storedSessionId) { - return storedSessionId; - } - const newSessionId = generateSessionId(); - await storage.setItem("opencom_session_id", newSessionId); - return newSessionId; -} - -async function persistVisitorId(visitorId: string): Promise { - const storage = await getStorage(); - await storage.setItem("opencom_visitor_id", visitorId); -} - -async function clearPersistedVisitorId(): Promise { - const storage = await getStorage(); - await storage.removeItem("opencom_visitor_id"); -} - -async function persistSessionToken(token: string, expiresAt: number): Promise { - await SecureStore.setItemAsync(SESSION_TOKEN_KEY, token); - await SecureStore.setItemAsync(SESSION_EXPIRES_AT_KEY, String(expiresAt)); -} - -async function clearPersistedSessionToken(): Promise { - await SecureStore.deleteItemAsync(SESSION_TOKEN_KEY); - await SecureStore.deleteItemAsync(SESSION_EXPIRES_AT_KEY); -} - -function scheduleRefresh(expiresAt: number): void { - if (refreshTimer) { - clearTimeout(refreshTimer); - refreshTimer = null; - } - const delay = Math.max(0, expiresAt - Date.now() - REFRESH_MARGIN_MS); - refreshTimer = setTimeout(async () => { - const state = getVisitorState(); - if (!state.sessionToken) return; - try { - const result = await refreshSessionApi({ sessionToken: state.sessionToken }); - setSessionToken(result.sessionToken); - setSessionExpiresAt(result.expiresAt); - await persistSessionToken(result.sessionToken, result.expiresAt); - scheduleRefresh(result.expiresAt); - } catch (error) { - console.error("[OpencomSDK] Session refresh failed:", error); - } - }, delay); -} - -function stopRefreshTimer(): void { - if (refreshTimer) { - clearTimeout(refreshTimer); - refreshTimer = null; +function warnIfNotInitialized(): boolean { + if (!opencomSDKState.isSDKInitialized) { + console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + return true; } + return false; } export const OpencomSDK = { @@ -154,73 +61,7 @@ export const OpencomSDK = { if (!config.convexUrl?.trim()) { throw new Error("[OpencomSDK] convexUrl is required."); } - - if (isSDKInitialized) { - console.warn("[OpencomSDK] SDK already initialized"); - return; - } - - // Set up storage adapter for sdk-core - const storage = await getStorage(); - setStorageAdapter({ - getItem: (key) => storage.getItem(key), - setItem: (key, value) => storage.setItem(key, value), - removeItem: (key) => storage.removeItem(key), - }); - - // Initialize Convex client - initializeClient(config); - - // Get or create session - const sessionId = await getOrCreateSessionId(); - setSessionId(sessionId); - - // Boot a signed session - const device = getDeviceInfo(); - const bootResult = await bootSessionApi({ - sessionId, - device, - clientType: "mobile_sdk", - clientVersion: REACT_NATIVE_SDK_VERSION, - clientIdentifier: "@opencom/react-native-sdk", - }); - - const visitorId = bootResult.visitor._id as VisitorId; - setVisitorId(visitorId); - setSessionToken(bootResult.sessionToken); - setSessionExpiresAt(bootResult.expiresAt); - await persistVisitorId(visitorId); - await persistSessionToken(bootResult.sessionToken, bootResult.expiresAt); - emitEvent("visitor_created", { visitorId }); - - // Schedule token refresh - scheduleRefresh(bootResult.expiresAt); - - // Start heartbeat - startHeartbeat(visitorId); - - // Track session start - if (!sessionStartTracked) { - sessionStartTracked = true; - trackAutoEventApi({ - visitorId, - sessionToken: bootResult.sessionToken, - eventType: "session_start", - sessionId, - properties: { - platform: Platform.OS, - }, - }).catch(console.error); - } - - // Set up app state listener for session tracking - setupAppStateListener(visitorId, sessionId); - - isSDKInitialized = true; - - if (config.debug) { - console.log("[OpencomSDK] Initialized successfully"); - } + await initializeSession(config); }, /** @@ -228,8 +69,7 @@ export const OpencomSDK = { * Call this when a screen becomes visible. */ async trackScreenView(screenName: string, properties?: EventProperties): Promise { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return; } @@ -257,35 +97,14 @@ export const OpencomSDK = { * Call this when a user logs in or when you have user information. */ async identify(user: UserIdentification): Promise { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); - return; - } - - const state = getVisitorState(); - if (!state.visitorId) { - console.warn("[OpencomSDK] No visitor ID available"); - return; - } - - setUser(user); - - await identifyVisitorApi({ - visitorId: state.visitorId, - sessionToken: state.sessionToken ?? undefined, - user, - device: getDeviceInfo(), - }); - - emitEvent("visitor_identified", { user }); + await identifyUser(user); }, /** * Track a custom event. */ async trackEvent(name: string, properties?: EventProperties): Promise { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return; } @@ -309,9 +128,9 @@ export const OpencomSDK = { * Subscribe to survey trigger events emitted by runtime event tracking. */ addSurveyTriggerListener(listener: (eventName: string) => void): () => void { - surveyTriggerListeners.add(listener); + opencomSDKState.surveyTriggerListeners.add(listener); return () => { - surveyTriggerListeners.delete(listener); + opencomSDKState.surveyTriggerListeners.delete(listener); }; }, @@ -319,57 +138,14 @@ export const OpencomSDK = { * Log out the current user and reset the session. */ async logout(): Promise { - if (!isSDKInitialized) { - return; - } - - stopHeartbeat(); - stopRefreshTimer(); - clearUser(); - - // Revoke current session - const state = getVisitorState(); - if (state.sessionToken) { - revokeSessionApi({ sessionToken: state.sessionToken }).catch(console.error); - } - clearSessionToken(); - - // Clear stored session and visitor ID - const storage = await getStorage(); - await storage.removeItem("opencom_session_id"); - await clearPersistedVisitorId(); - await clearPersistedSessionToken(); - - // Generate new session - const newSessionId = generateSessionId(); - await storage.setItem("opencom_session_id", newSessionId); - setSessionId(newSessionId); - - // Boot a new signed session - const device = getDeviceInfo(); - const bootResult = await bootSessionApi({ - sessionId: newSessionId, - device, - clientType: "mobile_sdk", - clientVersion: REACT_NATIVE_SDK_VERSION, - clientIdentifier: "@opencom/react-native-sdk", - }); - - const visitorId = bootResult.visitor._id as VisitorId; - setVisitorId(visitorId); - setSessionToken(bootResult.sessionToken); - setSessionExpiresAt(bootResult.expiresAt); - await persistVisitorId(visitorId); - await persistSessionToken(bootResult.sessionToken, bootResult.expiresAt); - scheduleRefresh(bootResult.expiresAt); - startHeartbeat(visitorId); + await logoutSession(); }, /** * Check if the SDK is initialized. */ isInitialized(): boolean { - return isSDKInitialized && isInitialized(); + return opencomSDKState.isSDKInitialized && isInitialized(); }, /** @@ -393,15 +169,12 @@ export const OpencomSDK = { stopHeartbeat(); stopRefreshTimer(); cleanupAppStateListener(); - surveyTriggerListeners.clear(); resetVisitorState(); resetClient(); - const storage = await getStorage(); - await storage.removeItem("opencom_session_id"); + await clearPersistedSessionId(); await clearPersistedVisitorId(); await clearPersistedSessionToken(); - isSDKInitialized = false; - sessionStartTracked = false; + resetOpencomSDKState(); }, /** @@ -409,22 +182,14 @@ export const OpencomSDK = { * Returns the push token if successful, null otherwise. */ async registerForPush(): Promise { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); - return null; - } - return registerForPushNotifications(); + return registerForPushService(); }, /** * Unregister the current device from push notifications for this visitor session. */ async unregisterFromPush(): Promise { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); - return false; - } - return unregisterPushNotifications(); + return unregisterFromPushService(); }, /** @@ -433,8 +198,7 @@ export const OpencomSDK = { * In practice, the app should use the OpencomMessenger component. */ present(): void { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return; } emitEvent("messenger_opened", {}); @@ -444,8 +208,7 @@ export const OpencomSDK = { * Present a carousel by ID. */ presentCarousel(carouselId: string): void { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return; } emitEvent("carousel_opened", { carouselId }); @@ -455,8 +218,7 @@ export const OpencomSDK = { * Present the help center. */ presentHelpCenter(): void { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return; } emitEvent("help_center_opened", {}); @@ -467,8 +229,7 @@ export const OpencomSDK = { * Returns an existing open conversation if one exists, otherwise creates a new one. */ async getOrCreateConversation(): Promise<{ _id: ConversationId } | null> { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return null; } const state = getVisitorState(); @@ -484,8 +245,7 @@ export const OpencomSDK = { * Always creates a new conversation, even if open conversations exist. */ async createConversation(): Promise<{ _id: ConversationId } | null> { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return null; } const state = getVisitorState(); @@ -500,8 +260,7 @@ export const OpencomSDK = { * Get messages for a conversation. */ async getMessages(conversationId: ConversationId) { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return []; } const state = getVisitorState(); @@ -516,8 +275,7 @@ export const OpencomSDK = { * Get all conversations for the current visitor. */ async getConversations() { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return []; } const state = getVisitorState(); @@ -532,8 +290,7 @@ export const OpencomSDK = { * Send a message in a conversation. */ async sendMessage(conversationId: ConversationId, content: string): Promise { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return; } const state = getVisitorState(); @@ -562,20 +319,17 @@ export const OpencomSDK = { * Returns the parsed deep link data or null if invalid. */ handleDeepLink(url: string): DeepLinkResult | null { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return null; } try { - // Parse the URL const parsed = parseDeepLink(url); if (!parsed) { console.warn("[OpencomSDK] Invalid deep link URL:", url); return null; } - // Emit appropriate event based on deep link type switch (parsed.type) { case "conversation": emitEvent("messenger_opened", { conversationId: parsed.id }); @@ -608,8 +362,7 @@ export const OpencomSDK = { * Present the tickets view. */ presentTickets(): void { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return; } emitEvent("tickets_opened", {}); @@ -619,8 +372,7 @@ export const OpencomSDK = { * Present a specific ticket. */ presentTicket(ticketId: string): void { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return; } emitEvent("ticket_opened", { ticketId }); @@ -630,8 +382,7 @@ export const OpencomSDK = { * Present a specific article. */ presentArticle(articleId: string): void { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return; } emitEvent("help_center_opened", { articleId }); @@ -641,8 +392,7 @@ export const OpencomSDK = { * Present a specific conversation. */ presentConversation(conversationId: string): void { - if (!isSDKInitialized) { - console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + if (warnIfNotInitialized()) { return; } emitEvent("messenger_opened", { conversationId }); @@ -664,7 +414,6 @@ export interface DeepLinkResult { } function parseDeepLink(url: string): DeepLinkResult | null { - // Support both opencom:// and https://opencom.app/ schemes const opencomScheme = /^opencom:\/\/(.+)$/; const httpsScheme = /^https:\/\/opencom\.app\/(.+)$/; @@ -684,7 +433,6 @@ function parseDeepLink(url: string): DeepLinkResult | null { return null; } - // Remove query params and hash const cleanPath = path.split("?")[0].split("#")[0]; const segments = cleanPath.split("/").filter(Boolean); @@ -716,77 +464,3 @@ function parseDeepLink(url: string): DeepLinkResult | null { return null; } } - -function startHeartbeat(visitorId: VisitorId): void { - if (heartbeatInterval) { - clearInterval(heartbeatInterval); - } - - // Send initial heartbeat - const state = getVisitorState(); - heartbeat(visitorId, state.sessionToken ?? undefined).catch(console.error); - - // Set up interval - heartbeatInterval = setInterval(() => { - const s = getVisitorState(); - heartbeat(visitorId, s.sessionToken ?? undefined).catch(console.error); - }, HEARTBEAT_INTERVAL_MS); -} - -function stopHeartbeat(): void { - if (heartbeatInterval) { - clearInterval(heartbeatInterval); - heartbeatInterval = null; - } -} - -function setupAppStateListener(visitorId: VisitorId, sessionId: string): void { - // Remove existing subscription if any - if (appStateSubscription) { - appStateSubscription.remove(); - } - - let lastAppState: AppStateStatus = AppState.currentState; - - appStateSubscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => { - // App coming to foreground from background - if (lastAppState.match(/inactive|background/) && nextAppState === "active") { - // Track session start when app becomes active - const s = getVisitorState(); - trackAutoEventApi({ - visitorId, - sessionToken: s.sessionToken ?? undefined, - eventType: "session_start", - sessionId, - properties: { - platform: Platform.OS, - resumedFromBackground: true, - }, - }).catch(console.error); - } - - // App going to background - if (lastAppState === "active" && nextAppState.match(/inactive|background/)) { - // Track session end when app goes to background - const s = getVisitorState(); - trackAutoEventApi({ - visitorId, - sessionToken: s.sessionToken ?? undefined, - eventType: "session_end", - sessionId, - properties: { - platform: Platform.OS, - }, - }).catch(console.error); - } - - lastAppState = nextAppState; - }); -} - -function cleanupAppStateListener(): void { - if (appStateSubscription) { - appStateSubscription.remove(); - appStateSubscription = null; - } -} diff --git a/packages/react-native-sdk/src/components/MessengerContent.tsx b/packages/react-native-sdk/src/components/MessengerContent.tsx index 640b6f9..2775c44 100644 --- a/packages/react-native-sdk/src/components/MessengerContent.tsx +++ b/packages/react-native-sdk/src/components/MessengerContent.tsx @@ -7,6 +7,10 @@ import { OpencomChecklist } from "./OpencomChecklist"; import { OpencomHome, useHomeConfig } from "./OpencomHome"; import { TabIcon } from "./TabIcon"; import { useMessengerSettings } from "../hooks/useMessengerSettings"; +import type { + MessengerConversationId, + MessengerNestedView, +} from "./messengerCompositionTypes"; export type MainTab = "home" | "messages" | "help" | "tours" | "tasks" | "tickets"; export type MessengerView = "tabs" | "conversation" | "article" | "email-capture"; @@ -16,8 +20,8 @@ export interface MessengerContentProps { setActiveTab: (tab: MainTab) => void; messengerView: MessengerView; setMessengerView: (view: MessengerView) => void; - selectedConversationId: string | null; - setSelectedConversationId: (id: string | null) => void; + selectedConversationId: MessengerConversationId; + setSelectedConversationId: (id: MessengerConversationId) => void; selectedArticleId: string | null; setSelectedArticleId: (id: string | null) => void; availableTabs: MainTab[]; @@ -54,9 +58,14 @@ export function MessengerContent({ const homeConfig = useHomeConfig(workspaceId, isIdentified); // Track nested view state from child components - const [messengerNestedView, setMessengerNestedView] = useState<"list" | "conversation">("list"); - const [messengerConversationId, setMessengerConversationId] = useState(null); + const [messengerNestedView, setMessengerNestedView] = useState("list"); + const [messengerConversationId, setMessengerConversationId] = useState( + null + ); const [helpCenterNestedView, setHelpCenterNestedView] = useState<"search" | "article">("search"); + const handleMessengerConversationChange = (conversationId: MessengerConversationId) => { + setMessengerConversationId(conversationId); + }; // Determine if we're in a nested view (conversation or article detail) const isInNestedView = @@ -147,8 +156,8 @@ export function MessengerContent({ style={styles.tabContent} onViewChange={setMessengerNestedView} controlledView={messengerNestedView} - activeConversationId={messengerConversationId as any} - onConversationChange={setMessengerConversationId as any} + activeConversationId={messengerConversationId} + onConversationChange={handleMessengerConversationChange} /> )} {activeTab === "help" && enableHelpCenter && ( @@ -177,7 +186,13 @@ export function MessengerContent({ )} {messengerView === "conversation" && selectedConversationId && ( - + )} {messengerView === "article" && ( diff --git a/packages/react-native-sdk/src/components/Opencom.tsx b/packages/react-native-sdk/src/components/Opencom.tsx index 2867f33..d32019f 100644 --- a/packages/react-native-sdk/src/components/Opencom.tsx +++ b/packages/react-native-sdk/src/components/Opencom.tsx @@ -15,6 +15,8 @@ import { OpencomSurveyRuntime } from "./OpencomSurveyRuntime"; import { OpencomContextProvider } from "./OpencomProvider"; import { MessengerContent, type MainTab, type MessengerView } from "./MessengerContent"; import type { UserIdentification } from "@opencom/sdk-core"; +import type { MessengerConversationId } from "./messengerCompositionTypes"; +import { toMessengerConversationId } from "./messengerCompositionTypes"; // ============================================================================ // Types @@ -187,9 +189,14 @@ export const Opencom = forwardRef(function Opencom( const [isOpen, setIsOpen] = useState(false); const [activeTab, setActiveTab] = useState("home"); const [messengerView, setMessengerView] = useState("tabs"); - const [selectedConversationId, setSelectedConversationId] = useState(null); + const [selectedConversationId, setSelectedConversationId] = useState( + null + ); const [selectedArticleId, setSelectedArticleId] = useState(null); const [identifiedUser, setIdentifiedUser] = useState(user ?? null); + const handleSelectedConversationChange = useCallback((id: MessengerConversationId) => { + setSelectedConversationId(id); + }, []); // Initialize SDK useEffect(() => { @@ -289,7 +296,7 @@ export const Opencom = forwardRef(function Opencom( onOpen?.(); }, presentConversation: (conversationId: string) => { - setSelectedConversationId(conversationId); + setSelectedConversationId(toMessengerConversationId(conversationId)); setMessengerView("conversation"); setIsOpen(true); onOpen?.(); @@ -375,7 +382,7 @@ export const Opencom = forwardRef(function Opencom( messengerView={messengerView} setMessengerView={setMessengerView} selectedConversationId={selectedConversationId} - setSelectedConversationId={setSelectedConversationId} + setSelectedConversationId={handleSelectedConversationChange} selectedArticleId={selectedArticleId} setSelectedArticleId={setSelectedArticleId} availableTabs={availableTabs} diff --git a/packages/react-native-sdk/src/components/OpencomMessenger.tsx b/packages/react-native-sdk/src/components/OpencomMessenger.tsx index d25fcd4..2974c9c 100644 --- a/packages/react-native-sdk/src/components/OpencomMessenger.tsx +++ b/packages/react-native-sdk/src/components/OpencomMessenger.tsx @@ -1,40 +1,27 @@ -import React, { useState, useRef, useEffect } from "react"; +import React from "react"; import { View, - Text, - TextInput, - TouchableOpacity, - FlatList, - StyleSheet, KeyboardAvoidingView, Platform, SafeAreaView, type ViewStyle, } from "react-native"; -import { - useConversations, - useConversation, - useCreateConversation, -} from "../hooks/useConversations"; +import type { Id } from "@opencom/convex/dataModel"; import { useOpencomContext } from "./OpencomProvider"; import { useMessengerSettings } from "../hooks/useMessengerSettings"; -import { useAutomationSettings } from "../hooks/useAutomationSettings"; -import { OpencomSDK } from "../OpencomSDK"; -import { useMutation } from "convex/react"; -import { api } from "@opencom/convex"; -import type { Id } from "@opencom/convex/dataModel"; - -type MessengerView = "list" | "conversation"; - -interface OpencomMessengerProps { +import type { MessengerCompositionControlProps } from "./messengerCompositionTypes"; +import { ConversationListView } from "./messenger/ConversationListView"; +import { ConversationDetailView } from "./messenger/ConversationDetailView"; +import { messengerStyles } from "./messenger/styles"; +import { useConversationListController } from "./messenger/useConversationListController"; +import { useConversationDetailController } from "./messenger/useConversationDetailController"; +import { useMessengerShellController } from "./messenger/useMessengerShellController"; + +interface OpencomMessengerProps extends MessengerCompositionControlProps { onClose?: () => void; style?: ViewStyle; headerTitle?: string; primaryColor?: string; - onViewChange?: (view: "list" | "conversation") => void; - controlledView?: "list" | "conversation"; - activeConversationId?: Id<"conversations"> | null; - onConversationChange?: (conversationId: Id<"conversations"> | null) => void; } export function OpencomMessenger({ @@ -46,694 +33,67 @@ export function OpencomMessenger({ onConversationChange, }: OpencomMessengerProps) { const { theme } = useMessengerSettings(); + const { workspaceId } = useOpencomContext(); - // Use theme colors if not overridden by props const effectivePrimaryColor = primaryColor ?? theme.primaryColor; - const [view, setView] = useState(controlledView ?? "list"); - const [localConversationId, setLocalConversationId] = useState | null>(null); - const { workspaceId } = useOpencomContext(); - // Use controlled conversation ID if provided, otherwise use local state - const activeConversationId = - controlledConversationId !== undefined ? controlledConversationId : localConversationId; - const setActiveConversationId = (id: Id<"conversations"> | null) => { - if (onConversationChange) { - onConversationChange(id); - } else { - setLocalConversationId(id); - } - }; + const { view, activeConversationId, handleSelectConversation, handleNewConversation } = + useMessengerShellController({ + controlledView, + activeConversationId: controlledConversationId, + onViewChange, + onConversationChange, + }); - // Sync with parent-controlled view when it changes - useEffect(() => { - if (controlledView !== undefined && controlledView !== view) { - setView(controlledView); - // Only clear conversation when explicitly going back to list - if (controlledView === "list" && onConversationChange) { - onConversationChange(null); - } - } - }, [controlledView]); + const listController = useConversationListController({ + workspaceId: workspaceId as Id<"workspaces">, + onSelectConversation: handleSelectConversation, + onNewConversation: handleNewConversation, + }); - const handleSelectConversation = (conversationId: Id<"conversations">) => { - setActiveConversationId(conversationId); - setView("conversation"); - onViewChange?.("conversation"); - }; + const detailController = useConversationDetailController({ + conversationId: activeConversationId, + }); - const handleNewConversation = (conversationId: Id<"conversations">) => { - setActiveConversationId(conversationId); - setView("conversation"); - onViewChange?.("conversation"); - }; + const showConversation = view === "conversation" && activeConversationId !== null; return ( - + - {view === "list" ? ( - } theme={theme} + inputValue={detailController.inputValue} + onInputChange={detailController.setInputValue} + onSend={detailController.handleSend} + emailInput={detailController.emailInput} + onEmailChange={detailController.setEmailInput} + showEmailCapture={detailController.showEmailCapture} + onEmailSubmit={detailController.handleEmailSubmit} + onEmailDismiss={detailController.handleEmailDismiss} + isValidEmail={detailController.isValidEmail} + flatListRef={detailController.flatListRef} /> ) : ( - + + + )} ); } - -interface ConversationListProps { - onSelectConversation: (id: Id<"conversations">) => void; - onNewConversation: (id: Id<"conversations">) => void; - primaryColor: string; - workspaceId: Id<"workspaces">; - theme: import("../hooks/useMessengerSettings").OpencomTheme; -} - -function ConversationList({ - onSelectConversation, - onNewConversation, - primaryColor, - workspaceId, - theme, -}: ConversationListProps) { - const { conversations, isLoading } = useConversations(); - const { createConversation } = useCreateConversation(); - - const handleNewConversation = async () => { - const result = await createConversation(workspaceId); - if (result) { - onNewConversation(result._id); - } - }; - - const formatTime = (timestamp: number) => { - const date = new Date(timestamp); - const now = new Date(); - const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); - - if (diffDays === 0) { - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - } else if (diffDays === 1) { - return "Yesterday"; - } else if (diffDays < 7) { - return date.toLocaleDateString([], { weekday: "short" }); - } - return date.toLocaleDateString([], { month: "short", day: "numeric" }); - }; - - return ( - - {isLoading ? ( - - Loading... - - ) : conversations.length === 0 ? ( - - No conversations yet - - Start a new conversation to get help - - - - Start a conversation - - - - ) : ( - item._id} - renderItem={({ item }) => ( - onSelectConversation(item._id)} - > - - - Conversation - - - {item.lastMessage?.content || "No messages yet"} - - - - {item.unreadByVisitor && item.unreadByVisitor > 0 && ( - - - {item.unreadByVisitor} - - - )} - - {item.lastMessageAt ? formatTime(item.lastMessageAt) : formatTime(item.createdAt)} - - - - )} - ListHeaderComponent={ - - - - + - - - New Conversation - - - - } - /> - )} - - ); -} - -interface ConversationDetailProps { - conversationId: Id<"conversations">; - primaryColor: string; - theme: import("../hooks/useMessengerSettings").OpencomTheme; -} - -function ConversationDetail({ conversationId, primaryColor, theme }: ConversationDetailProps) { - const { messages, isLoading, sendMessage, markAsRead } = useConversation(conversationId); - const state = OpencomSDK.getVisitorState(); - const visitorId = state.visitorId; - const automationSettings = useAutomationSettings(); - const identifyVisitor = useMutation(api.visitors.identify); - - const [inputValue, setInputValue] = useState(""); - const [emailInput, setEmailInput] = useState(""); - const [showEmailCapture, setShowEmailCapture] = useState(false); - const [emailCaptured, setEmailCaptured] = useState(false); - const [lastAgentMessageCount, setLastAgentMessageCount] = useState(0); - const flatListRef = useRef(null); - - // Check if visitor has sent any messages - const visitorMessages = messages.filter((m) => m.senderType === "visitor"); - const hasVisitorSentMessage = visitorMessages.length > 0; - - useEffect(() => { - markAsRead(); - }, [conversationId]); - - useEffect(() => { - if (messages.length > 0) { - flatListRef.current?.scrollToEnd({ animated: true }); - } - }, [messages.length]); - - // Smart email capture: show after first visitor message if collectEmailEnabled - useEffect(() => { - if (!visitorId || emailCaptured) return; - if (!hasVisitorSentMessage) return; - if (!automationSettings?.collectEmailEnabled) return; - - const agentMessages = messages.filter((m) => m.senderType !== "visitor"); - const agentCount = agentMessages.length; - - // Show email capture after visitor sends first message - if (!showEmailCapture && !emailCaptured) { - setShowEmailCapture(true); - return; - } - - // Re-show after new agent reply if previously dismissed - if (agentCount > lastAgentMessageCount && !emailCaptured) { - setShowEmailCapture(true); - } - setLastAgentMessageCount(agentCount); - }, [ - visitorId, - hasVisitorSentMessage, - messages, - emailCaptured, - lastAgentMessageCount, - automationSettings?.collectEmailEnabled, - showEmailCapture, - ]); - - const handleEmailSubmit = async () => { - if (!emailInput.trim() || !visitorId) return; - try { - await identifyVisitor({ - visitorId: visitorId as Id<"visitors">, - sessionToken: state.sessionToken ?? undefined, - email: emailInput.trim(), - origin: undefined, - }); - setShowEmailCapture(false); - setEmailCaptured(true); - setEmailInput(""); - } catch (error) { - console.error("[Opencom] Failed to update email:", error); - } - }; - - const handleEmailDismiss = () => { - setShowEmailCapture(false); - }; - - const handleSend = async () => { - if (!inputValue.trim()) return; - const content = inputValue; - setInputValue(""); - await sendMessage(content); - }; - - const formatTime = (timestamp: number) => { - return new Date(timestamp).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - }; - - const isValidEmail = (email: string) => { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); - }; - - return ( - - item._id} - style={styles.messageList} - contentContainerStyle={styles.messageListContent} - renderItem={({ item }) => ( - - - {item.content} - - - {formatTime(item._creationTime)} - - - )} - ListEmptyComponent={ - isLoading ? ( - - Loading messages... - - ) : ( - - Hi! How can we help you today? - - ) - } - /> - - {/* In-conversation email capture */} - {showEmailCapture && ( - - - Get notified when we reply: - - - - - Save - - - - Skip - - - )} - - - - - Send - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#FFFFFF", - }, - keyboardView: { - flex: 1, - }, - listContainer: { - flex: 1, - }, - detailContainer: { - flex: 1, - }, - header: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: "#E5E5E5", - }, - headerTitle: { - fontSize: 18, - fontWeight: "600", - color: "#FFFFFF", - }, - headerTitleRow: { - flexDirection: "row", - alignItems: "center", - gap: 10, - }, - headerLogo: { - width: 28, - height: 28, - borderRadius: 4, - }, - headerActions: { - flexDirection: "row", - alignItems: "center", - gap: 12, - }, - headerButton: { - padding: 4, - }, - headerButtonText: { - fontSize: 16, - fontWeight: "500", - }, - closeButton: { - fontSize: 20, - color: "#666666", - }, - backButton: { - padding: 4, - }, - backButtonText: { - fontSize: 28, - color: "#000000", - }, - emptyContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", - padding: 24, - }, - emptyTitle: { - fontSize: 18, - fontWeight: "600", - color: "#000000", - marginBottom: 8, - }, - emptyText: { - fontSize: 14, - color: "#666666", - textAlign: "center", - marginBottom: 16, - }, - startButton: { - paddingHorizontal: 24, - paddingVertical: 12, - borderRadius: 8, - }, - startButtonText: { - color: "#FFFFFF", - fontSize: 16, - fontWeight: "600", - }, - conversationItem: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "flex-start", - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: "#F0F0F0", - backgroundColor: "#FFFFFF", - }, - conversationContent: { - flex: 1, - marginRight: 12, - }, - conversationMessage: { - flex: 1, - fontSize: 15, - color: "#333333", - }, - conversationMeta: { - flexDirection: "row", - alignItems: "center", - gap: 8, - }, - conversationTime: { - fontSize: 12, - color: "#999999", - }, - unreadBadge: { - borderRadius: 10, - minWidth: 20, - height: 20, - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 6, - }, - unreadText: { - color: "#FFFFFF", - fontSize: 12, - fontWeight: "600", - }, - messageList: { - flex: 1, - }, - messageListContent: { - padding: 16, - }, - messageBubble: { - maxWidth: "80%", - padding: 12, - borderRadius: 16, - marginBottom: 8, - }, - userMessage: { - alignSelf: "flex-end", - borderBottomRightRadius: 4, - }, - agentMessage: { - alignSelf: "flex-start", - backgroundColor: "#F0F0F0", - borderBottomLeftRadius: 4, - }, - messageText: { - fontSize: 15, - color: "#000000", - }, - userMessageText: { - color: "#FFFFFF", - }, - messageTime: { - fontSize: 11, - color: "#666666", - marginTop: 4, - }, - userMessageTime: { - color: "rgba(255, 255, 255, 0.7)", - }, - loadingText: { - textAlign: "center", - color: "#666666", - marginTop: 20, - }, - welcomeMessage: { - backgroundColor: "#F0F0F0", - padding: 12, - borderRadius: 16, - borderBottomLeftRadius: 4, - alignSelf: "flex-start", - }, - welcomeText: { - fontSize: 15, - color: "#000000", - }, - inputContainer: { - flexDirection: "row", - alignItems: "flex-end", - padding: 12, - borderTopWidth: 1, - borderTopColor: "#E5E5E5", - gap: 8, - }, - input: { - flex: 1, - backgroundColor: "#F5F5F5", - borderRadius: 20, - paddingHorizontal: 16, - paddingVertical: 10, - fontSize: 15, - maxHeight: 100, - }, - sendButton: { - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 20, - }, - sendButtonText: { - color: "#FFFFFF", - fontSize: 15, - fontWeight: "600", - }, - conversationTitle: { - fontSize: 16, - fontWeight: "600", - color: "#000000", - marginBottom: 2, - }, - newConversationContainer: { - padding: 16, - paddingBottom: 8, - }, - newConversationButton: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: 14, - borderRadius: 8, - gap: 8, - }, - newConversationButtonIcon: { - color: "#FFFFFF", - fontSize: 20, - fontWeight: "600", - }, - newConversationButtonText: { - color: "#FFFFFF", - fontSize: 16, - fontWeight: "600", - }, - emailCaptureContainer: { - backgroundColor: "#F8F9FA", - padding: 12, - borderTopWidth: 1, - borderTopColor: "#E5E5E5", - }, - emailCaptureText: { - fontSize: 13, - color: "#666666", - marginBottom: 8, - }, - emailCaptureRow: { - flexDirection: "row", - alignItems: "center", - gap: 8, - }, - emailInput: { - flex: 1, - backgroundColor: "#FFFFFF", - borderWidth: 1, - borderColor: "#E5E5E5", - borderRadius: 6, - paddingHorizontal: 12, - paddingVertical: 8, - fontSize: 14, - }, - emailSubmitButton: { - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 6, - }, - emailSubmitText: { - color: "#FFFFFF", - fontSize: 14, - fontWeight: "600", - }, - emailSkipButton: { - marginTop: 8, - alignSelf: "center", - }, - emailSkipText: { - color: "#999999", - fontSize: 13, - }, -}); diff --git a/packages/react-native-sdk/src/components/OpencomSurvey.tsx b/packages/react-native-sdk/src/components/OpencomSurvey.tsx index bb882fb..d1beca4 100644 --- a/packages/react-native-sdk/src/components/OpencomSurvey.tsx +++ b/packages/react-native-sdk/src/components/OpencomSurvey.tsx @@ -1,94 +1,11 @@ -import React, { useState, useEffect } from "react"; -import { - View, - Text, - TouchableOpacity, - StyleSheet, - TextInput, - ScrollView, - type ViewStyle, -} from "react-native"; -import { useMutation } from "convex/react"; -import { api } from "@opencom/convex"; -import { OpencomSDK } from "../OpencomSDK"; -import type { Id } from "@opencom/convex/dataModel"; +import React from "react"; +import { View, Text, TouchableOpacity, ScrollView } from "react-native"; +import { SurveyIntroStepView, SurveyQuestionStepView, SurveyThankYouStepView } from "./survey/SurveyStepViews"; +import { surveyStyles } from "./survey/styles"; +import type { OpencomSurveyProps, Survey, SurveyQuestion } from "./survey/types"; +import { useSurveyController } from "./survey/useSurveyController"; -type QuestionType = - | "nps" - | "numeric_scale" - | "star_rating" - | "emoji_rating" - | "dropdown" - | "short_text" - | "long_text" - | "multiple_choice"; - -export interface SurveyQuestion { - id: string; - type: QuestionType; - title: string; - description?: string; - required: boolean; - storeAsAttribute?: string; - options?: { - scaleStart?: number; - scaleEnd?: number; - startLabel?: string; - endLabel?: string; - starLabels?: { low?: string; high?: string }; - emojiCount?: 3 | 5; - emojiLabels?: { low?: string; high?: string }; - choices?: string[]; - allowMultiple?: boolean; - }; -} - -export interface Survey { - _id: Id<"surveys">; - name: string; - format: "small" | "large"; - questions: SurveyQuestion[]; - introStep?: { title: string; description?: string; buttonText?: string }; - thankYouStep?: { title: string; description?: string; buttonText?: string }; - showProgressBar?: boolean; - showDismissButton?: boolean; -} - -interface OpencomSurveyProps { - survey: Survey; - onDismiss?: () => void; - onComplete?: () => void; - style?: ViewStyle; - primaryColor?: string; -} - -type SurveySubmissionValue = string | number | boolean | string[] | number[] | null; - -function isSurveyAnswerPrimitive(value: unknown): value is string | number | boolean | null { - return ( - value === null || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ); -} - -function normalizeSurveyAnswerValue(value: unknown): SurveySubmissionValue { - if (isSurveyAnswerPrimitive(value)) { - return value; - } - - if (Array.isArray(value)) { - if (value.every((entry) => typeof entry === "string")) { - return value; - } - if (value.every((entry) => typeof entry === "number")) { - return value; - } - } - - return null; -} +export type { Survey, SurveyQuestion }; export function OpencomSurvey({ survey, @@ -97,164 +14,43 @@ export function OpencomSurvey({ style, primaryColor = "#792cd4", }: OpencomSurveyProps) { - const [currentIndex, setCurrentIndex] = useState(survey.introStep ? -1 : 0); - const [answers, setAnswers] = useState>({}); - const [isSubmitting, setIsSubmitting] = useState(false); - const [showThankYou, setShowThankYou] = useState(false); - - const submitResponse = useMutation(api.surveys.submitResponse); - const recordImpression = useMutation(api.surveys.recordImpression); - - const totalSteps = survey.questions.length; - const isIntroStep = currentIndex === -1; - const isQuestionStep = currentIndex >= 0 && currentIndex < totalSteps; - const currentQuestion = isQuestionStep ? survey.questions[currentIndex] : null; - - useEffect(() => { - const state = OpencomSDK.getVisitorState(); - if (state.visitorId && state.sessionToken) { - recordImpression({ - surveyId: survey._id, - visitorId: state.visitorId, - sessionId: state.sessionId, - sessionToken: state.sessionToken, - action: "shown", - }); - } - }, []); - - const handleAnswer = (value: unknown) => { - if (!currentQuestion) return; - setAnswers({ ...answers, [currentQuestion.id]: value }); - }; - - const handleNext = async () => { - const state = OpencomSDK.getVisitorState(); - - if (isIntroStep) { - if (state.visitorId && state.sessionToken) { - recordImpression({ - surveyId: survey._id, - visitorId: state.visitorId, - sessionId: state.sessionId, - sessionToken: state.sessionToken, - action: "started", - }); - } - setCurrentIndex(0); - return; - } - - if (currentQuestion?.required && answers[currentQuestion.id] === undefined) { - return; - } - - if (currentIndex < totalSteps - 1) { - setCurrentIndex(currentIndex + 1); - } else { - await handleSubmit(); - } - }; - - const handleBack = () => { - if (currentIndex > (survey.introStep ? -1 : 0)) { - setCurrentIndex(currentIndex - 1); - } - }; - - const handleSubmit = async () => { - setIsSubmitting(true); - const state = OpencomSDK.getVisitorState(); - if (!state.visitorId || !state.sessionToken) { - setIsSubmitting(false); - return; - } - - try { - const answerArray = Object.entries(answers).map(([questionId, value]) => ({ - questionId, - value: normalizeSurveyAnswerValue(value), - })); - - await submitResponse({ - surveyId: survey._id, - visitorId: state.visitorId, - sessionId: state.sessionId, - sessionToken: state.sessionToken, - answers: answerArray, - isComplete: true, - }); - - await recordImpression({ - surveyId: survey._id, - visitorId: state.visitorId, - sessionId: state.sessionId, - sessionToken: state.sessionToken, - action: "completed", - }); - - if (survey.thankYouStep) { - setShowThankYou(true); - } else { - onComplete?.(); - } - } catch (error) { - console.error("Failed to submit survey:", error); - } finally { - setIsSubmitting(false); - } - }; - - const handleDismiss = async () => { - const state = OpencomSDK.getVisitorState(); - if (state.visitorId && state.sessionToken) { - await recordImpression({ - surveyId: survey._id, - visitorId: state.visitorId, - sessionId: state.sessionId, - sessionToken: state.sessionToken, - action: "dismissed", - }); - } - onDismiss?.(); - }; - - const canProceed = !currentQuestion?.required || answers[currentQuestion.id] !== undefined; - - if (showThankYou && survey.thankYouStep) { + const controller = useSurveyController({ + survey, + onDismiss, + onComplete, + style, + primaryColor, + }); + + if (controller.showThankYou && survey.thankYouStep) { return ( - - - {survey.thankYouStep.title} - {survey.thankYouStep.description && ( - {survey.thankYouStep.description} - )} - - {survey.thankYouStep.buttonText || "Done"} - - + + ); } return ( - + {survey.showDismissButton !== false && ( - - + + )} - {survey.showProgressBar && isQuestionStep && ( - + {survey.showProgressBar && controller.isQuestionStep && ( + - {isIntroStep && survey.introStep && ( - - {survey.introStep.title} - {survey.introStep.description && ( - {survey.introStep.description} - )} - - {survey.introStep.buttonText || "Start"} - - + {controller.isIntroStep && survey.introStep && ( + )} - {isQuestionStep && currentQuestion && ( - - - - {currentQuestion.title} - {currentQuestion.required && *} - - {currentQuestion.description && ( - {currentQuestion.description} - )} - - - - - - - - {currentIndex > 0 && ( - - Back - - )} - - - {isSubmitting ? "..." : currentIndex === totalSteps - 1 ? "Submit" : "Next"} - - - - + {controller.isQuestionStep && controller.currentQuestion && ( + )} ); } - -function QuestionRenderer({ - question, - value, - onChange, - primaryColor, -}: { - question: SurveyQuestion; - value: unknown; - onChange: (value: unknown) => void; - primaryColor: string; -}) { - switch (question.type) { - case "nps": - return ( - - ); - case "numeric_scale": - return ( - - ); - case "star_rating": - return ( - - ); - case "emoji_rating": - return ( - - ); - case "dropdown": - case "multiple_choice": - return ( - - ); - case "short_text": - return ( - - ); - case "long_text": - return ( - - ); - default: - return null; - } -} - -function NPSQuestion({ - value, - onChange, - primaryColor, -}: { - value: number | undefined; - onChange: (value: number) => void; - primaryColor: string; -}) { - return ( - - - Not likely - Very likely - - - {Array.from({ length: 11 }, (_, i) => ( - onChange(i)} - > - - {i} - - - ))} - - - ); -} - -function NumericScaleQuestion({ - value, - onChange, - options, - primaryColor, -}: { - value: number | undefined; - onChange: (value: number) => void; - options?: SurveyQuestion["options"]; - primaryColor: string; -}) { - const start = options?.scaleStart ?? 1; - const end = options?.scaleEnd ?? 5; - const range = Array.from({ length: end - start + 1 }, (_, i) => start + i); - - return ( - - - {options?.startLabel || String(start)} - {options?.endLabel || String(end)} - - - {range.map((n) => ( - onChange(n)} - > - - {n} - - - ))} - - - ); -} - -function StarRatingQuestion({ - value, - onChange, - options, - primaryColor, -}: { - value: number | undefined; - onChange: (value: number) => void; - options?: SurveyQuestion["options"]; - primaryColor: string; -}) { - return ( - - - {options?.starLabels?.low || ""} - {options?.starLabels?.high || ""} - - - {[1, 2, 3, 4, 5].map((star) => ( - onChange(star)}> - - ★ - - - ))} - - - ); -} - -function EmojiRatingQuestion({ - value, - onChange, - options, - primaryColor, -}: { - value: number | undefined; - onChange: (value: number) => void; - options?: SurveyQuestion["options"]; - primaryColor: string; -}) { - const count = options?.emojiCount ?? 5; - const emojis5 = ["😠", "😕", "😐", "🙂", "😄"]; - const emojis3 = ["😕", "😐", "🙂"]; - const emojis = count === 3 ? emojis3 : emojis5; - - return ( - - - {options?.emojiLabels?.low || ""} - {options?.emojiLabels?.high || ""} - - - {emojis.map((emoji, index) => ( - onChange(index + 1)} - > - {emoji} - - ))} - - - ); -} - -function MultipleChoiceQuestion({ - value, - onChange, - options, - primaryColor, -}: { - value: string | string[] | undefined; - onChange: (value: string | string[]) => void; - options?: SurveyQuestion["options"]; - primaryColor: string; -}) { - const allowMultiple = options?.allowMultiple ?? false; - const selectedValues = Array.isArray(value) ? value : value ? [value] : []; - - const handleSelect = (choice: string) => { - if (allowMultiple) { - if (selectedValues.includes(choice)) { - onChange(selectedValues.filter((v) => v !== choice)); - } else { - onChange([...selectedValues, choice]); - } - } else { - onChange(choice); - } - }; - - return ( - - {options?.choices?.map((choice) => { - const isSelected = selectedValues.includes(choice); - return ( - handleSelect(choice)} - > - - {isSelected && {allowMultiple ? "✓" : "●"}} - - {choice} - - ); - })} - - ); -} - -function TextQuestion({ - value, - onChange, - maxLength, - multiline, -}: { - value: string | undefined; - onChange: (value: string) => void; - maxLength: number; - multiline?: boolean; -}) { - return ( - - - - {(value || "").length}/{maxLength} - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#FFFFFF", - }, - dismissButton: { - position: "absolute", - top: 16, - right: 16, - zIndex: 10, - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: "rgba(0, 0, 0, 0.1)", - justifyContent: "center", - alignItems: "center", - }, - dismissText: { - color: "#374151", - fontSize: 18, - }, - progressContainer: { - height: 4, - backgroundColor: "#E5E7EB", - marginHorizontal: 24, - marginTop: 60, - borderRadius: 2, - overflow: "hidden", - }, - progressBar: { - height: "100%", - borderRadius: 2, - }, - content: { - flex: 1, - }, - contentContainer: { - flexGrow: 1, - justifyContent: "center", - padding: 24, - }, - stepContainer: { - alignItems: "center", - }, - thankYouContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", - padding: 24, - }, - title: { - fontSize: 24, - fontWeight: "700", - color: "#111827", - textAlign: "center", - marginBottom: 12, - }, - description: { - fontSize: 16, - color: "#6B7280", - textAlign: "center", - lineHeight: 24, - marginBottom: 24, - }, - questionHeader: { - width: "100%", - marginBottom: 24, - }, - questionTitle: { - fontSize: 18, - fontWeight: "600", - color: "#111827", - textAlign: "center", - marginBottom: 8, - }, - questionDescription: { - fontSize: 14, - color: "#6B7280", - textAlign: "center", - }, - required: { - color: "#EF4444", - }, - questionContent: { - width: "100%", - marginBottom: 32, - }, - actions: { - flexDirection: "row", - gap: 12, - width: "100%", - }, - primaryButton: { - flex: 1, - paddingVertical: 14, - paddingHorizontal: 24, - borderRadius: 8, - alignItems: "center", - }, - fullWidthButton: { - flex: 1, - }, - primaryButtonText: { - fontSize: 16, - fontWeight: "600", - color: "#FFFFFF", - }, - secondaryButton: { - flex: 1, - paddingVertical: 14, - paddingHorizontal: 24, - borderRadius: 8, - alignItems: "center", - backgroundColor: "#F3F4F6", - }, - secondaryButtonText: { - fontSize: 16, - fontWeight: "600", - }, - scaleContainer: { - width: "100%", - }, - scaleLabels: { - flexDirection: "row", - justifyContent: "space-between", - marginBottom: 8, - }, - scaleLabel: { - fontSize: 12, - color: "#6B7280", - }, - scaleButtons: { - flexDirection: "row", - flexWrap: "wrap", - gap: 6, - justifyContent: "center", - }, - scaleButton: { - minWidth: 36, - height: 36, - borderRadius: 6, - borderWidth: 1, - borderColor: "#E5E7EB", - justifyContent: "center", - alignItems: "center", - backgroundColor: "#FFFFFF", - }, - scaleButtonText: { - fontSize: 14, - color: "#374151", - }, - scaleButtonTextSelected: { - color: "#FFFFFF", - }, - starsContainer: { - width: "100%", - }, - starsRow: { - flexDirection: "row", - justifyContent: "center", - gap: 8, - }, - starButton: { - padding: 4, - }, - starIcon: { - fontSize: 36, - }, - emojiContainer: { - width: "100%", - }, - emojiRow: { - flexDirection: "row", - justifyContent: "center", - gap: 12, - }, - emojiButton: { - padding: 12, - borderRadius: 8, - borderWidth: 2, - borderColor: "transparent", - }, - emoji: { - fontSize: 32, - }, - choicesContainer: { - width: "100%", - gap: 8, - }, - choiceButton: { - flexDirection: "row", - alignItems: "center", - padding: 14, - borderRadius: 8, - borderWidth: 1, - borderColor: "#E5E7EB", - backgroundColor: "#FFFFFF", - }, - choiceIndicator: { - width: 20, - height: 20, - borderWidth: 2, - borderColor: "#D1D5DB", - justifyContent: "center", - alignItems: "center", - marginRight: 12, - }, - radio: { - borderRadius: 10, - }, - checkbox: { - borderRadius: 4, - }, - checkmark: { - color: "#FFFFFF", - fontSize: 12, - fontWeight: "700", - }, - choiceText: { - fontSize: 14, - color: "#374151", - }, - textContainer: { - width: "100%", - }, - textInput: { - borderWidth: 1, - borderColor: "#E5E7EB", - borderRadius: 8, - padding: 12, - fontSize: 14, - color: "#111827", - }, - textInputMultiline: { - height: 100, - textAlignVertical: "top", - }, - charCount: { - fontSize: 12, - color: "#9CA3AF", - textAlign: "right", - marginTop: 4, - }, -}); diff --git a/packages/react-native-sdk/src/components/messenger/ConversationDetailView.tsx b/packages/react-native-sdk/src/components/messenger/ConversationDetailView.tsx new file mode 100644 index 0000000..708c25c --- /dev/null +++ b/packages/react-native-sdk/src/components/messenger/ConversationDetailView.tsx @@ -0,0 +1,173 @@ +import React from "react"; +import { View, Text, TextInput, TouchableOpacity, FlatList } from "react-native"; +import type { OpencomTheme } from "../../hooks/useMessengerSettings"; +import { messengerStyles } from "./styles"; + +interface ConversationMessage { + _id: string; + _creationTime: number; + senderType: "visitor" | string; + content: string; +} + +interface ConversationDetailViewProps { + messages: ConversationMessage[]; + isLoading: boolean; + primaryColor: string; + theme: OpencomTheme; + inputValue: string; + onInputChange: (value: string) => void; + onSend: () => Promise; + emailInput: string; + onEmailChange: (value: string) => void; + showEmailCapture: boolean; + onEmailSubmit: () => Promise; + onEmailDismiss: () => void; + isValidEmail: (email: string) => boolean; + flatListRef: React.RefObject; +} + +function formatTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + +export function ConversationDetailView({ + messages, + isLoading, + primaryColor, + theme, + inputValue, + onInputChange, + onSend, + emailInput, + onEmailChange, + showEmailCapture, + onEmailSubmit, + onEmailDismiss, + isValidEmail, + flatListRef, +}: ConversationDetailViewProps) { + return ( + + item._id} + style={messengerStyles.messageList} + contentContainerStyle={messengerStyles.messageListContent} + renderItem={({ item }) => ( + + + {item.content} + + + {formatTime(item._creationTime)} + + + )} + ListEmptyComponent={ + isLoading ? ( + + Loading messages... + + ) : ( + + Hi! How can we help you today? + + ) + } + /> + + {showEmailCapture && ( + + + Get notified when we reply: + + + + + + Save + + + + + Skip + + + )} + + + + + Send + + + + ); +} diff --git a/packages/react-native-sdk/src/components/messenger/ConversationListView.tsx b/packages/react-native-sdk/src/components/messenger/ConversationListView.tsx new file mode 100644 index 0000000..58588ac --- /dev/null +++ b/packages/react-native-sdk/src/components/messenger/ConversationListView.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { View, Text, TouchableOpacity, FlatList } from "react-native"; +import type { Id } from "@opencom/convex/dataModel"; +import type { OpencomTheme } from "../../hooks/useMessengerSettings"; +import { messengerStyles } from "./styles"; + +interface ConversationListItem { + _id: Id<"conversations">; + createdAt: number; + lastMessageAt?: number; + unreadByVisitor?: number; + lastMessage?: { + content?: string; + } | null; +} + +interface ConversationListViewProps { + conversations: ConversationListItem[]; + isLoading: boolean; + onSelectConversation: (conversationId: Id<"conversations">) => void; + onStartConversation: () => Promise; + formatConversationTimestamp: (timestamp: number) => string; + primaryColor: string; + theme: OpencomTheme; +} + +export function ConversationListView({ + conversations, + isLoading, + onSelectConversation, + onStartConversation, + formatConversationTimestamp, + primaryColor, + theme, +}: ConversationListViewProps) { + if (isLoading) { + return ( + + Loading... + + ); + } + + if (conversations.length === 0) { + return ( + + No conversations yet + + Start a new conversation to get help + + + + Start a conversation + + + + ); + } + + return ( + item._id} + renderItem={({ item }) => ( + onSelectConversation(item._id)} + > + + Conversation + + {item.lastMessage?.content || "No messages yet"} + + + + {item.unreadByVisitor && item.unreadByVisitor > 0 && ( + + + {item.unreadByVisitor} + + + )} + + {item.lastMessageAt + ? formatConversationTimestamp(item.lastMessageAt) + : formatConversationTimestamp(item.createdAt)} + + + + )} + ListHeaderComponent={ + + + + + + New Conversation + + + + } + /> + ); +} diff --git a/packages/react-native-sdk/src/components/messenger/messengerFlow.ts b/packages/react-native-sdk/src/components/messenger/messengerFlow.ts new file mode 100644 index 0000000..acf91c0 --- /dev/null +++ b/packages/react-native-sdk/src/components/messenger/messengerFlow.ts @@ -0,0 +1,95 @@ +import type { Id } from "@opencom/convex/dataModel"; +import type { MessengerConversationId, MessengerNestedView } from "../messengerCompositionTypes"; + +export interface EmailCaptureDecisionInput { + visitorId: string | null; + hasVisitorSentMessage: boolean; + collectEmailEnabled: boolean; + showEmailCapture: boolean; + emailCaptured: boolean; + lastAgentMessageCount: number; + agentMessageCount: number; +} + +export interface EmailCaptureDecision { + shouldOpenPrompt: boolean; + nextLastAgentMessageCount: number; +} + +export interface MessengerShellState { + view: MessengerNestedView; + conversationId: MessengerConversationId; +} + +export function createInitialMessengerShellState( + controlledView: MessengerNestedView | undefined, + controlledConversationId: MessengerConversationId | undefined +): MessengerShellState { + return { + view: controlledView ?? "list", + conversationId: controlledConversationId ?? null, + }; +} + +export function selectMessengerConversation( + conversationId: Id<"conversations">, + currentState: MessengerShellState +): MessengerShellState { + return { + ...currentState, + view: "conversation", + conversationId, + }; +} + +export function shouldResetConversationOnControlledList( + controlledView: MessengerNestedView | undefined, + hasExternalConversationController: boolean +): boolean { + return controlledView === "list" && hasExternalConversationController; +} + +export function normalizeOutgoingMessage(inputValue: string): string | null { + const normalized = inputValue.trim(); + return normalized.length > 0 ? normalized : null; +} + +export function formatConversationTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + if (diffDays === 1) { + return "Yesterday"; + } + if (diffDays < 7) { + return date.toLocaleDateString([], { weekday: "short" }); + } + return date.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +export function evaluateEmailCaptureDecision( + input: EmailCaptureDecisionInput +): EmailCaptureDecision | null { + if (!input.visitorId || input.emailCaptured) { + return null; + } + if (!input.hasVisitorSentMessage || !input.collectEmailEnabled) { + return null; + } + + if (!input.showEmailCapture) { + return { + shouldOpenPrompt: true, + nextLastAgentMessageCount: input.lastAgentMessageCount, + }; + } + + return { + shouldOpenPrompt: input.agentMessageCount > input.lastAgentMessageCount, + nextLastAgentMessageCount: input.agentMessageCount, + }; +} diff --git a/packages/react-native-sdk/src/components/messenger/styles.ts b/packages/react-native-sdk/src/components/messenger/styles.ts new file mode 100644 index 0000000..0fcdc9a --- /dev/null +++ b/packages/react-native-sdk/src/components/messenger/styles.ts @@ -0,0 +1,284 @@ +import { StyleSheet } from "react-native"; + +export const messengerStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + keyboardView: { + flex: 1, + }, + listContainer: { + flex: 1, + }, + detailContainer: { + flex: 1, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#E5E5E5", + }, + headerTitle: { + fontSize: 18, + fontWeight: "600", + color: "#FFFFFF", + }, + headerTitleRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + headerLogo: { + width: 28, + height: 28, + borderRadius: 4, + }, + headerActions: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + headerButton: { + padding: 4, + }, + headerButtonText: { + fontSize: 16, + fontWeight: "500", + }, + closeButton: { + fontSize: 20, + color: "#666666", + }, + backButton: { + padding: 4, + }, + backButtonText: { + fontSize: 28, + color: "#000000", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 24, + }, + emptyTitle: { + fontSize: 18, + fontWeight: "600", + color: "#000000", + marginBottom: 8, + }, + emptyText: { + fontSize: 14, + color: "#666666", + textAlign: "center", + marginBottom: 16, + }, + startButton: { + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + }, + startButtonText: { + color: "#FFFFFF", + fontSize: 16, + fontWeight: "600", + }, + conversationItem: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#F0F0F0", + backgroundColor: "#FFFFFF", + }, + conversationContent: { + flex: 1, + marginRight: 12, + }, + conversationMessage: { + flex: 1, + fontSize: 15, + color: "#333333", + }, + conversationMeta: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + conversationTime: { + fontSize: 12, + color: "#999999", + }, + unreadBadge: { + borderRadius: 10, + minWidth: 20, + height: 20, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 6, + }, + unreadText: { + color: "#FFFFFF", + fontSize: 12, + fontWeight: "600", + }, + messageList: { + flex: 1, + }, + messageListContent: { + padding: 16, + }, + messageBubble: { + maxWidth: "80%", + padding: 12, + borderRadius: 16, + marginBottom: 8, + }, + userMessage: { + alignSelf: "flex-end", + borderBottomRightRadius: 4, + }, + agentMessage: { + alignSelf: "flex-start", + backgroundColor: "#F0F0F0", + borderBottomLeftRadius: 4, + }, + messageText: { + fontSize: 15, + color: "#000000", + }, + userMessageText: { + color: "#FFFFFF", + }, + messageTime: { + fontSize: 11, + color: "#666666", + marginTop: 4, + }, + userMessageTime: { + color: "rgba(255, 255, 255, 0.7)", + }, + loadingText: { + textAlign: "center", + color: "#666666", + marginTop: 20, + }, + welcomeMessage: { + backgroundColor: "#F0F0F0", + padding: 12, + borderRadius: 16, + borderBottomLeftRadius: 4, + alignSelf: "flex-start", + }, + welcomeText: { + fontSize: 15, + color: "#000000", + }, + inputContainer: { + flexDirection: "row", + alignItems: "flex-end", + padding: 12, + borderTopWidth: 1, + borderTopColor: "#E5E5E5", + gap: 8, + }, + input: { + flex: 1, + backgroundColor: "#F5F5F5", + borderRadius: 20, + paddingHorizontal: 16, + paddingVertical: 10, + fontSize: 15, + maxHeight: 100, + }, + sendButton: { + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 20, + }, + sendButtonText: { + color: "#FFFFFF", + fontSize: 15, + fontWeight: "600", + }, + conversationTitle: { + fontSize: 16, + fontWeight: "600", + color: "#000000", + marginBottom: 2, + }, + newConversationContainer: { + padding: 16, + paddingBottom: 8, + }, + newConversationButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 14, + borderRadius: 8, + gap: 8, + }, + newConversationButtonIcon: { + color: "#FFFFFF", + fontSize: 20, + fontWeight: "600", + }, + newConversationButtonText: { + color: "#FFFFFF", + fontSize: 16, + fontWeight: "600", + }, + emailCaptureContainer: { + backgroundColor: "#F8F9FA", + padding: 12, + borderTopWidth: 1, + borderTopColor: "#E5E5E5", + }, + emailCaptureText: { + fontSize: 13, + color: "#666666", + marginBottom: 8, + }, + emailCaptureRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + emailInput: { + flex: 1, + backgroundColor: "#FFFFFF", + borderWidth: 1, + borderColor: "#E5E5E5", + borderRadius: 6, + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: 14, + }, + emailSubmitButton: { + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 6, + }, + emailSubmitText: { + color: "#FFFFFF", + fontSize: 14, + fontWeight: "600", + }, + emailSkipButton: { + marginTop: 8, + alignSelf: "center", + }, + emailSkipText: { + color: "#999999", + fontSize: 13, + }, +}); diff --git a/packages/react-native-sdk/src/components/messenger/useConversationDetailController.ts b/packages/react-native-sdk/src/components/messenger/useConversationDetailController.ts new file mode 100644 index 0000000..67d0b7c --- /dev/null +++ b/packages/react-native-sdk/src/components/messenger/useConversationDetailController.ts @@ -0,0 +1,120 @@ +import { useEffect, useRef, useState } from "react"; +import type { FlatList } from "react-native"; +import { useMutation } from "convex/react"; +import { api } from "@opencom/convex"; +import type { Id } from "@opencom/convex/dataModel"; +import { OpencomSDK } from "../../OpencomSDK"; +import { useConversation } from "../../hooks/useConversations"; +import { useAutomationSettings } from "../../hooks/useAutomationSettings"; +import { evaluateEmailCaptureDecision, normalizeOutgoingMessage } from "./messengerFlow"; + +interface UseConversationDetailControllerInput { + conversationId: Id<"conversations"> | null; +} + +export function useConversationDetailController({ conversationId }: UseConversationDetailControllerInput) { + const { messages, isLoading, sendMessage, markAsRead } = useConversation(conversationId); + const state = OpencomSDK.getVisitorState(); + const visitorId = state.visitorId; + const automationSettings = useAutomationSettings(); + const identifyVisitor = useMutation(api.visitors.identify); + + const [inputValue, setInputValue] = useState(""); + const [emailInput, setEmailInput] = useState(""); + const [showEmailCapture, setShowEmailCapture] = useState(false); + const [emailCaptured, setEmailCaptured] = useState(false); + const [lastAgentMessageCount, setLastAgentMessageCount] = useState(0); + const flatListRef = useRef(null); + + const visitorMessages = messages.filter((message) => message.senderType === "visitor"); + const hasVisitorSentMessage = visitorMessages.length > 0; + const agentMessageCount = messages.filter((message) => message.senderType !== "visitor").length; + + useEffect(() => { + markAsRead(); + }, [conversationId]); + + useEffect(() => { + if (messages.length > 0) { + flatListRef.current?.scrollToEnd({ animated: true }); + } + }, [messages.length]); + + useEffect(() => { + const decision = evaluateEmailCaptureDecision({ + visitorId: visitorId ?? null, + hasVisitorSentMessage, + collectEmailEnabled: Boolean(automationSettings?.collectEmailEnabled), + showEmailCapture, + emailCaptured, + lastAgentMessageCount, + agentMessageCount, + }); + + if (!decision) { + return; + } + + if (decision.shouldOpenPrompt) { + setShowEmailCapture(true); + } + setLastAgentMessageCount(decision.nextLastAgentMessageCount); + }, [ + visitorId, + hasVisitorSentMessage, + showEmailCapture, + emailCaptured, + lastAgentMessageCount, + agentMessageCount, + automationSettings?.collectEmailEnabled, + ]); + + const handleEmailSubmit = async () => { + if (!emailInput.trim() || !visitorId) { + return; + } + try { + await identifyVisitor({ + visitorId: visitorId as Id<"visitors">, + sessionToken: state.sessionToken ?? undefined, + email: emailInput.trim(), + origin: undefined, + }); + setShowEmailCapture(false); + setEmailCaptured(true); + setEmailInput(""); + } catch (error) { + console.error("[Opencom] Failed to update email:", error); + } + }; + + const handleEmailDismiss = () => { + setShowEmailCapture(false); + }; + + const handleSend = async () => { + const content = normalizeOutgoingMessage(inputValue); + if (!content) { + return; + } + setInputValue(""); + await sendMessage(content); + }; + + const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + + return { + messages, + isLoading, + inputValue, + emailInput, + showEmailCapture, + flatListRef, + setInputValue, + setEmailInput, + handleSend, + handleEmailSubmit, + handleEmailDismiss, + isValidEmail, + }; +} diff --git a/packages/react-native-sdk/src/components/messenger/useConversationListController.ts b/packages/react-native-sdk/src/components/messenger/useConversationListController.ts new file mode 100644 index 0000000..35ecbd4 --- /dev/null +++ b/packages/react-native-sdk/src/components/messenger/useConversationListController.ts @@ -0,0 +1,33 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { useConversations, useCreateConversation } from "../../hooks/useConversations"; +import { formatConversationTimestamp } from "./messengerFlow"; + +interface UseConversationListControllerInput { + workspaceId: Id<"workspaces">; + onSelectConversation: (conversationId: Id<"conversations">) => void; + onNewConversation: (conversationId: Id<"conversations">) => void; +} + +export function useConversationListController({ + workspaceId, + onNewConversation, + onSelectConversation, +}: UseConversationListControllerInput) { + const { conversations, isLoading } = useConversations(); + const { createConversation } = useCreateConversation(); + + const handleNewConversation = async () => { + const result = await createConversation(workspaceId); + if (result) { + onNewConversation(result._id); + } + }; + + return { + conversations, + isLoading, + handleNewConversation, + onSelectConversation, + formatConversationTimestamp, + }; +} diff --git a/packages/react-native-sdk/src/components/messenger/useMessengerShellController.ts b/packages/react-native-sdk/src/components/messenger/useMessengerShellController.ts new file mode 100644 index 0000000..b1c9799 --- /dev/null +++ b/packages/react-native-sdk/src/components/messenger/useMessengerShellController.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import type { Id } from "@opencom/convex/dataModel"; +import type { + MessengerCompositionControlProps, + MessengerConversationId, + MessengerNestedView, +} from "../messengerCompositionTypes"; +import { + createInitialMessengerShellState, + selectMessengerConversation, + shouldResetConversationOnControlledList, +} from "./messengerFlow"; + +interface MessengerShellControllerInput extends MessengerCompositionControlProps { + onViewChange?: (view: MessengerNestedView) => void; +} + +export interface MessengerShellControllerResult { + view: MessengerNestedView; + activeConversationId: MessengerConversationId; + handleSelectConversation: (conversationId: Id<"conversations">) => void; + handleNewConversation: (conversationId: Id<"conversations">) => void; +} + +export function useMessengerShellController({ + controlledView, + activeConversationId: controlledConversationId, + onViewChange, + onConversationChange, +}: MessengerShellControllerInput): MessengerShellControllerResult { + const [view, setView] = useState( + () => createInitialMessengerShellState(controlledView, controlledConversationId).view + ); + const [localConversationId, setLocalConversationId] = useState( + () => createInitialMessengerShellState(controlledView, controlledConversationId).conversationId + ); + + const activeConversationId = + controlledConversationId !== undefined ? controlledConversationId : localConversationId; + + const setActiveConversationId = (conversationId: MessengerConversationId) => { + if (onConversationChange) { + onConversationChange(conversationId); + return; + } + setLocalConversationId(conversationId); + }; + + useEffect(() => { + if (controlledView !== undefined && controlledView !== view) { + setView(controlledView); + if (shouldResetConversationOnControlledList(controlledView, Boolean(onConversationChange))) { + onConversationChange?.(null); + } + } + }, [controlledView, view, onConversationChange]); + + const openConversationView = (conversationId: Id<"conversations">) => { + const nextState = selectMessengerConversation(conversationId, { + view, + conversationId: activeConversationId ?? null, + }); + setActiveConversationId(nextState.conversationId); + setView(nextState.view); + onViewChange?.(nextState.view); + }; + + return { + view, + activeConversationId, + handleSelectConversation: openConversationView, + handleNewConversation: openConversationView, + }; +} diff --git a/packages/react-native-sdk/src/components/messengerCompositionContract.md b/packages/react-native-sdk/src/components/messengerCompositionContract.md new file mode 100644 index 0000000..7fe29c3 --- /dev/null +++ b/packages/react-native-sdk/src/components/messengerCompositionContract.md @@ -0,0 +1,38 @@ +# Messenger Composition Contract (RN SDK) + +This contract governs prop wiring between: + +- `Opencom.tsx` (public API + modal shell) +- `MessengerContent.tsx` (tab/composition layer) +- `OpencomMessenger.tsx` (messages list/detail runtime) + +## Canonical Types + +Defined in `messengerCompositionTypes.ts`: + +- `MessengerNestedView` +- `MessengerConversationId` +- `MessengerCompositionControlProps` + +These are the single source of truth for messenger composition control props. + +## Previous Mismatch Points (Removed) + +1. `MessengerContent` previously passed `activeConversationId` to `OpencomMessenger` with `as any`. +2. `MessengerContent` previously passed `onConversationChange` using `as any` because a raw state setter (`Dispatch>`) did not match the callback signature. +3. Public `OpencomRef.presentConversation(conversationId: string)` uses string IDs while messenger internals use Convex `Id<"conversations">`. + +## Adapter Rule + +Compatibility transforms must be explicit: + +- Use `toMessengerConversationId(...)` to convert public/string IDs to internal messenger IDs. +- Do not use broad casts (`as any`) in composition paths to bridge these differences. + +## Ownership + +- `messengerCompositionTypes.ts` owns composition control interfaces and ID adapters. +- `MessengerContent.tsx` owns wiring/orchestration between tabs and composed messenger/help views. +- `OpencomMessenger.tsx` owns runtime list/detail state behavior within the canonical control contract. + +If composition props evolve, update the canonical type module first, then update both caller and callee in the same change. diff --git a/packages/react-native-sdk/src/components/messengerCompositionTypes.ts b/packages/react-native-sdk/src/components/messengerCompositionTypes.ts new file mode 100644 index 0000000..459fdb2 --- /dev/null +++ b/packages/react-native-sdk/src/components/messengerCompositionTypes.ts @@ -0,0 +1,28 @@ +import type { Id } from "@opencom/convex/dataModel"; + +export type MessengerNestedView = "list" | "conversation"; +export type MessengerConversationId = Id<"conversations"> | null; +export type LegacyConversationId = string | null; + +export interface MessengerCompositionControlProps { + onViewChange?: (view: MessengerNestedView) => void; + controlledView?: MessengerNestedView; + activeConversationId?: MessengerConversationId; + onConversationChange?: (conversationId: MessengerConversationId) => void; +} + +// Adapter for external/public APIs that still surface conversation IDs as strings. +export function toMessengerConversationId( + conversationId: LegacyConversationId | undefined +): MessengerConversationId { + if (!conversationId) { + return null; + } + return conversationId as Id<"conversations">; +} + +export function toLegacyConversationId( + conversationId: MessengerConversationId +): LegacyConversationId { + return conversationId; +} diff --git a/packages/react-native-sdk/src/components/messengerSurveyModuleOwnership.md b/packages/react-native-sdk/src/components/messengerSurveyModuleOwnership.md new file mode 100644 index 0000000..0b0c077 --- /dev/null +++ b/packages/react-native-sdk/src/components/messengerSurveyModuleOwnership.md @@ -0,0 +1,44 @@ +# Messenger + Survey Container Modularity + +This document defines ownership for the decomposed RN SDK containers. + +## Messenger domains + +- `OpencomMessenger.tsx` + - Shell composition only. + - Chooses list/detail view and wires controller output to presentational views. +- `messenger/useMessengerShellController.ts` + - Owns shell-level state (`view`, `activeConversationId`) and controlled-view synchronization. +- `messenger/useConversationListController.ts` + - Owns conversation list data orchestration and new-conversation creation flow. +- `messenger/useConversationDetailController.ts` + - Owns message send flow, read-marking, email capture orchestration, and visitor identify mutation wiring. +- `messenger/messengerFlow.ts` + - Pure flow helpers (send normalization, timestamp formatting, view transitions, email-cue decisions). +- `messenger/ConversationListView.tsx` and `messenger/ConversationDetailView.tsx` + - Presentation-only rendering. +- `messenger/styles.ts` + - Shared style tokens for messenger presentation modules. + +## Survey domains + +- `OpencomSurvey.tsx` + - Shell composition only. + - Chooses intro/question/thank-you presentation from controller state. +- `survey/useSurveyController.ts` + - Owns survey progression orchestration, impression recording, submission flow, and dismiss/completion actions. +- `survey/surveyFlow.ts` + - Pure flow helpers for step transitions, required-answer gating, answer normalization, and submission state transitions. +- `survey/SurveyStepViews.tsx` and `survey/SurveyQuestionRenderer.tsx` + - Presentation-only rendering for step and question variants. +- `survey/styles.ts` + - Shared style tokens for survey presentation modules. +- `survey/types.ts` + - Survey domain type contracts used by runtime delivery hooks and components. + +## Extension guidance + +- New rendering variants belong in `SurveyQuestionRenderer` or dedicated presentational modules. +- New progression rules belong in `surveyFlow.ts` first, then consumed via `useSurveyController`. +- Messenger state transition changes belong in `messengerFlow.ts` and controller hooks, not in view components. +- Keep host-facing props and exported component names stable for `apps/mobile` and external RN SDK consumers. diff --git a/packages/react-native-sdk/src/components/survey/SurveyQuestionRenderer.tsx b/packages/react-native-sdk/src/components/survey/SurveyQuestionRenderer.tsx new file mode 100644 index 0000000..33dfd05 --- /dev/null +++ b/packages/react-native-sdk/src/components/survey/SurveyQuestionRenderer.tsx @@ -0,0 +1,334 @@ +import React from "react"; +import { View, Text, TouchableOpacity, TextInput } from "react-native"; +import type { SurveyQuestion } from "./types"; +import { surveyStyles } from "./styles"; + +interface SurveyQuestionRendererProps { + question: SurveyQuestion; + value: unknown; + onChange: (value: unknown) => void; + primaryColor: string; +} + +export function SurveyQuestionRenderer({ + question, + value, + onChange, + primaryColor, +}: SurveyQuestionRendererProps) { + switch (question.type) { + case "nps": + return ( + + ); + case "numeric_scale": + return ( + + ); + case "star_rating": + return ( + + ); + case "emoji_rating": + return ( + + ); + case "dropdown": + case "multiple_choice": + return ( + + ); + case "short_text": + return ( + + ); + case "long_text": + return ( + + ); + default: + return null; + } +} + +function NPSQuestion({ + value, + onChange, + primaryColor, +}: { + value: number | undefined; + onChange: (value: number) => void; + primaryColor: string; +}) { + return ( + + + Not likely + Very likely + + + {Array.from({ length: 11 }, (_, i) => ( + onChange(i)} + > + + {i} + + + ))} + + + ); +} + +function NumericScaleQuestion({ + value, + onChange, + options, + primaryColor, +}: { + value: number | undefined; + onChange: (value: number) => void; + options?: SurveyQuestion["options"]; + primaryColor: string; +}) { + const start = options?.scaleStart ?? 1; + const end = options?.scaleEnd ?? 5; + const range = Array.from({ length: end - start + 1 }, (_, i) => start + i); + + return ( + + + {options?.startLabel || String(start)} + {options?.endLabel || String(end)} + + + {range.map((valueInRange) => ( + onChange(valueInRange)} + > + + {valueInRange} + + + ))} + + + ); +} + +function StarRatingQuestion({ + value, + onChange, + options, + primaryColor, +}: { + value: number | undefined; + onChange: (value: number) => void; + options?: SurveyQuestion["options"]; + primaryColor: string; +}) { + return ( + + + {options?.starLabels?.low || ""} + {options?.starLabels?.high || ""} + + + {[1, 2, 3, 4, 5].map((star) => ( + onChange(star)}> + + ★ + + + ))} + + + ); +} + +function EmojiRatingQuestion({ + value, + onChange, + options, + primaryColor, +}: { + value: number | undefined; + onChange: (value: number) => void; + options?: SurveyQuestion["options"]; + primaryColor: string; +}) { + const count = options?.emojiCount ?? 5; + const emojis5 = ["😠", "😕", "😐", "🙂", "😄"]; + const emojis3 = ["😕", "😐", "🙂"]; + const emojis = count === 3 ? emojis3 : emojis5; + + return ( + + + {options?.emojiLabels?.low || ""} + {options?.emojiLabels?.high || ""} + + + {emojis.map((emoji, index) => ( + onChange(index + 1)} + > + {emoji} + + ))} + + + ); +} + +function MultipleChoiceQuestion({ + value, + onChange, + options, + primaryColor, +}: { + value: string | string[] | undefined; + onChange: (value: string | string[]) => void; + options?: SurveyQuestion["options"]; + primaryColor: string; +}) { + const allowMultiple = options?.allowMultiple ?? false; + const selectedValues = Array.isArray(value) ? value : value ? [value] : []; + + const handleSelect = (choice: string) => { + if (allowMultiple) { + if (selectedValues.includes(choice)) { + onChange(selectedValues.filter((existingValue) => existingValue !== choice)); + } else { + onChange([...selectedValues, choice]); + } + return; + } + onChange(choice); + }; + + return ( + + {options?.choices?.map((choice) => { + const isSelected = selectedValues.includes(choice); + return ( + handleSelect(choice)} + > + + {isSelected && ( + {allowMultiple ? "✓" : "●"} + )} + + {choice} + + ); + })} + + ); +} + +function TextQuestion({ + value, + onChange, + maxLength, + multiline, +}: { + value: string | undefined; + onChange: (value: string) => void; + maxLength: number; + multiline?: boolean; +}) { + return ( + + + + {(value || "").length}/{maxLength} + + + ); +} diff --git a/packages/react-native-sdk/src/components/survey/SurveyStepViews.tsx b/packages/react-native-sdk/src/components/survey/SurveyStepViews.tsx new file mode 100644 index 0000000..2a98a25 --- /dev/null +++ b/packages/react-native-sdk/src/components/survey/SurveyStepViews.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { View, Text, TouchableOpacity } from "react-native"; +import { SurveyQuestionRenderer } from "./SurveyQuestionRenderer"; +import type { SurveyQuestion } from "./types"; +import { surveyStyles } from "./styles"; + +interface SurveyIntroStepViewProps { + title: string; + description?: string; + buttonText?: string; + primaryColor: string; + onStart: () => Promise; +} + +export function SurveyIntroStepView({ + title, + description, + buttonText, + primaryColor, + onStart, +}: SurveyIntroStepViewProps) { + return ( + + {title} + {description && {description}} + + {buttonText || "Start"} + + + ); +} + +interface SurveyQuestionStepViewProps { + question: SurveyQuestion; + value: unknown; + currentIndex: number; + totalSteps: number; + canProceed: boolean; + isSubmitting: boolean; + primaryColor: string; + onAnswer: (value: unknown) => void; + onBack: () => void; + onNext: () => Promise; +} + +export function SurveyQuestionStepView({ + question, + value, + currentIndex, + totalSteps, + canProceed, + isSubmitting, + primaryColor, + onAnswer, + onBack, + onNext, +}: SurveyQuestionStepViewProps) { + return ( + + + + {question.title} + {question.required && *} + + {question.description && {question.description}} + + + + + + + + {currentIndex > 0 && ( + + Back + + )} + + + {isSubmitting ? "..." : currentIndex === totalSteps - 1 ? "Submit" : "Next"} + + + + + ); +} + +interface SurveyThankYouStepViewProps { + title: string; + description?: string; + buttonText?: string; + primaryColor: string; + onComplete?: () => void; +} + +export function SurveyThankYouStepView({ + title, + description, + buttonText, + primaryColor, + onComplete, +}: SurveyThankYouStepViewProps) { + return ( + + {title} + {description && {description}} + + {buttonText || "Done"} + + + ); +} diff --git a/packages/react-native-sdk/src/components/survey/styles.ts b/packages/react-native-sdk/src/components/survey/styles.ts new file mode 100644 index 0000000..e573fc5 --- /dev/null +++ b/packages/react-native-sdk/src/components/survey/styles.ts @@ -0,0 +1,246 @@ +import { StyleSheet } from "react-native"; + +export const surveyStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#FFFFFF", + }, + dismissButton: { + position: "absolute", + top: 16, + right: 16, + zIndex: 10, + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "rgba(0, 0, 0, 0.1)", + justifyContent: "center", + alignItems: "center", + }, + dismissText: { + color: "#374151", + fontSize: 18, + }, + progressContainer: { + height: 4, + backgroundColor: "#E5E7EB", + marginHorizontal: 24, + marginTop: 60, + borderRadius: 2, + overflow: "hidden", + }, + progressBar: { + height: "100%", + borderRadius: 2, + }, + content: { + flex: 1, + }, + contentContainer: { + flexGrow: 1, + justifyContent: "center", + padding: 24, + }, + stepContainer: { + alignItems: "center", + }, + thankYouContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 24, + }, + title: { + fontSize: 24, + fontWeight: "700", + color: "#111827", + textAlign: "center", + marginBottom: 12, + }, + description: { + fontSize: 16, + color: "#6B7280", + textAlign: "center", + lineHeight: 24, + marginBottom: 24, + }, + questionHeader: { + width: "100%", + marginBottom: 24, + }, + questionTitle: { + fontSize: 18, + fontWeight: "600", + color: "#111827", + textAlign: "center", + marginBottom: 8, + }, + questionDescription: { + fontSize: 14, + color: "#6B7280", + textAlign: "center", + }, + required: { + color: "#EF4444", + }, + questionContent: { + width: "100%", + marginBottom: 32, + }, + actions: { + flexDirection: "row", + gap: 12, + width: "100%", + }, + primaryButton: { + flex: 1, + paddingVertical: 14, + paddingHorizontal: 24, + borderRadius: 8, + alignItems: "center", + }, + fullWidthButton: { + flex: 1, + }, + primaryButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#FFFFFF", + }, + secondaryButton: { + flex: 1, + paddingVertical: 14, + paddingHorizontal: 24, + borderRadius: 8, + alignItems: "center", + backgroundColor: "#F3F4F6", + }, + secondaryButtonText: { + fontSize: 16, + fontWeight: "600", + }, + scaleContainer: { + width: "100%", + }, + scaleLabels: { + flexDirection: "row", + justifyContent: "space-between", + marginBottom: 8, + }, + scaleLabel: { + fontSize: 12, + color: "#6B7280", + }, + scaleButtons: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + justifyContent: "center", + }, + scaleButton: { + minWidth: 36, + height: 36, + borderRadius: 6, + borderWidth: 1, + borderColor: "#E5E7EB", + justifyContent: "center", + alignItems: "center", + backgroundColor: "#FFFFFF", + }, + scaleButtonText: { + fontSize: 14, + color: "#374151", + }, + scaleButtonTextSelected: { + color: "#FFFFFF", + }, + starsContainer: { + width: "100%", + }, + starsRow: { + flexDirection: "row", + justifyContent: "center", + gap: 8, + }, + starButton: { + padding: 4, + }, + starIcon: { + fontSize: 36, + }, + emojiContainer: { + width: "100%", + }, + emojiRow: { + flexDirection: "row", + justifyContent: "center", + gap: 12, + }, + emojiButton: { + padding: 12, + borderRadius: 8, + borderWidth: 2, + borderColor: "transparent", + }, + emoji: { + fontSize: 32, + }, + choicesContainer: { + width: "100%", + gap: 8, + }, + choiceButton: { + flexDirection: "row", + alignItems: "center", + padding: 14, + borderRadius: 8, + borderWidth: 1, + borderColor: "#E5E7EB", + backgroundColor: "#FFFFFF", + }, + choiceIndicator: { + width: 20, + height: 20, + borderWidth: 2, + borderColor: "#D1D5DB", + justifyContent: "center", + alignItems: "center", + marginRight: 12, + }, + radio: { + borderRadius: 10, + }, + checkbox: { + borderRadius: 4, + }, + checkmark: { + color: "#FFFFFF", + fontSize: 12, + fontWeight: "700", + }, + choiceText: { + fontSize: 14, + color: "#374151", + }, + textContainer: { + width: "100%", + }, + textInput: { + borderWidth: 1, + borderColor: "#E5E7EB", + borderRadius: 8, + padding: 12, + fontSize: 14, + color: "#111827", + }, + textInputMultiline: { + height: 100, + textAlignVertical: "top", + }, + charCount: { + fontSize: 12, + color: "#9CA3AF", + textAlign: "right", + marginTop: 4, + }, +}); diff --git a/packages/react-native-sdk/src/components/survey/surveyFlow.ts b/packages/react-native-sdk/src/components/survey/surveyFlow.ts new file mode 100644 index 0000000..731dee8 --- /dev/null +++ b/packages/react-native-sdk/src/components/survey/surveyFlow.ts @@ -0,0 +1,141 @@ +import type { Survey, SurveyQuestion } from "./types"; + +export type SurveySubmissionValue = string | number | boolean | string[] | number[] | null; + +export interface SurveyFlowState { + currentIndex: number; + totalSteps: number; + isIntroStep: boolean; + isQuestionStep: boolean; + currentQuestion: SurveyQuestion | null; +} + +export interface SurveySubmissionState { + isSubmitting: boolean; + submitError: string | null; + showThankYou: boolean; +} + +export type NextSurveyAction = + | { type: "advance"; nextIndex: number } + | { type: "submit" } + | { type: "noop" }; + +export function getInitialSurveyIndex(survey: Pick): number { + return survey.introStep ? -1 : 0; +} + +export function getSurveyFlowState(survey: Survey, currentIndex: number): SurveyFlowState { + const totalSteps = survey.questions.length; + const isIntroStep = currentIndex === -1; + const isQuestionStep = currentIndex >= 0 && currentIndex < totalSteps; + const currentQuestion = isQuestionStep ? survey.questions[currentIndex] : null; + + return { + currentIndex, + totalSteps, + isIntroStep, + isQuestionStep, + currentQuestion, + }; +} + +export function canProceedFromQuestion( + question: SurveyQuestion | null, + answers: Record +): boolean { + if (!question?.required) { + return true; + } + return answers[question.id] !== undefined; +} + +export function getNextSurveyAction( + state: SurveyFlowState, + answers: Record +): NextSurveyAction { + if (state.isIntroStep) { + return { type: "advance", nextIndex: 0 }; + } + + if (state.currentQuestion?.required && answers[state.currentQuestion.id] === undefined) { + return { type: "noop" }; + } + + if (state.currentIndex < state.totalSteps - 1) { + return { type: "advance", nextIndex: state.currentIndex + 1 }; + } + + return { type: "submit" }; +} + +export function getPreviousSurveyIndex( + currentIndex: number, + hasIntroStep: boolean +): number | null { + const minimumIndex = hasIntroStep ? -1 : 0; + if (currentIndex <= minimumIndex) { + return null; + } + return currentIndex - 1; +} + +function isSurveyAnswerPrimitive(value: unknown): value is string | number | boolean | null { + return ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +export function normalizeSurveyAnswerValue(value: unknown): SurveySubmissionValue { + if (isSurveyAnswerPrimitive(value)) { + return value; + } + + if (Array.isArray(value)) { + if (value.every((entry) => typeof entry === "string")) { + return value; + } + if (value.every((entry) => typeof entry === "number")) { + return value; + } + } + + return null; +} + +export function beginSubmission( + currentState: SurveySubmissionState +): SurveySubmissionState { + return { + ...currentState, + isSubmitting: true, + submitError: null, + }; +} + +export function completeSubmission( + currentState: SurveySubmissionState, + hasThankYouStep: boolean +): SurveySubmissionState { + return { + ...currentState, + isSubmitting: false, + submitError: null, + showThankYou: hasThankYouStep, + }; +} + +export function failSubmission( + currentState: SurveySubmissionState, + error: unknown +): SurveySubmissionState { + const message = error instanceof Error ? error.message : "Failed to submit survey"; + return { + ...currentState, + isSubmitting: false, + submitError: message, + }; +} diff --git a/packages/react-native-sdk/src/components/survey/types.ts b/packages/react-native-sdk/src/components/survey/types.ts new file mode 100644 index 0000000..9cc671a --- /dev/null +++ b/packages/react-native-sdk/src/components/survey/types.ts @@ -0,0 +1,51 @@ +import type { ViewStyle } from "react-native"; +import type { Id } from "@opencom/convex/dataModel"; + +export type QuestionType = + | "nps" + | "numeric_scale" + | "star_rating" + | "emoji_rating" + | "dropdown" + | "short_text" + | "long_text" + | "multiple_choice"; + +export interface SurveyQuestion { + id: string; + type: QuestionType; + title: string; + description?: string; + required: boolean; + storeAsAttribute?: string; + options?: { + scaleStart?: number; + scaleEnd?: number; + startLabel?: string; + endLabel?: string; + starLabels?: { low?: string; high?: string }; + emojiCount?: 3 | 5; + emojiLabels?: { low?: string; high?: string }; + choices?: string[]; + allowMultiple?: boolean; + }; +} + +export interface Survey { + _id: Id<"surveys">; + name: string; + format: "small" | "large"; + questions: SurveyQuestion[]; + introStep?: { title: string; description?: string; buttonText?: string }; + thankYouStep?: { title: string; description?: string; buttonText?: string }; + showProgressBar?: boolean; + showDismissButton?: boolean; +} + +export interface OpencomSurveyProps { + survey: Survey; + onDismiss?: () => void; + onComplete?: () => void; + style?: ViewStyle; + primaryColor?: string; +} diff --git a/packages/react-native-sdk/src/components/survey/useSurveyController.ts b/packages/react-native-sdk/src/components/survey/useSurveyController.ts new file mode 100644 index 0000000..262b965 --- /dev/null +++ b/packages/react-native-sdk/src/components/survey/useSurveyController.ts @@ -0,0 +1,161 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation } from "convex/react"; +import { api } from "@opencom/convex"; +import { OpencomSDK } from "../../OpencomSDK"; +import type { OpencomSurveyProps } from "./types"; +import { + beginSubmission, + canProceedFromQuestion, + completeSubmission, + failSubmission, + getInitialSurveyIndex, + getNextSurveyAction, + getPreviousSurveyIndex, + getSurveyFlowState, + normalizeSurveyAnswerValue, + type SurveySubmissionState, +} from "./surveyFlow"; + +export function useSurveyController({ survey, onDismiss, onComplete }: OpencomSurveyProps) { + const [currentIndex, setCurrentIndex] = useState(() => getInitialSurveyIndex(survey)); + const [answers, setAnswers] = useState>({}); + const [submissionState, setSubmissionState] = useState({ + isSubmitting: false, + submitError: null, + showThankYou: false, + }); + + const submitResponse = useMutation(api.surveys.submitResponse); + const recordImpression = useMutation(api.surveys.recordImpression); + + const flowState = useMemo(() => getSurveyFlowState(survey, currentIndex), [survey, currentIndex]); + const canProceed = canProceedFromQuestion(flowState.currentQuestion, answers); + + useEffect(() => { + const state = OpencomSDK.getVisitorState(); + if (state.visitorId && state.sessionToken) { + recordImpression({ + surveyId: survey._id, + visitorId: state.visitorId, + sessionId: state.sessionId, + sessionToken: state.sessionToken, + action: "shown", + }); + } + }, [recordImpression, survey._id]); + + const answerQuestion = (value: unknown) => { + const question = flowState.currentQuestion; + if (!question) { + return; + } + setAnswers((previousAnswers) => ({ ...previousAnswers, [question.id]: value })); + }; + + const submitSurvey = async () => { + setSubmissionState((currentState) => beginSubmission(currentState)); + const state = OpencomSDK.getVisitorState(); + + if (!state.visitorId || !state.sessionToken) { + setSubmissionState((currentState) => ({ + ...currentState, + isSubmitting: false, + })); + return; + } + + try { + const answerArray = Object.entries(answers).map(([questionId, value]) => ({ + questionId, + value: normalizeSurveyAnswerValue(value), + })); + + await submitResponse({ + surveyId: survey._id, + visitorId: state.visitorId, + sessionId: state.sessionId, + sessionToken: state.sessionToken, + answers: answerArray, + isComplete: true, + }); + + await recordImpression({ + surveyId: survey._id, + visitorId: state.visitorId, + sessionId: state.sessionId, + sessionToken: state.sessionToken, + action: "completed", + }); + + const hasThankYouStep = Boolean(survey.thankYouStep); + setSubmissionState((currentState) => completeSubmission(currentState, hasThankYouStep)); + if (!hasThankYouStep) { + onComplete?.(); + } + } catch (error) { + console.error("Failed to submit survey:", error); + setSubmissionState((currentState) => failSubmission(currentState, error)); + } + }; + + const handleNext = async () => { + const state = OpencomSDK.getVisitorState(); + const nextAction = getNextSurveyAction(flowState, answers); + + if (nextAction.type === "noop") { + return; + } + + if (flowState.isIntroStep && state.visitorId && state.sessionToken) { + recordImpression({ + surveyId: survey._id, + visitorId: state.visitorId, + sessionId: state.sessionId, + sessionToken: state.sessionToken, + action: "started", + }); + } + + if (nextAction.type === "advance") { + setCurrentIndex(nextAction.nextIndex); + return; + } + + await submitSurvey(); + }; + + const handleBack = () => { + const previousIndex = getPreviousSurveyIndex(currentIndex, Boolean(survey.introStep)); + if (previousIndex === null) { + return; + } + setCurrentIndex(previousIndex); + }; + + const handleDismiss = async () => { + const state = OpencomSDK.getVisitorState(); + if (state.visitorId && state.sessionToken) { + await recordImpression({ + surveyId: survey._id, + visitorId: state.visitorId, + sessionId: state.sessionId, + sessionToken: state.sessionToken, + action: "dismissed", + }); + } + onDismiss?.(); + }; + + return { + ...flowState, + answers, + canProceed, + isSubmitting: submissionState.isSubmitting, + showThankYou: submissionState.showThankYou, + submitError: submissionState.submitError, + answerQuestion, + handleNext, + handleBack, + handleDismiss, + }; +} diff --git a/packages/react-native-sdk/src/opencomSdk/contracts.ts b/packages/react-native-sdk/src/opencomSdk/contracts.ts new file mode 100644 index 0000000..679fae3 --- /dev/null +++ b/packages/react-native-sdk/src/opencomSdk/contracts.ts @@ -0,0 +1,33 @@ +import type { SDKConfig, UserIdentification, VisitorId } from "@opencom/sdk-core"; +import type { OpencomStorageAdapter } from "./storageService"; + +export interface SessionServiceContract { + initializeSession(config: SDKConfig): Promise; + identifyUser(user: UserIdentification): Promise; + logoutSession(): Promise; +} + +export interface StorageServiceContract { + getStorageAdapter(): OpencomStorageAdapter; + getOrCreateSessionId(): Promise; + persistSessionId(sessionId: string): Promise; + clearPersistedSessionId(): Promise; + persistVisitorId(visitorId: string): Promise; + clearPersistedVisitorId(): Promise; + persistSessionToken(token: string, expiresAt: number): Promise; + clearPersistedSessionToken(): Promise; +} + +export interface PushServiceContract { + registerForPush(): Promise; + unregisterFromPush(): Promise; +} + +export interface LifecycleServiceContract { + scheduleRefresh(expiresAt: number): void; + stopRefreshTimer(): void; + startHeartbeat(visitorId: VisitorId): void; + stopHeartbeat(): void; + setupAppStateListener(visitorId: VisitorId, sessionId: string): void; + cleanupAppStateListener(): void; +} diff --git a/packages/react-native-sdk/src/opencomSdk/lifecycleService.ts b/packages/react-native-sdk/src/opencomSdk/lifecycleService.ts new file mode 100644 index 0000000..ebd4cc4 --- /dev/null +++ b/packages/react-native-sdk/src/opencomSdk/lifecycleService.ts @@ -0,0 +1,121 @@ +import { AppState, type AppStateStatus, Platform } from "react-native"; +import { + refreshSession as refreshSessionApi, + setSessionExpiresAt, + setSessionToken, + getVisitorState, + heartbeat, + trackAutoEvent as trackAutoEventApi, + type VisitorId, +} from "@opencom/sdk-core"; +import { persistSessionToken } from "./storageService"; +import { opencomSDKState } from "./state"; +import type { LifecycleServiceContract } from "./contracts"; + +const HEARTBEAT_INTERVAL_MS = 30000; // 30 seconds +const REFRESH_MARGIN_MS = 60000; // refresh 60s before expiry + +export function scheduleRefresh(expiresAt: number): void { + stopRefreshTimer(); + + const delay = Math.max(0, expiresAt - Date.now() - REFRESH_MARGIN_MS); + opencomSDKState.refreshTimer = setTimeout(async () => { + const state = getVisitorState(); + if (!state.sessionToken) return; + try { + const result = await refreshSessionApi({ sessionToken: state.sessionToken }); + setSessionToken(result.sessionToken); + setSessionExpiresAt(result.expiresAt); + await persistSessionToken(result.sessionToken, result.expiresAt); + scheduleRefresh(result.expiresAt); + } catch (error) { + console.error("[OpencomSDK] Session refresh failed:", error); + } + }, delay); +} + +export function stopRefreshTimer(): void { + if (opencomSDKState.refreshTimer) { + clearTimeout(opencomSDKState.refreshTimer); + opencomSDKState.refreshTimer = null; + } +} + +export function startHeartbeat(visitorId: VisitorId): void { + stopHeartbeat(); + + // Send initial heartbeat + const state = getVisitorState(); + heartbeat(visitorId, state.sessionToken ?? undefined).catch(console.error); + + // Set up interval + opencomSDKState.heartbeatInterval = setInterval(() => { + const s = getVisitorState(); + heartbeat(visitorId, s.sessionToken ?? undefined).catch(console.error); + }, HEARTBEAT_INTERVAL_MS); +} + +export function stopHeartbeat(): void { + if (opencomSDKState.heartbeatInterval) { + clearInterval(opencomSDKState.heartbeatInterval); + opencomSDKState.heartbeatInterval = null; + } +} + +export function setupAppStateListener(visitorId: VisitorId, sessionId: string): void { + cleanupAppStateListener(); + + let lastAppState: AppStateStatus = AppState.currentState; + + opencomSDKState.appStateSubscription = AppState.addEventListener( + "change", + (nextAppState: AppStateStatus) => { + // App coming to foreground from background + if (lastAppState.match(/inactive|background/) && nextAppState === "active") { + const s = getVisitorState(); + trackAutoEventApi({ + visitorId, + sessionToken: s.sessionToken ?? undefined, + eventType: "session_start", + sessionId, + properties: { + platform: Platform.OS, + resumedFromBackground: true, + }, + }).catch(console.error); + } + + // App going to background + if (lastAppState === "active" && nextAppState.match(/inactive|background/)) { + const s = getVisitorState(); + trackAutoEventApi({ + visitorId, + sessionToken: s.sessionToken ?? undefined, + eventType: "session_end", + sessionId, + properties: { + platform: Platform.OS, + }, + }).catch(console.error); + } + + lastAppState = nextAppState; + } + ); +} + +export function cleanupAppStateListener(): void { + if (opencomSDKState.appStateSubscription) { + opencomSDKState.appStateSubscription.remove(); + opencomSDKState.appStateSubscription = null; + } +} + +export const lifecycleService: LifecycleServiceContract = { + scheduleRefresh, + stopRefreshTimer, + startHeartbeat, + stopHeartbeat, + setupAppStateListener, + cleanupAppStateListener, +}; diff --git a/packages/react-native-sdk/src/opencomSdk/moduleOwnership.md b/packages/react-native-sdk/src/opencomSdk/moduleOwnership.md new file mode 100644 index 0000000..10c499a --- /dev/null +++ b/packages/react-native-sdk/src/opencomSdk/moduleOwnership.md @@ -0,0 +1,37 @@ +# RN SDK Orchestrator Module Ownership + +This directory owns internal orchestration concerns for `OpencomSDK` while keeping the public API in +`src/OpencomSDK.ts` stable. + +## Module boundaries + +- `state.ts` + - Owns shared mutable orchestrator state (`isSDKInitialized`, timers, app-state subscription, survey listeners). + - Provides `resetOpencomSDKState()` for deterministic teardown. +- `storageService.ts` + - Owns session/visitor/session-token persistence keys and storage adapter access. + - No lifecycle or push behavior. +- `lifecycleService.ts` + - Owns heartbeat scheduling, refresh timer scheduling, and app foreground/background hooks. + - Depends on sdk-core runtime state and storage persistence helpers. +- `sessionService.ts` + - Owns initialize/identify/logout orchestration flow and delegates timer/listener work to lifecycle service. + - No UI/event presentation concerns. +- `pushService.ts` + - Owns push registration gate behavior for initialization checks and delegates provider logic to `src/push.ts`. + +## Facade contract + +- `OpencomSDK.ts` is the only public orchestrator facade. +- Host apps (`apps/mobile`) and shared consumers (`sdk-core`, RN components) must keep using `OpencomSDK` APIs. +- Internal module changes must not require public API signature changes. + +## Extension guidance + +- Put new persistence concerns in `storageService.ts`. +- Put new timers/app-state hooks in `lifecycleService.ts`. +- Put new boot/session transitions in `sessionService.ts`. +- Keep `OpencomSDK.ts` focused on: + - input validation + - public guardrails/warnings + - facade-level event routing diff --git a/packages/react-native-sdk/src/opencomSdk/pushService.ts b/packages/react-native-sdk/src/opencomSdk/pushService.ts new file mode 100644 index 0000000..3055b76 --- /dev/null +++ b/packages/react-native-sdk/src/opencomSdk/pushService.ts @@ -0,0 +1,30 @@ +import { registerForPushNotifications, unregisterPushNotifications } from "../push"; +import { opencomSDKState } from "./state"; +import type { PushServiceContract } from "./contracts"; + +function warnIfNotInitialized(): boolean { + if (!opencomSDKState.isSDKInitialized) { + console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + return true; + } + return false; +} + +export async function registerForPush(): Promise { + if (warnIfNotInitialized()) { + return null; + } + return registerForPushNotifications(); +} + +export async function unregisterFromPush(): Promise { + if (warnIfNotInitialized()) { + return false; + } + return unregisterPushNotifications(); +} + +export const pushService: PushServiceContract = { + registerForPush, + unregisterFromPush, +}; diff --git a/packages/react-native-sdk/src/opencomSdk/sessionService.ts b/packages/react-native-sdk/src/opencomSdk/sessionService.ts new file mode 100644 index 0000000..1a7b077 --- /dev/null +++ b/packages/react-native-sdk/src/opencomSdk/sessionService.ts @@ -0,0 +1,183 @@ +import { Platform } from "react-native"; +import { + initializeClient, + setStorageAdapter, + bootSession as bootSessionApi, + identifyVisitor as identifyVisitorApi, + trackAutoEvent as trackAutoEventApi, + revokeSession as revokeSessionApi, + setVisitorId, + setSessionToken, + setSessionExpiresAt, + clearSessionToken, + setSessionId, + setUser, + clearUser, + getVisitorState, + generateSessionId, + emitEvent, + type DeviceInfo, + type SDKConfig, + type UserIdentification, + type VisitorId, +} from "@opencom/sdk-core"; +import { + clearPersistedSessionId, + clearPersistedSessionToken, + clearPersistedVisitorId, + getOrCreateSessionId, + getStorageAdapter, + persistSessionId, + persistSessionToken, + persistVisitorId, +} from "./storageService"; +import type { SessionServiceContract } from "./contracts"; +import { + scheduleRefresh, + setupAppStateListener, + startHeartbeat, + stopHeartbeat, + stopRefreshTimer, +} from "./lifecycleService"; +import { opencomSDKState } from "./state"; + +const REACT_NATIVE_SDK_VERSION = "0.1.0"; + +function getDeviceInfo(): DeviceInfo { + return { + os: Platform.OS, + platform: Platform.OS as "ios" | "android", + deviceType: "mobile", + }; +} + +export async function initializeSession(config: SDKConfig): Promise { + if (opencomSDKState.isSDKInitialized) { + console.warn("[OpencomSDK] SDK already initialized"); + return; + } + + const storage = getStorageAdapter(); + setStorageAdapter({ + getItem: (key) => storage.getItem(key), + setItem: (key, value) => storage.setItem(key, value), + removeItem: (key) => storage.removeItem(key), + }); + + initializeClient(config); + + const sessionId = await getOrCreateSessionId(); + setSessionId(sessionId); + + const device = getDeviceInfo(); + const bootResult = await bootSessionApi({ + sessionId, + device, + clientType: "mobile_sdk", + clientVersion: REACT_NATIVE_SDK_VERSION, + clientIdentifier: "@opencom/react-native-sdk", + }); + + const visitorId = bootResult.visitor._id as VisitorId; + setVisitorId(visitorId); + setSessionToken(bootResult.sessionToken); + setSessionExpiresAt(bootResult.expiresAt); + await persistVisitorId(visitorId); + await persistSessionToken(bootResult.sessionToken, bootResult.expiresAt); + emitEvent("visitor_created", { visitorId }); + + scheduleRefresh(bootResult.expiresAt); + startHeartbeat(visitorId); + + if (!opencomSDKState.sessionStartTracked) { + opencomSDKState.sessionStartTracked = true; + trackAutoEventApi({ + visitorId, + sessionToken: bootResult.sessionToken, + eventType: "session_start", + sessionId, + properties: { + platform: Platform.OS, + }, + }).catch(console.error); + } + + setupAppStateListener(visitorId, sessionId); + opencomSDKState.isSDKInitialized = true; + + if (config.debug) { + console.log("[OpencomSDK] Initialized successfully"); + } +} + +export async function identifyUser(user: UserIdentification): Promise { + if (!opencomSDKState.isSDKInitialized) { + console.warn("[OpencomSDK] SDK not initialized. Call initialize() first."); + return; + } + + const state = getVisitorState(); + if (!state.visitorId) { + console.warn("[OpencomSDK] No visitor ID available"); + return; + } + + setUser(user); + + await identifyVisitorApi({ + visitorId: state.visitorId, + sessionToken: state.sessionToken ?? undefined, + user, + device: getDeviceInfo(), + }); + + emitEvent("visitor_identified", { user }); +} + +export async function logoutSession(): Promise { + if (!opencomSDKState.isSDKInitialized) { + return; + } + + stopHeartbeat(); + stopRefreshTimer(); + clearUser(); + + const state = getVisitorState(); + if (state.sessionToken) { + revokeSessionApi({ sessionToken: state.sessionToken }).catch(console.error); + } + clearSessionToken(); + + await clearPersistedSessionId(); + await clearPersistedVisitorId(); + await clearPersistedSessionToken(); + + const newSessionId = generateSessionId(); + await persistSessionId(newSessionId); + setSessionId(newSessionId); + + const device = getDeviceInfo(); + const bootResult = await bootSessionApi({ + sessionId: newSessionId, + device, + clientType: "mobile_sdk", + clientVersion: REACT_NATIVE_SDK_VERSION, + clientIdentifier: "@opencom/react-native-sdk", + }); + + const visitorId = bootResult.visitor._id as VisitorId; + setVisitorId(visitorId); + setSessionToken(bootResult.sessionToken); + setSessionExpiresAt(bootResult.expiresAt); + await persistVisitorId(visitorId); + await persistSessionToken(bootResult.sessionToken, bootResult.expiresAt); + scheduleRefresh(bootResult.expiresAt); + startHeartbeat(visitorId); +} + +export const sessionService: SessionServiceContract = { + initializeSession, + identifyUser, + logoutSession, +}; diff --git a/packages/react-native-sdk/src/opencomSdk/state.ts b/packages/react-native-sdk/src/opencomSdk/state.ts new file mode 100644 index 0000000..1904b35 --- /dev/null +++ b/packages/react-native-sdk/src/opencomSdk/state.ts @@ -0,0 +1,26 @@ +export interface OpencomSDKOrchestratorState { + isSDKInitialized: boolean; + heartbeatInterval: ReturnType | null; + refreshTimer: ReturnType | null; + appStateSubscription: { remove: () => void } | null; + sessionStartTracked: boolean; + surveyTriggerListeners: Set<(eventName: string) => void>; +} + +export const opencomSDKState: OpencomSDKOrchestratorState = { + isSDKInitialized: false, + heartbeatInterval: null, + refreshTimer: null, + appStateSubscription: null, + sessionStartTracked: false, + surveyTriggerListeners: new Set<(eventName: string) => void>(), +}; + +export function resetOpencomSDKState(): void { + opencomSDKState.isSDKInitialized = false; + opencomSDKState.heartbeatInterval = null; + opencomSDKState.refreshTimer = null; + opencomSDKState.appStateSubscription = null; + opencomSDKState.sessionStartTracked = false; + opencomSDKState.surveyTriggerListeners.clear(); +} diff --git a/packages/react-native-sdk/src/opencomSdk/storageService.ts b/packages/react-native-sdk/src/opencomSdk/storageService.ts new file mode 100644 index 0000000..2a85719 --- /dev/null +++ b/packages/react-native-sdk/src/opencomSdk/storageService.ts @@ -0,0 +1,68 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as SecureStore from "expo-secure-store"; +import { generateSessionId } from "@opencom/sdk-core"; +import type { StorageServiceContract } from "./contracts"; + +export const SESSION_ID_KEY = "opencom_session_id"; +export const VISITOR_ID_KEY = "opencom_visitor_id"; +export const SESSION_TOKEN_KEY = "opencom_session_token"; +export const SESSION_EXPIRES_AT_KEY = "opencom_session_expires_at"; + +const storageAdapter = { + getItem: (key: string) => AsyncStorage.getItem(key), + setItem: (key: string, value: string) => AsyncStorage.setItem(key, value), + removeItem: (key: string) => AsyncStorage.removeItem(key), +}; + +export type OpencomStorageAdapter = typeof storageAdapter; + +export function getStorageAdapter(): OpencomStorageAdapter { + return storageAdapter; +} + +export async function getOrCreateSessionId(): Promise { + const storedSessionId = await storageAdapter.getItem(SESSION_ID_KEY); + if (storedSessionId) { + return storedSessionId; + } + const newSessionId = generateSessionId(); + await storageAdapter.setItem(SESSION_ID_KEY, newSessionId); + return newSessionId; +} + +export async function persistSessionId(sessionId: string): Promise { + await storageAdapter.setItem(SESSION_ID_KEY, sessionId); +} + +export async function clearPersistedSessionId(): Promise { + await storageAdapter.removeItem(SESSION_ID_KEY); +} + +export async function persistVisitorId(visitorId: string): Promise { + await storageAdapter.setItem(VISITOR_ID_KEY, visitorId); +} + +export async function clearPersistedVisitorId(): Promise { + await storageAdapter.removeItem(VISITOR_ID_KEY); +} + +export async function persistSessionToken(token: string, expiresAt: number): Promise { + await SecureStore.setItemAsync(SESSION_TOKEN_KEY, token); + await SecureStore.setItemAsync(SESSION_EXPIRES_AT_KEY, String(expiresAt)); +} + +export async function clearPersistedSessionToken(): Promise { + await SecureStore.deleteItemAsync(SESSION_TOKEN_KEY); + await SecureStore.deleteItemAsync(SESSION_EXPIRES_AT_KEY); +} + +export const storageService: StorageServiceContract = { + getStorageAdapter, + getOrCreateSessionId, + persistSessionId, + clearPersistedSessionId, + persistVisitorId, + clearPersistedVisitorId, + persistSessionToken, + clearPersistedSessionToken, +}; diff --git a/packages/react-native-sdk/tests/messengerCompositionContracts.test.ts b/packages/react-native-sdk/tests/messengerCompositionContracts.test.ts new file mode 100644 index 0000000..c1c6e5d --- /dev/null +++ b/packages/react-native-sdk/tests/messengerCompositionContracts.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + toLegacyConversationId, + toMessengerConversationId, +} from "../src/components/messengerCompositionTypes"; + +describe("messenger composition contracts", () => { + it("adapts legacy string IDs into messenger conversation IDs", () => { + const converted = toMessengerConversationId("conversation_123"); + + expect(converted).toBe("conversation_123"); + expect(toMessengerConversationId(null)).toBeNull(); + expect(toMessengerConversationId(undefined)).toBeNull(); + }); + + it("adapts messenger IDs back to legacy/public string IDs", () => { + expect(toLegacyConversationId(toMessengerConversationId("conversation_456"))).toBe( + "conversation_456" + ); + expect(toLegacyConversationId(null)).toBeNull(); + }); + + it("prevents broad cast escapes in messenger composition source files", () => { + const messengerContentSource = readFileSync( + new URL("../src/components/MessengerContent.tsx", import.meta.url), + "utf8" + ); + const opencomMessengerSource = readFileSync( + new URL("../src/components/OpencomMessenger.tsx", import.meta.url), + "utf8" + ); + + expect(messengerContentSource).not.toContain("as any"); + expect(opencomMessengerSource).not.toContain("as any"); + }); +}); diff --git a/packages/react-native-sdk/tests/messengerFlowParity.test.ts b/packages/react-native-sdk/tests/messengerFlowParity.test.ts new file mode 100644 index 0000000..2de4a73 --- /dev/null +++ b/packages/react-native-sdk/tests/messengerFlowParity.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { + createInitialMessengerShellState, + evaluateEmailCaptureDecision, + formatConversationTimestamp, + normalizeOutgoingMessage, + selectMessengerConversation, + shouldResetConversationOnControlledList, +} from "../src/components/messenger/messengerFlow"; + +describe("messenger flow parity", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps send flow normalization behavior", () => { + expect(normalizeOutgoingMessage(" hello ")).toBe("hello"); + expect(normalizeOutgoingMessage(" ")).toBeNull(); + }); + + it("keeps list-to-conversation view navigation behavior", () => { + const initial = createInitialMessengerShellState(undefined, undefined); + expect(initial.view).toBe("list"); + expect(initial.conversationId).toBeNull(); + + const next = selectMessengerConversation("conversation_123" as any, initial); + expect(next.view).toBe("conversation"); + expect(next.conversationId).toBe("conversation_123"); + }); + + it("keeps controlled list reset rule behavior", () => { + expect(shouldResetConversationOnControlledList("list", true)).toBe(true); + expect(shouldResetConversationOnControlledList("conversation", true)).toBe(false); + expect(shouldResetConversationOnControlledList("list", false)).toBe(false); + }); + + it("keeps AI email cue trigger behavior", () => { + const firstPrompt = evaluateEmailCaptureDecision({ + visitorId: "visitor_123", + hasVisitorSentMessage: true, + collectEmailEnabled: true, + showEmailCapture: false, + emailCaptured: false, + lastAgentMessageCount: 0, + agentMessageCount: 0, + }); + + expect(firstPrompt).toEqual({ + shouldOpenPrompt: true, + nextLastAgentMessageCount: 0, + }); + + const followUpPrompt = evaluateEmailCaptureDecision({ + visitorId: "visitor_123", + hasVisitorSentMessage: true, + collectEmailEnabled: true, + showEmailCapture: true, + emailCaptured: false, + lastAgentMessageCount: 1, + agentMessageCount: 2, + }); + + expect(followUpPrompt).toEqual({ + shouldOpenPrompt: true, + nextLastAgentMessageCount: 2, + }); + }); + + it("keeps conversation status timestamp formatting categories", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); + + const yesterday = formatConversationTimestamp(new Date("2026-03-04T12:00:00.000Z").getTime()); + expect(yesterday).toBe("Yesterday"); + + const sameDay = formatConversationTimestamp(new Date("2026-03-05T11:55:00.000Z").getTime()); + expect(sameDay.length).toBeGreaterThan(0); + expect(sameDay).not.toBe("Yesterday"); + }); +}); diff --git a/packages/react-native-sdk/tests/orchestratorModularityContracts.test.ts b/packages/react-native-sdk/tests/orchestratorModularityContracts.test.ts new file mode 100644 index 0000000..18a463a --- /dev/null +++ b/packages/react-native-sdk/tests/orchestratorModularityContracts.test.ts @@ -0,0 +1,76 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { opencomSDKState, resetOpencomSDKState } from "../src/opencomSdk/state"; + +describe("orchestrator modularity contracts", () => { + it("resets shared orchestrator state deterministically", () => { + opencomSDKState.isSDKInitialized = true; + opencomSDKState.sessionStartTracked = true; + opencomSDKState.heartbeatInterval = {} as ReturnType; + opencomSDKState.refreshTimer = {} as ReturnType; + opencomSDKState.appStateSubscription = { remove: () => undefined }; + opencomSDKState.surveyTriggerListeners.add(() => undefined); + + resetOpencomSDKState(); + + expect(opencomSDKState.isSDKInitialized).toBe(false); + expect(opencomSDKState.sessionStartTracked).toBe(false); + expect(opencomSDKState.heartbeatInterval).toBeNull(); + expect(opencomSDKState.refreshTimer).toBeNull(); + expect(opencomSDKState.appStateSubscription).toBeNull(); + expect(opencomSDKState.surveyTriggerListeners.size).toBe(0); + }); + + it("keeps OpencomSDK facade free of monolith-owned helpers and globals", () => { + const opencomSource = readFileSync(new URL("../src/OpencomSDK.ts", import.meta.url), "utf8"); + + expect(opencomSource).toContain('from "./opencomSdk/sessionService"'); + expect(opencomSource).toContain('from "./opencomSdk/storageService"'); + expect(opencomSource).toContain('from "./opencomSdk/lifecycleService"'); + expect(opencomSource).toContain('from "./opencomSdk/pushService"'); + + const legacyMonolithTokens = [ + "let isSDKInitialized", + "let heartbeatInterval", + "let refreshTimer", + "let appStateSubscription", + "let sessionStartTracked", + "function getOrCreateSessionId(", + "function persistSessionToken(", + "function scheduleRefresh(", + "function startHeartbeat(", + "function setupAppStateListener(", + ]; + + for (const token of legacyMonolithTokens) { + expect(opencomSource).not.toContain(token); + } + }); + + it("defines explicit internal service contracts", () => { + const contractsSource = readFileSync(new URL("../src/opencomSdk/contracts.ts", import.meta.url), "utf8"); + const lifecycleSource = readFileSync( + new URL("../src/opencomSdk/lifecycleService.ts", import.meta.url), + "utf8" + ); + const pushSource = readFileSync(new URL("../src/opencomSdk/pushService.ts", import.meta.url), "utf8"); + const sessionSource = readFileSync( + new URL("../src/opencomSdk/sessionService.ts", import.meta.url), + "utf8" + ); + const storageSource = readFileSync( + new URL("../src/opencomSdk/storageService.ts", import.meta.url), + "utf8" + ); + + expect(contractsSource).toContain("export interface SessionServiceContract"); + expect(contractsSource).toContain("export interface StorageServiceContract"); + expect(contractsSource).toContain("export interface PushServiceContract"); + expect(contractsSource).toContain("export interface LifecycleServiceContract"); + + expect(sessionSource).toContain("export const sessionService: SessionServiceContract"); + expect(storageSource).toContain("export const storageService: StorageServiceContract"); + expect(pushSource).toContain("export const pushService: PushServiceContract"); + expect(lifecycleSource).toContain("export const lifecycleService: LifecycleServiceContract"); + }); +}); diff --git a/packages/react-native-sdk/tests/surveyFlowParity.test.ts b/packages/react-native-sdk/tests/surveyFlowParity.test.ts new file mode 100644 index 0000000..99037d4 --- /dev/null +++ b/packages/react-native-sdk/tests/surveyFlowParity.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + beginSubmission, + canProceedFromQuestion, + completeSubmission, + failSubmission, + getInitialSurveyIndex, + getNextSurveyAction, + getSurveyFlowState, + normalizeSurveyAnswerValue, +} from "../src/components/survey/surveyFlow"; +import type { Survey } from "../src/components/survey/types"; + +const baseSurvey: Survey = { + _id: "survey_1" as any, + name: "Test survey", + format: "small", + introStep: { + title: "Welcome", + }, + questions: [ + { + id: "q1", + type: "short_text", + title: "Required question", + required: true, + }, + { + id: "q2", + type: "nps", + title: "Optional follow-up", + required: false, + }, + ], +}; + +describe("survey flow parity", () => { + it("keeps intro and question step transition behavior", () => { + const initialIndex = getInitialSurveyIndex(baseSurvey); + expect(initialIndex).toBe(-1); + + const introState = getSurveyFlowState(baseSurvey, initialIndex); + expect(getNextSurveyAction(introState, {})).toEqual({ type: "advance", nextIndex: 0 }); + + const questionState = getSurveyFlowState(baseSurvey, 0); + expect(canProceedFromQuestion(questionState.currentQuestion, {})).toBe(false); + expect(getNextSurveyAction(questionState, {})).toEqual({ type: "noop" }); + + const answered = { q1: "Yes" }; + expect(canProceedFromQuestion(questionState.currentQuestion, answered)).toBe(true); + expect(getNextSurveyAction(questionState, answered)).toEqual({ type: "advance", nextIndex: 1 }); + + const finalState = getSurveyFlowState(baseSurvey, 1); + expect(getNextSurveyAction(finalState, answered)).toEqual({ type: "submit" }); + }); + + it("keeps answer normalization behavior", () => { + expect(normalizeSurveyAnswerValue("text")).toBe("text"); + expect(normalizeSurveyAnswerValue(4)).toBe(4); + expect(normalizeSurveyAnswerValue(true)).toBe(true); + expect(normalizeSurveyAnswerValue(["a", "b"])).toEqual(["a", "b"]); + expect(normalizeSurveyAnswerValue([1, 2])).toEqual([1, 2]); + expect(normalizeSurveyAnswerValue({ nested: true })).toBeNull(); + }); + + it("keeps submission completion and retry/error state behavior", () => { + const started = beginSubmission({ + isSubmitting: false, + submitError: "previous error", + showThankYou: false, + }); + expect(started).toEqual({ + isSubmitting: true, + submitError: null, + showThankYou: false, + }); + + const failed = failSubmission(started, new Error("Network unavailable")); + expect(failed).toEqual({ + isSubmitting: false, + submitError: "Network unavailable", + showThankYou: false, + }); + + const retry = beginSubmission(failed); + expect(retry.submitError).toBeNull(); + expect(retry.isSubmitting).toBe(true); + + const completed = completeSubmission(retry, true); + expect(completed).toEqual({ + isSubmitting: false, + submitError: null, + showThankYou: true, + }); + }); +}); From 8b3eab648cd52956bdc630a7f11aae8515b95846 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 18:31:31 +0000 Subject: [PATCH 15/91] centralize-visitor-readable-id-generator --- .../src/app/settings/HomeSettingsSection.tsx | 117 ++-- .../src/lib/__tests__/visitorIdentity.test.ts | 75 +++ apps/web/src/lib/visitorIdentity.ts | 540 +----------------- apps/widget/src/TourOverlay.tsx | 13 +- apps/widget/src/components/Home.tsx | 49 +- .../src/hooks/useWidgetTabVisibility.ts | 64 +-- apps/widget/src/test/tourOverlay.test.tsx | 4 +- ...home-config-shared-contracts-2026-03-05.md | 98 ++++ ...s-visitor-readable-id-shared-2026-03-05.md | 73 +++ .../.openspec.yaml | 2 + .../README.md | 3 + .../design.md | 83 +++ .../proposal.md | 32 ++ .../visitor-readable-id-generation/spec.md | 28 + .../tasks.md | 18 + packages/convex/convex/messengerSettings.ts | 121 +--- packages/convex/convex/visitorReadableId.ts | 529 +---------------- packages/convex/package.json | 1 + .../convex/tests/visitorReadableId.test.ts | 11 + packages/types/src/homeConfig.ts | 156 +++++ packages/types/src/index.ts | 2 + packages/types/src/visitorReadableId.ts | 529 +++++++++++++++++ pnpm-lock.yaml | 3 + 23 files changed, 1209 insertions(+), 1342 deletions(-) create mode 100644 apps/web/src/lib/__tests__/visitorIdentity.test.ts create mode 100644 docs/refactor-progress-home-config-shared-contracts-2026-03-05.md create mode 100644 docs/refactor-progress-visitor-readable-id-shared-2026-03-05.md create mode 100644 openspec/changes/centralize-visitor-readable-id-generator/.openspec.yaml create mode 100644 openspec/changes/centralize-visitor-readable-id-generator/README.md create mode 100644 openspec/changes/centralize-visitor-readable-id-generator/design.md create mode 100644 openspec/changes/centralize-visitor-readable-id-generator/proposal.md create mode 100644 openspec/changes/centralize-visitor-readable-id-generator/specs/visitor-readable-id-generation/spec.md create mode 100644 openspec/changes/centralize-visitor-readable-id-generator/tasks.md create mode 100644 packages/convex/tests/visitorReadableId.test.ts create mode 100644 packages/types/src/homeConfig.ts create mode 100644 packages/types/src/visitorReadableId.ts diff --git a/apps/web/src/app/settings/HomeSettingsSection.tsx b/apps/web/src/app/settings/HomeSettingsSection.tsx index c630267..2d54ddd 100644 --- a/apps/web/src/app/settings/HomeSettingsSection.tsx +++ b/apps/web/src/app/settings/HomeSettingsSection.tsx @@ -4,6 +4,17 @@ import { useState, useEffect } from "react"; import { useQuery, useMutation } from "convex/react"; import { Button, Card } from "@opencom/ui"; import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared"; +import { + getDefaultHomeTabs, + normalizeHomeTabs, + type HomeCard, + type HomeCardType, + type HomeConfig, + type HomeDefaultSpace, + type HomeTab, + type HomeTabId, + type HomeVisibility, +} from "@opencom/types"; import { Home, Plus, X, GripVertical, Search, MessageSquare, FileText, Bell } from "lucide-react"; import { api } from "@opencom/convex"; import type { Id } from "@opencom/convex/dataModel"; @@ -37,7 +48,12 @@ const CARD_TYPES = [ description: "Curated help content", }, { type: "announcements", label: "Announcements", icon: Bell, description: "News and updates" }, -] as const; +] as const satisfies ReadonlyArray<{ + type: HomeCardType; + label: string; + icon: typeof Home; + description: string; +}>; const TAB_TYPES = [ { @@ -76,65 +92,12 @@ const TAB_TYPES = [ description: "Support ticket list and creation", locked: false, }, -] as const; - -type CardType = (typeof CARD_TYPES)[number]["type"]; -type TabId = (typeof TAB_TYPES)[number]["id"]; -type VisibleTo = "all" | "visitors" | "users"; -type HomeCardConfigPrimitive = string | number | boolean | null; -type HomeCardConfigObject = Record; -type HomeCardConfigValue = - | HomeCardConfigPrimitive - | HomeCardConfigPrimitive[] - | HomeCardConfigObject; -type HomeCardConfig = Record; - -interface HomeCard { - id: string; - type: CardType; - config?: HomeCardConfig; - visibleTo: VisibleTo; -} - -interface HomeTab { - id: TabId; - enabled: boolean; - visibleTo: VisibleTo; -} - -interface HomeConfigResponse { - enabled: boolean; - cards: HomeCard[]; - defaultSpace: "home" | "messages" | "help"; - tabs?: HomeTab[]; -} - -const DEFAULT_TABS: HomeTab[] = [ - { id: "home", enabled: true, visibleTo: "all" }, - { id: "messages", enabled: true, visibleTo: "all" }, - { id: "help", enabled: true, visibleTo: "all" }, - { id: "tours", enabled: true, visibleTo: "all" }, - { id: "tasks", enabled: true, visibleTo: "all" }, - { id: "tickets", enabled: true, visibleTo: "all" }, -]; - -function normalizeTabs(tabs: HomeTab[] | undefined): HomeTab[] { - const tabsById = new Map((tabs ?? []).map((tab) => [tab.id, tab])); - return DEFAULT_TABS.map((defaultTab) => { - if (defaultTab.id === "messages") { - return { ...defaultTab }; - } - const configuredTab = tabsById.get(defaultTab.id); - if (!configuredTab) { - return { ...defaultTab }; - } - return { - id: defaultTab.id, - enabled: configuredTab.enabled, - visibleTo: configuredTab.visibleTo, - }; - }); -} +] as const satisfies ReadonlyArray<{ + id: HomeTabId; + label: string; + description: string; + locked: boolean; +}>; export function HomeSettingsSection({ workspaceId, @@ -144,15 +107,15 @@ export function HomeSettingsSection({ const homeConfig = useQuery( api.messengerSettings.getHomeConfig, workspaceId ? { workspaceId } : "skip" - ) as HomeConfigResponse | undefined; + ) as HomeConfig | undefined; const updateHomeConfig = useMutation(api.messengerSettings.updateHomeConfig); const toggleHomeEnabled = useMutation(api.messengerSettings.toggleHomeEnabled); const [enabled, setEnabled] = useState(false); const [cards, setCards] = useState([]); - const [tabs, setTabs] = useState(DEFAULT_TABS); - const [defaultSpace, setDefaultSpace] = useState<"home" | "messages" | "help">("messages"); + const [tabs, setTabs] = useState(() => getDefaultHomeTabs()); + const [defaultSpace, setDefaultSpace] = useState("messages"); const [isSaving, setIsSaving] = useState(false); const [showAddCard, setShowAddCard] = useState(false); const [draggedIndex, setDraggedIndex] = useState(null); @@ -161,9 +124,9 @@ export function HomeSettingsSection({ useEffect(() => { if (homeConfig) { setEnabled(homeConfig.enabled); - setCards(homeConfig.cards as HomeCard[]); + setCards(homeConfig.cards); setDefaultSpace(homeConfig.defaultSpace); - setTabs(normalizeTabs(homeConfig.tabs)); + setTabs(normalizeHomeTabs(homeConfig.tabs)); } }, [homeConfig]); @@ -178,7 +141,7 @@ export function HomeSettingsSection({ enabled, cards, defaultSpace, - tabs: normalizeTabs(tabs), + tabs: normalizeHomeTabs(tabs), }, }); } catch (error) { @@ -206,7 +169,7 @@ export function HomeSettingsSection({ } }; - const addCard = (type: CardType) => { + const addCard = (type: HomeCardType) => { const newCard: HomeCard = { id: `${type}-${Date.now()}`, type, @@ -220,17 +183,17 @@ export function HomeSettingsSection({ setCards(cards.filter((c) => c.id !== id)); }; - const updateCardVisibility = (id: string, visibleTo: VisibleTo) => { + const updateCardVisibility = (id: string, visibleTo: HomeVisibility) => { setCards(cards.map((c) => (c.id === id ? { ...c, visibleTo } : c))); }; - const updateTabVisibility = (id: TabId, visibleTo: VisibleTo) => { + const updateTabVisibility = (id: HomeTabId, visibleTo: HomeVisibility) => { setTabs( tabs.map((tab) => (tab.id === id ? { ...tab, visibleTo: id === "messages" ? "all" : visibleTo } : tab)) ); }; - const updateTabEnabled = (id: TabId, enabledValue: boolean) => { + const updateTabEnabled = (id: HomeTabId, enabledValue: boolean) => { if (id === "messages") { return; } @@ -256,12 +219,12 @@ export function HomeSettingsSection({ setDraggedIndex(null); }; - const getCardIcon = (type: CardType) => { + const getCardIcon = (type: HomeCardType) => { const cardDef = CARD_TYPES.find((c) => c.type === type); return cardDef?.icon ?? Home; }; - const getCardLabel = (type: CardType) => { + const getCardLabel = (type: HomeCardType) => { const cardDef = CARD_TYPES.find((c) => c.type === type); return cardDef?.label ?? type; }; @@ -301,7 +264,7 @@ export function HomeSettingsSection({ updateTabVisibility(tabType.id, e.target.value as VisibleTo)} + onChange={(e) => + updateTabVisibility(tabType.id, e.target.value as HomeVisibility) + } disabled={visibilityDisabled} className="px-2 py-1 text-xs border rounded bg-background disabled:opacity-60" > @@ -429,7 +394,9 @@ export function HomeSettingsSection({
    handleSegmentSelect(e.target.value)} className="w-full px-3 py-2 border rounded-lg text-sm" > @@ -621,7 +599,7 @@ export function AudienceRuleBuilder({ ))} - {value?.type === "segment" && preview && ( + {isSegmentRule(value) && preview && (
    @@ -638,7 +616,11 @@ export function AudienceRuleBuilder({
    )} - {isEnabled && targetingMode === "custom" && value && value.type === "group" && ( + {isEnabled && + targetingMode === "custom" && + value && + "type" in value && + value.type === "group" && ( <> {preview && ( diff --git a/apps/web/src/lib/__tests__/audienceRules.test.ts b/apps/web/src/lib/__tests__/audienceRules.test.ts new file mode 100644 index 0000000..dcbb931 --- /dev/null +++ b/apps/web/src/lib/__tests__/audienceRules.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { Id } from "@opencom/convex/dataModel"; +import type { AudienceRule } from "@/components/AudienceRuleBuilder"; +import { toInlineAudienceRule, toInlineAudienceRuleFromBuilder } from "../audienceRules"; + +describe("audienceRules helpers", () => { + it("drops segment references from unknown payloads", () => { + expect( + toInlineAudienceRule({ + segmentId: "segment_1" as Id<"segments">, + }) + ).toBeNull(); + }); + + it("keeps inline group/condition rules", () => { + const inlineRule = { + type: "group" as const, + operator: "and" as const, + conditions: [ + { + type: "condition" as const, + property: { source: "system" as const, key: "email" }, + operator: "contains" as const, + value: "@example.com", + }, + ], + }; + + expect(toInlineAudienceRule(inlineRule)).toEqual(inlineRule); + }); + + it("drops segment references from builder rules", () => { + const segmentRule: AudienceRule = { + segmentId: "segment_1" as Id<"segments">, + }; + + expect(toInlineAudienceRuleFromBuilder(segmentRule)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/audienceRules.ts b/apps/web/src/lib/audienceRules.ts index 3a34de1..fb3bba2 100644 --- a/apps/web/src/lib/audienceRules.ts +++ b/apps/web/src/lib/audienceRules.ts @@ -1,6 +1,7 @@ -import type { AudienceRule } from "@/components/AudienceRuleBuilder"; +import { isAudienceSegmentReference } from "@opencom/types"; +import type { AudienceRule, SegmentReference } from "@/components/AudienceRuleBuilder"; -export type InlineAudienceRule = Exclude; +export type InlineAudienceRule = Exclude; export function toInlineAudienceRule(rule: unknown): InlineAudienceRule | null { if (!rule || typeof rule !== "object") { @@ -8,11 +9,7 @@ export function toInlineAudienceRule(rule: unknown): InlineAudienceRule | null { } const candidate = rule as Record; - if (candidate.type === "segment") { - return null; - } - - if ("segmentId" in candidate && candidate.type === undefined) { + if (isAudienceSegmentReference(candidate)) { return null; } @@ -22,7 +19,7 @@ export function toInlineAudienceRule(rule: unknown): InlineAudienceRule | null { export function toInlineAudienceRuleFromBuilder( rule: AudienceRule | null ): InlineAudienceRule | null { - if (!rule || rule.type === "segment") { + if (!rule || isAudienceSegmentReference(rule)) { return null; } diff --git a/docs/refactor-progress-audience-rule-contract-alignment-2026-03-05.md b/docs/refactor-progress-audience-rule-contract-alignment-2026-03-05.md new file mode 100644 index 0000000..5bc9cc8 --- /dev/null +++ b/docs/refactor-progress-audience-rule-contract-alignment-2026-03-05.md @@ -0,0 +1,69 @@ +# Refactor Progress: Audience Rule Contract Alignment (2026-03-05) + +## Scope + +- `packages/types` +- `apps/web` +- validation guardrails for: + - `packages/convex` + - `apps/widget` + - `packages/sdk-core` + - `apps/mobile` + - `packages/react-native-sdk` + +## Problems Addressed + +1. Web audience rule types drifted from Convex validator contracts. +2. Segment reference shape in web builder did not match backend payload contract. +3. Outbound editor used a segment-capable type even though backend accepts inline rules only. +4. Article markdown export loop accessed `content` without narrowing file union type. + +## What Was Changed + +### Shared contracts + +- Added `packages/types/src/audienceRules.ts` with shared audience-rule contracts and helpers. +- Exported via `packages/types/src/index.ts`. + +### Web audience rule adoption + +- Refactored `apps/web/src/components/AudienceRuleBuilder.tsx`: + - uses shared audience contracts + - uses backend-compatible segment payload shape `{ segmentId }` + - limits nested group depth to match validator-supported rule depth +- Refactored `apps/web/src/lib/audienceRules.ts`: + - segment detection now based on `segmentId` shape + - inline conversion helper alignment for builder and unknown payloads + +### Outbound + articles fixes + +- Updated `apps/web/src/app/outbound/[id]/page.tsx`: + - targeting state switched to inline-only rules + - builder output normalized with inline conversion helper + - explicit null guard before post-specific save logic +- Updated `apps/web/src/app/articles/page.tsx`: + - markdown export archive generation now narrows `file.type` before reading `content` + +### Tests added + +- `apps/web/src/lib/__tests__/audienceRules.test.ts` + - segment payload exclusion + - inline rule preservation + - builder conversion behavior + +## Verification + +Passed: + +- `pnpm --filter @opencom/types typecheck` +- `pnpm --filter @opencom/web test -- src/lib/__tests__/audienceRules.test.ts src/lib/__tests__/visitorIdentity.test.ts` +- `pnpm --filter @opencom/web typecheck` +- `pnpm --filter @opencom/convex typecheck` +- `pnpm --filter @opencom/widget typecheck` +- `pnpm --filter @opencom/sdk-core typecheck` +- `pnpm --filter @opencom/mobile typecheck` +- `pnpm --filter @opencom/react-native-sdk typecheck` + +Outcome: + +- Current `@opencom/web` typecheck blockers from audience-rule drift and article export union narrowing are resolved in this slice. diff --git a/docs/refactor-remaining-map-2026-03-05.md b/docs/refactor-remaining-map-2026-03-05.md new file mode 100644 index 0000000..c39774c --- /dev/null +++ b/docs/refactor-remaining-map-2026-03-05.md @@ -0,0 +1,87 @@ +# Refactor Remaining Map (2026-03-05) + +## Current Status Snapshot + +Recently completed slices: + +- Shared home config contracts (`types + convex + web + widget`) +- Shared visitor readable ID generator (`types + convex + web`) +- Audience rule contract alignment and web typecheck recovery (`types + web`) + +Open active OpenSpec changes unrelated to this refactor map (product tracks) remain in progress: + +- `publish-mobile-sdk-packages-and-release-pipeline` +- `parity-mobile-inbox-ai-review-and-visitors` +- `ai-autotranslate-conversation-language-support` +- `add-intercom-migration-wizard` +- SEO changes + +## Remaining Refactors (Priority Order) + +## 1) UI Decomposition: Web Monoliths (High) + +- `apps/web/src/app/inbox/page.tsx` (~1438 lines) +- `apps/web/src/app/settings/page.tsx` (~1399 lines) +- `apps/web/src/app/surveys/[id]/page.tsx` (~1252 lines) +- `apps/web/src/app/campaigns/series/[id]/page.tsx` (~1204 lines) +- `apps/web/src/app/articles/page.tsx` (~1172 lines) + +Recommended next proposal tracks: + +- `decompose-web-settings-page-by-domain` +- `decompose-web-survey-editor` + +## 2) UI Decomposition: Widget Monoliths (High) + +- `apps/widget/src/Widget.tsx` (~1427 lines) +- `apps/widget/src/TourOverlay.tsx` (~1428 lines) +- `apps/widget/src/components/ConversationView.tsx` (~830 lines) +- `apps/widget/src/SurveyOverlay.tsx` (~723 lines) + +Recommended next proposal tracks: + +- `decompose-widget-tour-overlay-controller` +- `decompose-widget-conversation-view` + +## 3) Convex Domain Decomposition (High) + +Highest concentration modules: + +- `packages/convex/convex/schema.ts` (~2026 lines) +- `packages/convex/convex/reporting.ts` (~1224 lines) +- `packages/convex/convex/visitors.ts` (~1070 lines) +- `packages/convex/convex/carousels.ts` (~1038 lines) +- `packages/convex/convex/surveys.ts` (~968 lines) + +Recommended next proposal tracks: + +- `split-convex-schema-domain-fragments` +- `decompose-convex-visitors-domain` +- `decompose-convex-reporting-domain` + +## 4) Cross-Surface Contract Convergence (Medium) + +- Continue replacing web-local domain contracts with shared `@opencom/types` contracts where backend validators already define stable payload shapes. +- Candidate next targets: + - trigger config contracts + - outbound message button/click-action contracts + - tour step authoring payload contracts + +Recommended proposal track: + +- `centralize-trigger-and-outbound-contracts` + +## Major New Findings Confirmed In This Slice + +1. Audience-rule depth constraints were not explicit in web editor behavior. + - Backend validators effectively support bounded nesting; web now needs explicit UI guardrails to stay within contract. +2. Segment targeting support is not uniform across endpoints. + - Outbound is inline-only while campaign/tour surfaces are segment-capable. + - This needs explicit product and API boundary documentation to prevent future drift. +3. Union payload handling in article import/export paths benefits from strict narrowing. + - This is a recurring pattern to enforce in other import/export domains. + +## Suggested Immediate Next Refactor + +1. Start `decompose-web-settings-page-by-domain` (high impact, contained blast radius). +2. In parallel, draft `split-convex-schema-domain-fragments` to reduce backend coupling and review load. diff --git a/openspec/changes/align-web-audience-rule-contracts/.openspec.yaml b/openspec/changes/align-web-audience-rule-contracts/.openspec.yaml new file mode 100644 index 0000000..8f0b869 --- /dev/null +++ b/openspec/changes/align-web-audience-rule-contracts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-05 diff --git a/openspec/changes/align-web-audience-rule-contracts/README.md b/openspec/changes/align-web-audience-rule-contracts/README.md new file mode 100644 index 0000000..3be8549 --- /dev/null +++ b/openspec/changes/align-web-audience-rule-contracts/README.md @@ -0,0 +1,3 @@ +# align-web-audience-rule-contracts + +Align web audience rule types with Convex validators and segment-reference shape diff --git a/openspec/changes/align-web-audience-rule-contracts/design.md b/openspec/changes/align-web-audience-rule-contracts/design.md new file mode 100644 index 0000000..fc1d610 --- /dev/null +++ b/openspec/changes/align-web-audience-rule-contracts/design.md @@ -0,0 +1,75 @@ +## Context + +The web app models audience rules in `AudienceRuleBuilder` with a segment variant `{ type: "segment", segmentId }`, while Convex validators for segment targeting accept `{ segmentId }` and inline rule validators do not include segment references. This mismatch now causes type failures and increases runtime drift risk. + +## Goals / Non-Goals + +**Goals** + +- Define one shared audience-rule contract in `@opencom/types`. +- Make web builder/state shapes compatible with Convex validators. +- Keep segment targeting available only where backend endpoints support it. +- Restore `@opencom/web` typecheck health for current blockers. + +**Non-Goals** + +- Rewriting Convex validator internals. +- Rebuilding all targeting UIs. +- Changing existing backend endpoint names or authorization semantics. + +## Decisions + +### 1) Add shared audience-rule contracts to `@opencom/types` + +Decision: + +- Introduce shared types for condition operators, property refs, inline rules, and segment references. +- Keep inline rules strictly condition/group recursive (no nested segments). + +Rationale: + +- Aligns frontend typing with Convex validator behavior and removes duplicated local contracts. + +### 2) Use backend-compatible segment reference shape in web + +Decision: + +- Represent segment targeting as `{ segmentId }` in web payload/state where applicable. + +Rationale: + +- Matches `audienceRulesOrSegmentValidator` payload expectations and avoids runtime/schema drift. + +### 3) Split inline-only vs segment-capable usage by screen + +Decision: + +- Keep outbound targeting as inline-only state because outbound update/create validators only accept inline audience rules. +- Keep segment-capable state in screens backed by `audienceRulesOrSegmentValidator`. + +Rationale: + +- Prevents type unsoundness and user-facing configurations that backend rejects. + +## Risks / Trade-offs + +- [Risk] Existing saved data using legacy segment shape may still appear from old records. + - Mitigation: parsing helpers treat unknown and legacy forms defensively. +- [Risk] UI behavior regressions in targeting mode toggles. + - Mitigation: add focused helper tests and rerun targeted web tests/typecheck. + +## Migration Plan + +1. Add shared audience-rule types in `@opencom/types` and export them. +2. Refactor web audience rule builder/helper types to shared contracts. +3. Update outbound page to inline-only targeting state. +4. Patch article export union narrowing. +5. Run targeted package checks and tests. + +Rollback: + +- Revert web adoption of shared contracts while preserving shared type file for incremental rollout. + +## Open Questions + +- Should Convex also consume the shared audience-rule TypeScript contracts directly in a follow-up change to reduce backend duplication? diff --git a/openspec/changes/align-web-audience-rule-contracts/proposal.md b/openspec/changes/align-web-audience-rule-contracts/proposal.md new file mode 100644 index 0000000..e00eeeb --- /dev/null +++ b/openspec/changes/align-web-audience-rule-contracts/proposal.md @@ -0,0 +1,33 @@ +## Why + +Web audience-rule typing has drifted from Convex validator contracts, causing `@opencom/web` typecheck failures across campaign/tour/outbound screens and the shared rule builder. The current segment-reference shape also differs from backend expectations. + +## What Changes + +- Introduce shared audience-rule contract types in `@opencom/types`. +- Refactor web `AudienceRuleBuilder` and audience-rule helpers to use shared contracts and backend-compatible segment references. +- Constrain outbound editor targeting to inline rules only (matching Convex API). +- Fix related web typecheck blockers, including article export file union narrowing. +- Add tests for audience-rule conversion helpers. + +## Capabilities + +### New Capabilities + +- `shared-audience-rule-contracts`: Canonical shared audience-rule types and segment-reference contract reused by web and backend-facing consumers. + +### Modified Capabilities + +- None. + +## Impact + +- Affected code: + - `packages/types/src/*` + - `apps/web/src/components/AudienceRuleBuilder.tsx` + - `apps/web/src/lib/audienceRules.ts` + - targeted campaign/tour/outbound/article pages +- APIs: + - No endpoint renames; payload shapes align with existing Convex validators. +- Dependencies: + - No external dependency additions. diff --git a/openspec/changes/align-web-audience-rule-contracts/specs/shared-audience-rule-contracts/spec.md b/openspec/changes/align-web-audience-rule-contracts/specs/shared-audience-rule-contracts/spec.md new file mode 100644 index 0000000..5251ad2 --- /dev/null +++ b/openspec/changes/align-web-audience-rule-contracts/specs/shared-audience-rule-contracts/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Web audience targeting payloads MUST match Convex validator contracts + +Web targeting editors SHALL produce payload shapes that conform to corresponding Convex argument validators for each endpoint. + +#### Scenario: Segment-capable surface submits targeting + +- **WHEN** a web surface backed by `audienceRulesOrSegmentValidator` submits segment targeting +- **THEN** the payload SHALL use `{ segmentId }` shape +- **AND** it SHALL remain accepted by typecheck without unsafe casts + +#### Scenario: Inline-only surface submits targeting + +- **WHEN** a web surface backed by `audienceRulesValidator` submits targeting +- **THEN** the payload SHALL contain only inline condition/group rule structures +- **AND** segment references SHALL be excluded from its local targeting state + +### Requirement: Shared audience-rule contracts MUST be reusable across web modules + +Audience-rule condition/group/segment contract types SHALL be defined in `@opencom/types` and reused by web modules that build or normalize targeting state. + +#### Scenario: Builder and helper use shared contract + +- **WHEN** `AudienceRuleBuilder` and audience-rule helper modules compile +- **THEN** they SHALL import audience-rule contract types from `@opencom/types` +- **AND** duplicate local contract definitions SHALL be minimized diff --git a/openspec/changes/align-web-audience-rule-contracts/tasks.md b/openspec/changes/align-web-audience-rule-contracts/tasks.md new file mode 100644 index 0000000..2b5553f --- /dev/null +++ b/openspec/changes/align-web-audience-rule-contracts/tasks.md @@ -0,0 +1,20 @@ +## 1. Shared Contract + +- [x] 1.1 Add audience-rule contract types to `@opencom/types`. +- [x] 1.2 Export new audience-rule contracts from `packages/types/src/index.ts`. + +## 2. Web Adoption + +- [x] 2.1 Refactor `AudienceRuleBuilder` to use shared contracts and backend-compatible segment reference shape. +- [x] 2.2 Refactor `apps/web/src/lib/audienceRules.ts` to align conversion helpers with shared contracts. +- [x] 2.3 Update outbound targeting flow to inline-only rule state (no segment references). +- [x] 2.4 Fix article markdown export union narrowing in `apps/web/src/app/articles/page.tsx`. + +## 3. Verification + +- [x] 3.1 Add/extend web tests for audience-rule helper conversions. +- [x] 3.2 Run targeted checks (`@opencom/types` typecheck, `@opencom/web` test/typecheck, `@opencom/widget` typecheck) and fix regressions. + +## 4. Documentation + +- [x] 4.1 Add progress notes for this refactor slice and update remaining-refactor roadmap with newly confirmed priorities. diff --git a/packages/types/src/audienceRules.ts b/packages/types/src/audienceRules.ts new file mode 100644 index 0000000..e12a0d0 --- /dev/null +++ b/packages/types/src/audienceRules.ts @@ -0,0 +1,63 @@ +export type ConditionOperator = + | "equals" + | "not_equals" + | "contains" + | "not_contains" + | "starts_with" + | "ends_with" + | "greater_than" + | "less_than" + | "greater_than_or_equals" + | "less_than_or_equals" + | "is_set" + | "is_not_set"; + +export type PropertyReference = { + source: "system" | "custom" | "event"; + key: string; + eventFilter?: { + name: string; + countOperator?: "at_least" | "at_most" | "exactly"; + count?: number; + withinDays?: number; + }; +}; + +export type AudienceCondition = { + type: "condition"; + property: PropertyReference; + operator: ConditionOperator; + value?: string | number | boolean; +}; + +export type AudienceGroup = { + type: "group"; + operator: "and" | "or"; + conditions: Array; +}; + +export type AudienceNestedGroup = { + type: "group"; + operator: "and" | "or"; + conditions: AudienceCondition[]; +}; + +export type InlineAudienceRule = AudienceCondition | AudienceGroup; + +export type AudienceSegmentReference = { + segmentId: SegmentId; +}; + +export type AudienceRuleWithSegment = + | InlineAudienceRule + | AudienceSegmentReference; + +export function isAudienceSegmentReference( + rule: unknown +): rule is AudienceSegmentReference { + if (!rule || typeof rule !== "object") { + return false; + } + + return "segmentId" in rule; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 82f9925..dbace6f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,4 +1,5 @@ export * from "./backend"; +export * from "./audienceRules"; export * from "./backendValidation"; export * from "./homeConfig"; export * from "./visitorReadableId"; From be8f489309609fc4a093c758e8e64f82b7642357 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 19:27:25 +0000 Subject: [PATCH 17/91] settings and survey decomp --- .../src/app/settings/EmailChannelSection.tsx | 117 ++ .../app/settings/HelpCenterAccessSection.tsx | 67 ++ .../src/app/settings/SignupAuthSection.tsx | 122 ++ .../src/app/settings/TeamMembersSection.tsx | 337 ++++++ apps/web/src/app/settings/page.tsx | 723 +----------- .../app/settings/useTeamMembersSettings.ts | 194 +++ .../app/surveys/[id]/SurveyAnalyticsTab.tsx | 154 +++ .../src/app/surveys/[id]/SurveyBuilderTab.tsx | 561 +++++++++ .../app/surveys/[id]/SurveySettingsTab.tsx | 72 ++ .../app/surveys/[id]/SurveyTargetingTab.tsx | 176 +++ apps/web/src/app/surveys/[id]/page.tsx | 1040 ++--------------- .../src/app/surveys/[id]/surveyEditorTypes.ts | 70 ++ .../[id]/useSurveyQuestionEditor.test.tsx | 69 ++ .../surveys/[id]/useSurveyQuestionEditor.ts | 107 ++ ...ettings-domain-decomposition-2026-03-05.md | 42 + ...-survey-editor-decomposition-2026-03-05.md | 47 + docs/refactor-remaining-map-2026-03-05.md | 33 +- .../.openspec.yaml | 2 + .../README.md | 3 + .../design.md | 79 ++ .../proposal.md | 34 + .../web-settings-domain-modularity/spec.md | 21 + .../tasks.md | 19 + .../.openspec.yaml | 2 + .../decompose-web-survey-editor/design.md | 81 ++ .../decompose-web-survey-editor/proposal.md | 30 + .../web-survey-editor-modularity/spec.md | 21 + .../decompose-web-survey-editor/tasks.md | 17 + 28 files changed, 2588 insertions(+), 1652 deletions(-) create mode 100644 apps/web/src/app/settings/EmailChannelSection.tsx create mode 100644 apps/web/src/app/settings/HelpCenterAccessSection.tsx create mode 100644 apps/web/src/app/settings/SignupAuthSection.tsx create mode 100644 apps/web/src/app/settings/TeamMembersSection.tsx create mode 100644 apps/web/src/app/settings/useTeamMembersSettings.ts create mode 100644 apps/web/src/app/surveys/[id]/SurveyAnalyticsTab.tsx create mode 100644 apps/web/src/app/surveys/[id]/SurveyBuilderTab.tsx create mode 100644 apps/web/src/app/surveys/[id]/SurveySettingsTab.tsx create mode 100644 apps/web/src/app/surveys/[id]/SurveyTargetingTab.tsx create mode 100644 apps/web/src/app/surveys/[id]/surveyEditorTypes.ts create mode 100644 apps/web/src/app/surveys/[id]/useSurveyQuestionEditor.test.tsx create mode 100644 apps/web/src/app/surveys/[id]/useSurveyQuestionEditor.ts create mode 100644 docs/refactor-progress-web-settings-domain-decomposition-2026-03-05.md create mode 100644 docs/refactor-progress-web-survey-editor-decomposition-2026-03-05.md create mode 100644 openspec/changes/decompose-web-settings-page-by-domain/.openspec.yaml create mode 100644 openspec/changes/decompose-web-settings-page-by-domain/README.md create mode 100644 openspec/changes/decompose-web-settings-page-by-domain/design.md create mode 100644 openspec/changes/decompose-web-settings-page-by-domain/proposal.md create mode 100644 openspec/changes/decompose-web-settings-page-by-domain/specs/web-settings-domain-modularity/spec.md create mode 100644 openspec/changes/decompose-web-settings-page-by-domain/tasks.md create mode 100644 openspec/changes/decompose-web-survey-editor/.openspec.yaml create mode 100644 openspec/changes/decompose-web-survey-editor/design.md create mode 100644 openspec/changes/decompose-web-survey-editor/proposal.md create mode 100644 openspec/changes/decompose-web-survey-editor/specs/web-survey-editor-modularity/spec.md create mode 100644 openspec/changes/decompose-web-survey-editor/tasks.md diff --git a/apps/web/src/app/settings/EmailChannelSection.tsx b/apps/web/src/app/settings/EmailChannelSection.tsx new file mode 100644 index 0000000..0592afb --- /dev/null +++ b/apps/web/src/app/settings/EmailChannelSection.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { Button, Card, Input } from "@opencom/ui"; +import { Copy, Mail } from "lucide-react"; + +interface EmailChannelSectionProps { + emailEnabled: boolean; + setEmailEnabled: (value: boolean) => void; + forwardingAddress?: string; + emailFromName: string; + setEmailFromName: (value: string) => void; + emailFromEmail: string; + setEmailFromEmail: (value: string) => void; + emailSignature: string; + setEmailSignature: (value: string) => void; + isSavingEmail: boolean; + onSave: () => Promise; +} + +export function EmailChannelSection({ + emailEnabled, + setEmailEnabled, + forwardingAddress, + emailFromName, + setEmailFromName, + emailFromEmail, + setEmailFromEmail, + emailSignature, + setEmailSignature, + isSavingEmail, + onSave, +}: EmailChannelSectionProps): React.JSX.Element { + return ( + +
    + +

    Email Channel

    +
    + +
    + + + {forwardingAddress && ( +
    + +
    + + {forwardingAddress} + + +
    +

    + Forward emails to this address to receive them in your inbox. +

    +
    + )} + +
    + + setEmailFromName(e.target.value)} + placeholder="Support Team" + /> +

    + The name that appears in outbound emails. +

    +
    + +
    + + setEmailFromEmail(e.target.value)} + placeholder="support@yourcompany.com" + /> +

    + The email address used for outbound emails. Must be verified with your email provider. +

    +
    + +
    + +