diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1074543..2a48fed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,32 +32,94 @@ jobs: run: pnpm install --frozen-lockfile - name: Lint + id: lint + continue-on-error: true run: pnpm lint - name: Typecheck + id: typecheck + continue-on-error: true run: pnpm typecheck - name: Convex raw auth guard + id: convex_auth_guard + continue-on-error: true run: pnpm security:convex-auth-guard - name: Convex validator any guard + id: convex_any_guard + continue-on-error: true run: pnpm security:convex-any-args-gate - name: Secret scan gate + id: secret_scan + continue-on-error: true run: pnpm security:secret-scan - name: Security headers policy check + id: headers_check + continue-on-error: true run: pnpm security:headers-check - name: Convex backend tests - run: pnpm --filter @opencom/convex test + id: convex_tests + continue-on-error: true + run: pnpm test:convex - name: Web production build + id: web_build + continue-on-error: true run: pnpm --filter @opencom/web build - name: Dependency audit gate + id: dependency_audit + continue-on-error: true run: node scripts/ci-audit-gate.js + - name: Summarize check results + if: always() + run: | + failures=0 + + report_blocking() { + name="$1" + outcome="$2" + if [ "$outcome" = "success" ]; then + echo "::notice::$name passed" + elif [ "$outcome" = "skipped" ]; then + echo "::warning::$name skipped" + else + echo "::error::$name failed" + failures=1 + fi + } + + report_warning() { + name="$1" + outcome="$2" + if [ "$outcome" = "success" ]; then + echo "::notice::$name passed" + elif [ "$outcome" = "skipped" ]; then + echo "::warning::$name skipped" + else + echo "::warning::$name failed (warning only)" + fi + } + + report_blocking "Lint" "${{ steps.lint.outcome }}" + report_blocking "Typecheck" "${{ steps.typecheck.outcome }}" + report_blocking "Convex raw auth guard" "${{ steps.convex_auth_guard.outcome }}" + report_blocking "Convex validator any guard" "${{ steps.convex_any_guard.outcome }}" + report_blocking "Secret scan gate" "${{ steps.secret_scan.outcome }}" + report_blocking "Security headers policy check" "${{ steps.headers_check.outcome }}" + report_blocking "Convex backend tests" "${{ steps.convex_tests.outcome }}" + report_blocking "Web production build" "${{ steps.web_build.outcome }}" + report_blocking "Dependency audit gate" "${{ steps.dependency_audit.outcome }}" + + if [ "$failures" -ne 0 ]; then + exit 1 + fi + e2e: runs-on: ubuntu-latest timeout-minutes: 45 diff --git a/AGENTS.md b/AGENTS.md index 45d02f4..d51897c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,37 @@ - Backend: `packages/convex` - OpenSpec source of truth: `openspec/changes//` +## General Workflow Guardrails + +- Start every non-trivial task by grounding in current repo state before changing files: + 1. identify the active scope + 2. read the relevant files/specs/tests + 3. verify whether the work is already partly done + 4. choose a narrow verification plan +- If working from an existing OpenSpec change, always read: + - `openspec status --change "" --json` + - `openspec instructions apply --change "" --json` + - the current `proposal.md`, `design.md`, `specs/**/*.md`, and `tasks.md` +- Never assume unchecked boxes in `tasks.md` mean the code is still missing. Verify the current implementation first, then update artifacts or tasks to match reality. +- Before creating a new OpenSpec change, quickly check for overlapping active changes or existing specs so you do not create duplicates or split ownership accidentally. +- For multi-step work, keep an explicit plan/todo and update it as tasks complete. Prefer one active task at a time. +- When changing course mid-task, record the new scope and the reason in the active change artifacts if they are affected. +- Before marking work complete, verify both code and artifacts: + - code/tests/typechecks reflect the final state + - `tasks.md` checkboxes match what is actually done + - any follow-up work is written down explicitly instead of left implicit + +## Existing Proposal Discipline + +- If you did not create the current proposal/change, treat the artifacts as hypotheses until verified against the codebase. +- Separate findings into three buckets before editing artifacts: + - already implemented + - still unfinished + - intentionally out of scope or accepted exception +- Only put unfinished work into active proposal/spec/task artifacts. +- If code and artifacts disagree, prefer fixing the artifact first unless the user explicitly asked for implementation. +- When leaving partial progress, record exact remaining file clusters, blockers, and verification still needed so a later pass can continue without re-auditing the whole repo. + ## High-Value Commands (copy/paste) ### Typecheck @@ -24,6 +55,164 @@ - Whole workspace: - `pnpm typecheck` +### Convex TypeScript deep-instantiation workaround + +- Canonical guide: `docs/convex-type-safety-playbook.md` +- If Convex typecheck hits `TS2589` (`Type instantiation is excessively deep and possibly infinite`) at generated refs like `api.foo.bar` or `internal.foo.bar`, prefer a **local escape hatch** instead of broad weakening. +- First keep call signatures shallow at the hot spot: + - cast `ctx.scheduler.runAfter`, `ctx.runQuery`, or `ctx.runMutation` to a local shallow function type. +- If merely referencing `api...` / `internal...` still triggers `TS2589`, use `makeFunctionReference("module:function")` from `convex/server` at that call site instead of property access on generated refs. +- Keep this workaround **localized only to pathological sites**. Continue using generated `api` / `internal` refs normally elsewhere. +- Expect hidden follow-on errors: rerun `pnpm --filter @opencom/convex typecheck` after each small batch of fixes, because resolving one deep-instantiation site can reveal additional ones. + +## Convex Type Safety Standards + +- Read `docs/convex-type-safety-playbook.md` before adding new Convex boundaries. +- Frontend runtime/UI modules must not import `convex/react` directly. Use local adapters and wrapper hooks instead. +- Keep Convex refs at module scope. Never create `makeFunctionReference(...)` values inside React components or hooks. +- Do not add new `getQueryRef(name: string)`, `getMutationRef(name: string)`, or `getActionRef(name: string)` factories. +- Backend cross-function calls should use generated `api` / `internal` refs by default. Only move to fixed `makeFunctionReference("module:function")` refs after a real `TS2589` hotspot is confirmed. +- Keep unavoidable casts localized to adapters or named backend hotspot helpers. Do not spread `as unknown as`, `unsafeApi`, or `unsafeInternal` through runtime code. +- After changing a boundary, update the relevant hardening guard: + - `packages/convex/tests/runtimeTypeHardeningGuard.test.ts` + - `apps/web/src/app/typeHardeningGuard.test.ts` + - `apps/widget/src/test/refHardeningGuard.test.ts` + - `packages/react-native-sdk/tests/hookBoundaryGuard.test.ts` + +## Convex Hardening Audit Triage + +- Before treating an audit item as open work, verify whether it is already implemented and only the guard/proposal text is stale. +- Default classification for current repo state: + - `packages/sdk-core/src/api/*.ts` manual fixed refs are generally **approved TS2589 hotspots**, not automatic cleanup targets. + - `packages/sdk-core/src/api/aiAgent.ts` already routes `getRelevantKnowledge` through `client.action(...)`; do not reopen the old query-path migration unless you find a current regression. + - `packages/convex/convex/embeddings.ts` batching/backfill concurrency work is already in place; do not create new perf tasks for `generateBatch`, `backfillExisting`, or `generateBatchInternal` unless the current code regressed. + - `packages/convex/convex/testAdmin.ts` is an explicit dynamic exception because it intentionally dispatches caller-selected internal test mutations. +- Treat these patterns differently: + - **Remaining cleanup target:** generic `name: string` ref helpers such as `makeInternalQueryRef(name)` / `getQueryRef(name)` in covered runtime files. + - **Usually acceptable hotspot:** fixed module-scope `makeFunctionReference("module:function")` constants with a narrow comment or guard-railed `TS2589` justification. + - **Accepted exception:** intentionally dynamic dispatch that is security-constrained and documented (currently `testAdmin.ts`). +- When cleaning backend Convex boundaries, prefer this order: + 1. Generated `api` / `internal` refs + 2. Named shallow runner helper at the hot spot + 3. Fixed `makeFunctionReference("module:function")` constant + 4. Only if intentionally dynamic and documented, a narrow exception +- Do not add new generic helper factories to shared ref modules. If a module exists to share refs, export fixed named refs from it. + +## Testing Best Practices + +### Do + +- Create isolated test data using helpers +- Clean up after tests +- Use descriptive test names +- Test both success and error cases +- Use `data-testid` attributes for E2E selectors +- Keep tests focused and independent + +### Don't + +- Share state between tests +- Rely on specific database IDs +- Skip cleanup in afterAll +- Hard-code timeouts (use Playwright's auto-wait) + + +## Code Style and Comments + +### Comment Tags + +Use these tags to highlight important information in code comments: + +- `IMPORTANT:` - Critical information that must not be overlooked +- `NOTE:` - Helpful context or clarification +- `WARNING:` - Potential pitfalls or dangerous operations +- `TODO:` - Future work that should be done +- `FIXME:` - Known issues that need fixing + +### Code Patterns + +- Use `MUST` / `MUST NOT` for hard requirements +- Use `NEVER` / `ALWAYS` for absolute rules +- Use `AVOID` for anti-patterns to stay away from +- Use `DO NOT` for explicit prohibitions + +### Example + +```typescript +// IMPORTANT: This function must be called before any Convex operations +// NOTE: The widget uses Shadow DOM, so overlays must portal into the shadow root +// WARNING: Never fall back to wildcard "*" for CORS +// TODO: Add rate limiting to this endpoint +// FIXME: This cast should be removed after TS2589 is resolved +``` + +## Modularity Patterns + +### Module Organization + +- Separate orchestration from rendering +- Extract helper logic from page components +- Use explicit domain modules instead of co-locating all logic +- Preserve existing behavior when refactoring + +### Key Principles + +1. **Single Responsibility**: Each module should have one clear purpose +2. **Explicit Contracts**: Modules must expose typed internal contracts +3. **Preserve Semantics**: Refactoring must preserve existing behavior +4. **Shared Utilities**: Common logic should be extracted to shared modules + +### Common Patterns + +- **Controller/View Separation**: Separate orchestration from rendering +- **Domain Modules**: Group related functionality by domain +- **Adapter Pattern**: Use adapters for external dependencies +- **Wrapper Hooks**: Wrap external hooks with local adapters + +## Error Handling Patterns + +### Standard Error Functions + +Use the standardized error functions from `packages/convex/convex/utils/errors.ts`: + +- `throwNotFound(resourceType)` - Resource not found +- `throwNotAuthenticated()` - Authentication required +- `throwPermissionDenied(permission?)` - Permission denied + +### Error Feedback + +- Use standardized non-blocking error feedback for frontend paths +- Provide actionable user messaging +- Centralize unknown error mapping for covered paths + +## Documentation Standards + +### Source of Truth + +- OpenSpec specs are the source of truth for requirements +- `docs/` contains reference documentation +- `AGENTS.md` contains AI agent guardrails +- Code comments provide inline guidance + +### When to Update Docs + +- When adding new features or changing behavior +- When fixing bugs that affect user-facing behavior +- When refactoring that changes module boundaries +- When adding new patterns or conventions + +## Agent Handoff Notes + +- When converting a repo audit into OpenSpec artifacts, put **only unfinished work** into `proposal.md`, spec deltas, and `tasks.md`. +- Explicitly call out already-finished adjacent work so a follow-up agent does not reopen it by mistake. +- For the current Convex hardening area, the default out-of-scope items are: + - sdk-core `getRelevantKnowledge` action routing + - embedding batching/backfill concurrency in `packages/convex/convex/embeddings.ts` +- If you change the covered hardening inventory or accepted exceptions, update the matching guard in the same change. Common files: + - `packages/convex/tests/runtimeTypeHardeningGuard.test.ts` + - `packages/sdk-core/tests/refHardeningGuard.test.ts` +- When leaving work half-finished, record the remaining file clusters explicitly in `openspec/changes//tasks.md` so the next agent can resume without re-auditing the repo. + ### Tests - Convex targeted file: @@ -83,3 +272,379 @@ Use these when working within OpenSpec-driven requests to reduce setup time in fresh chats. Warning: Running scripts inline causes the terminal to hang and crash. Create files and run them that way. Avoid running commmands like `... node - <<"NODE ..."` or `python3 - <<'PY' ...` + + +# Convex Type Safety Playbook + +This is the canonical guide for adding or changing Convex-backed code in this repo. + +Use it when you are: + +- adding a new Convex `query`, `mutation`, or `action` +- calling one Convex function from another +- wiring a new frontend feature to Convex +- hitting `TS2589` (`Type instantiation is excessively deep and possibly infinite`) +- deciding where a cast or `makeFunctionReference(...)` is acceptable + +Historical hardening notes still exist in `openspec/archive/refactor-*` and `runtime-type-hardening-2026-03-05.md`, but this file is the current source of truth for new code. + +## Goals + +- Keep runtime and UI code on explicit, local types. +- Keep unavoidable Convex typing escape hatches small and centralized. +- Prevent new generic string-ref factories, broad casts, and component-local ref creation. +- Make it obvious which pattern to use for each call site. + +## Decision Table + +| Situation | Preferred approach | Where | Why | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------- | +| Define a new public Convex query or mutation | Export a normal Convex function with narrow `v.*` args and a narrow return shape | `packages/convex/convex/**` | Keeps the source contract explicit and reusable | +| Call Convex from web or widget UI/runtime code | Use the local surface adapter plus a feature-local wrapper hook or fixed ref constant | `apps/web/src/**`, `apps/widget/src/**` | Keeps `convex/react` and ref typing out of runtime/UI modules | +| Call one Convex function from another and generated refs typecheck normally | Use generated `api.*` / `internal.*` refs | `packages/convex/convex/**` | This is the default, simplest path | +| Call one Convex function from another and generated refs hit `TS2589` | Add a local shallow `runQuery` / `runMutation` / `runAction` / `runAfter` helper | the hotspot file only | Shrinks type instantiation at the call boundary | +| The generated ref itself still triggers `TS2589` | Replace only that hot ref with a fixed, typed `makeFunctionReference("module:function")` constant | the hotspot file only | Avoids broad weakening of the entire module | +| Convex React hook tuple typing still needs help | Keep a tiny adapter-local helper/cast in the surface adapter | adapter file only | Localizes the last unavoidable boundary | + +## Non-Negotiable Rules + +### 1. Do not import `convex/react` in runtime or feature UI files + +Use the surface adapter layer instead: + +- web: `apps/web/src/lib/convex/hooks.ts` +- widget: `apps/widget/src/lib/convex/hooks.ts` +- mobile: follow the same local-wrapper pattern; do not add new direct screen/context-level `convex/react` usage + +Direct `convex/react` imports are only acceptable in: + +- explicit adapter files +- bootstrap/provider wiring +- targeted tests that intentionally mock the adapter boundary + +The current hardening guards freeze these boundaries: + +- `apps/web/src/app/typeHardeningGuard.test.ts` +- `apps/widget/src/test/refHardeningGuard.test.ts` +- `apps/mobile/src/typeHardeningGuard.test.ts` +- `packages/react-native-sdk/tests/hookBoundaryGuard.test.ts` + +### 2. Do not create refs inside React components or hooks + +Bad: + +```ts +function WidgetPane() { + const listRef = makeFunctionReference<"query", Args, Result>("messages:list"); + const data = useQuery(listRef, args); +} +``` + +Good: + +```ts +const LIST_REF = widgetQueryRef("messages:list"); + +function WidgetPane() { + const data = useWidgetQuery(LIST_REF, args); +} +``` + +All refs must be module-scope constants. + +### 3. Do not add new generic string-ref factories + +Do not introduce helpers like: + +- `getQueryRef(name: string)` +- `getMutationRef(name: string)` +- `getActionRef(name: string)` + +Those patterns weaken the type boundary and make review harder. Some older code still has them, but they are legacy, not the standard for new work. + +Use named fixed refs instead: + +```ts +const LIST_MESSAGES_REF = webQueryRef("messages:list"); +const SEND_MESSAGE_REF = webMutationRef>("messages:send"); +``` + +### 4. Keep casts local, named, and justified + +Allowed: + +- a tiny adapter-local cast needed to satisfy a Convex hook tuple type +- a hotspot-local shallow helper for `ctx.runQuery`, `ctx.runMutation`, `ctx.runAction`, or `ctx.scheduler.runAfter` +- a hotspot-local typed `makeFunctionReference("module:function")` when generated refs trigger `TS2589` + +Not allowed for new code: + +- `as any` +- broad `unsafeApi` / `unsafeInternal` object aliases in runtime code +- repeated `as unknown as` across multiple call sites +- hiding transport typing inside UI/controller modules + +### 5. Update guard tests when you add or move a boundary + +If you intentionally add a new approved boundary, document it in the relevant guard test at the same time. + +## Standard Patterns + +## A. Defining a new Convex query or mutation + +Default backend pattern: + +```ts +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; + +type VisitorSummary = { + _id: Id<"visitors">; + name?: string; + email?: string; +}; + +export const listByWorkspace = query({ + args: { + workspaceId: v.id("workspaces"), + }, + handler: async (ctx, args): Promise => { + const visitors = await ctx.db + .query("visitors") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) + .collect(); + + return visitors.map((visitor) => ({ + _id: visitor._id, + name: visitor.name, + email: visitor.email, + })); + }, +}); +``` + +Rules: + +- Use narrow `v.*` validators. +- Prefer explicit local return types for shared, frontend-facing, or cross-function contracts. +- Convert untyped or broad data to a narrow shape before returning it. +- If you need `v.any()`, document it in `security/convex-v-any-arg-exceptions.json`. + +## B. Consuming Convex from web or widget code + +Runtime/UI files should consume feature-local wrappers or fixed refs through the local adapter. + +Widget example: + +```ts +import type { Id } from "@opencom/convex/dataModel"; +import { useWidgetQuery, widgetQueryRef } from "../lib/convex/hooks"; + +type TicketRecord = { + _id: Id<"tickets">; + subject: string; +}; + +const VISITOR_TICKETS_REF = widgetQueryRef< + { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken: string }, + TicketRecord[] +>("tickets:listByVisitor"); + +export function useVisitorTickets( + workspaceId: Id<"workspaces"> | undefined, + visitorId: Id<"visitors"> | null, + sessionToken: string | null +) { + return useWidgetQuery( + VISITOR_TICKETS_REF, + workspaceId && visitorId && sessionToken ? { workspaceId, visitorId, sessionToken } : "skip" + ); +} +``` + +Use the same structure in web with `webQueryRef`, `webMutationRef`, `webActionRef`, `useWebQuery`, `useWebMutation`, and `useWebAction`. + +Rules: + +- Define refs once at module scope. +- Keep `skip` / gating logic in the wrapper where practical. +- Export narrow feature-local result types instead of leaking giant inferred shapes. +- Do not import `convex/react` directly in feature components, screens, contexts, or controller hooks. + +## C. Calling one Convex function from another + +### Preferred default: generated refs + +Start here when the types are normal: + +```ts +import { internal } from "./_generated/api"; + +await ctx.runMutation(internal.notifications.deliver, { + conversationId, +}); +``` + +This is the standard path until it hits a real `TS2589` problem. + +### TS2589 fallback: shallow helper first + +If `ctx.runQuery(...)`, `ctx.runMutation(...)`, `ctx.runAction(...)`, or `ctx.scheduler.runAfter(...)` causes deep-instantiation errors, add a local helper: + +```ts +import { type FunctionReference } from "convex/server"; + +type ConvexRef< + Type extends "query" | "mutation" | "action", + Visibility extends "internal" | "public", + Args extends Record, + Return = unknown, +> = FunctionReference; + +function getShallowRunMutation(ctx: { runMutation: unknown }) { + return ctx.runMutation as unknown as < + Visibility extends "internal" | "public", + Args extends Record, + Return, + >( + mutationRef: ConvexRef<"mutation", Visibility, Args, Return>, + mutationArgs: Args + ) => Promise; +} +``` + +Then call through the helper: + +```ts +const runMutation = getShallowRunMutation(ctx); +await runMutation(internal.notifications.deliver, { conversationId }); +``` + +### TS2589 fallback: fixed typed ref when the generated ref itself is the problem + +If simply referencing `api.foo.bar` or `internal.foo.bar` still triggers `TS2589`, switch only that hot call site to a fixed typed ref: + +```ts +import { makeFunctionReference, type FunctionReference } from "convex/server"; + +type DeliverArgs = { conversationId: Id<"conversations"> }; +type DeliverResult = null; + +type ConvexRef< + Type extends "query" | "mutation" | "action", + Visibility extends "internal" | "public", + Args extends Record, + Return = unknown, +> = FunctionReference; + +const DELIVER_NOTIFICATION_REF = makeFunctionReference<"mutation", DeliverArgs, DeliverResult>( + "notifications:deliver" +) as unknown as ConvexRef<"mutation", "internal", DeliverArgs, DeliverResult>; +``` + +Use this only after the generated ref path proved pathological. + +## Which approach to choose + +### Use generated `api` / `internal` refs when + +- the call is backend-to-backend +- the generated ref typechecks normally +- you are not in a known `TS2589` hotspot + +### Use fixed typed `makeFunctionReference(...)` constants when + +- you are in a surface adapter or feature-local wrapper file +- you need a stable local ref for a frontend wrapper +- a backend hotspot still blows up after trying generated refs + +### Use local wrapper hooks when + +- the consumer is React UI, runtime, controller, screen, or context code +- the feature needs gating or `skip` behavior +- you want to normalize the result shape once for multiple consumers + +### Use a shallow backend helper when + +- the problem is `ctx.runQuery` / `ctx.runMutation` / `ctx.runAction` / `runAfter` +- the ref type is okay, but the invocation is too deep + +### Use an adapter-local cast only when + +- Convex’s React hook typing still needs an exact tuple or helper shape +- the cast can stay in the adapter file and nowhere else + +## Current Surface Standards + +### Backend (`packages/convex`) + +- Default to generated refs. +- Localize `TS2589` workarounds with named `getShallowRun*` helpers. +- If needed, use fixed typed refs at the hotspot only. +- Keep guard coverage in `packages/convex/tests/runtimeTypeHardeningGuard.test.ts`. + +### Web (`apps/web`) + +- Feature/runtime code should not import `convex/react` directly. +- Use feature-local wrapper hooks and the web adapter in `apps/web/src/lib/convex/hooks.ts`. +- Keep refs at module scope. +- Guard coverage lives in `apps/web/src/app/typeHardeningGuard.test.ts`. + +### Widget (`apps/widget`) + +- Feature/runtime code should not import `convex/react` directly. +- Use the widget adapter in `apps/widget/src/lib/convex/hooks.ts`. +- The only remaining adapter escape hatch is the query-args tuple helper required by Convex’s hook typing. +- Guard coverage lives in `apps/widget/src/test/refHardeningGuard.test.ts`. + +### Mobile (`apps/mobile`) + +- Target the same pattern as web/widget: local wrapper hooks plus module-scope typed refs. +- Do not add new direct `convex/react` usage to screens, contexts, or controller-style hooks. +- If a local adapter/wrapper does not exist for the feature yet, create one instead of importing hooks directly into runtime UI. +- Guard coverage lives in `apps/mobile/src/typeHardeningGuard.test.ts`. + +## Anti-Patterns To Avoid + +- `function getQueryRef(name: string) { ... }` +- `function getMutationRef(name: string) { ... }` +- `function getActionRef(name: string) { ... }` +- component-local `makeFunctionReference(...)` +- `as any` +- broad `unsafeApi` / `unsafeInternal` aliases +- scattering the same `as unknown as` across many call sites +- returning `unknown` or `string` when a branded `Id<"...">` or explicit object type is the real contract + +## Verification Checklist + +After changing Convex typing boundaries: + +1. Run the touched package typecheck first. +2. Run the touched package tests. +3. Run the relevant hardening guard tests. +4. If this work is OpenSpec-driven, run strict OpenSpec validation. + +Useful commands: + +```bash +pnpm --filter @opencom/convex typecheck +pnpm --filter @opencom/web typecheck +pnpm --filter @opencom/widget typecheck + +pnpm --filter @opencom/convex test -- --run tests/runtimeTypeHardeningGuard.test.ts +pnpm --filter @opencom/web test -- --run src/app/typeHardeningGuard.test.ts +pnpm --filter @opencom/widget test -- --run src/test/refHardeningGuard.test.ts +pnpm exec vitest run --config apps/mobile/vitest.config.ts apps/mobile/src/typeHardeningGuard.test.ts +``` + +## Review Rule of Thumb + +If you are about to: + +- add a new direct `convex/react` import in feature code +- add a new `get*Ref(name: string)` factory +- add `unsafeApi`, `unsafeInternal`, `as any`, or repeated `as unknown as` +- create a ref inside a React component + +stop and use one of the standard patterns above instead. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83f1dfc..92ce27c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,7 @@ Thanks for contributing to Opencom. This guide covers everything you need to get - [Security & Operations](docs/open-source/security-and-operations.md) - [Data Model Reference](docs/data-model.md) - [Backend API Reference](docs/api-reference.md) +- [Convex Type Safety Playbook](docs/convex-type-safety-playbook.md) - [Scripts Reference](docs/scripts-reference.md) ## Development Setup @@ -87,24 +88,33 @@ opencom/ - Visitor endpoints require `sessionToken` validated via `resolveVisitorFromSession()`. - System/bot actions use `internalMutation` to bypass external auth. - Use `v.any()` sparingly and document in `security/convex-v-any-arg-exceptions.json`. +- Follow `docs/convex-type-safety-playbook.md` for all new Convex boundaries. +- Default backend-to-backend calls to generated `api` / `internal` refs. +- If a call site hits `TS2589`, keep the workaround local: + - shallow `ctx.runQuery` / `ctx.runMutation` / `ctx.runAction` / `runAfter` helper first + - fixed typed `makeFunctionReference("module:function")` only if the generated ref still fails +- Do not add new generic `get*Ref(name: string)` factories or broad `unsafeApi` / `unsafeInternal` aliases. ### Frontend (Web / Landing) - Next.js App Router. - Tailwind CSS + Shadcn UI components from `@opencom/ui`. - React context for auth (`AuthContext`) and backend connection (`BackendContext`). -- Convex React hooks for data fetching (real-time subscriptions). +- Use local Convex wrapper hooks and adapters for data fetching. +- Do not import `convex/react` directly into feature/runtime UI modules. ### Widget - Vite-built IIFE bundle. Target: <50KB gzipped. - No external dependencies beyond Convex client. - All visitor calls thread `sessionToken`. +- Use `apps/widget/src/lib/convex/hooks.ts` plus feature-local wrappers instead of direct `convex/react` imports in runtime files. ### Mobile - Expo / React Native. - Same auth patterns as web (Convex Auth + BackendContext). +- New mobile Convex usage should follow the same local-wrapper pattern as web/widget instead of adding new direct screen/context-level hook usage. ## Verification Workflow diff --git a/ROADMAP.md b/ROADMAP.md index a2805f9..5090218 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,16 +16,86 @@ Goal: ship a professional open-source customer messaging platform with strong de ### P0 (critical before production confidence) -- [ ] Split up / simplify the settings page - maybe colocate settings with their corresponding functionality -- [ ] Import feature for docs / help center, so you can keep a folder of markdown anywhere that you edit and maintain, and upload it to sync latest changes while maintaining folder structure (as collections), etc. -- [ ] Fix inbox chat responsiveness (navbar) -- [ ] make mobile app match inbox functionality (understand AI review, which messages were sent by AI, visitors list and details/navigation flows) + + + +- [ ] make deploy of web and landing and updates to app stores dependent on successful convex deploy, otherwise the apps will be speaking to an old version of our convex functions - [ ] Merge some sidebar items -- [ ] Check AI chat suggestions setup is working - [ ] check email campaign setup - [ ] check series setup -- [ ] plan shift to production env - [ ] edit app store description for License to AGPLv3 +- [ ] do we need a way for users to fix versions to avoid potentially breaking changes? How would we do that - would it be just the widget JS, the convex backend too, anything else? +- [ ] SSO, SAML, OIDC, and granular role-based access controls +- [ ] Lets add an option for admins so they can set the /// +- [ ] merge internal articles into regular articles, and add a toggle per article for internal vs public? or equivalent. Perhaps collection based, or article based, needs more thought. (can then remove the Knowledge page) +- [ ] should snippets be accessible from inbox, rather than its own panel? +- [ ] improved inbox management (sorting, filtering etc.) +- [ ] dont allow requesting human support multiple times in a row on same chat +- [ ] "resolve visitor from expression - session expired" - are we handling refresh properly? +- [p] maintain message state in mobile app when switching apps +- [ ] Fix CI e2e +- [ ] telegram feedback + - [p] chat attachments + - [p] can we make the email collection component shown after sending a message less obtrusive - Maybe it can descend from the top bar, rather than from the bottom where it covers the latest message. then maybe we can leave it there until filled in without a skip button, but just have it take up less space so its not in the way? + - [ ] in the current set up, if skipped, dont re-ask for their email each time - give them a subtle affordance where they can add their email if they change their mind + - [ ] showcase the dashboard on the landing app? + - [p] API for headless management +- [ ] publish RN-SDK to npm (anything else need publishing? or web etc is fine since users just use JS snippet for install) +- [ ] paid plan + - [ ] what pricing model - one off fee? Limited free tier? PAYG for AI & email credits? + options for BYOKs? Start simple and add complexity - $49/month (with generous fair usage limits - if people approach limits, I will set up PAYG for powerusers to cover edge cases)? +- [ ] AI updates + - [ ] BYOK for AI in hosted / paid plan + - [ ] pull available models from provider API and display them in the settings UI to control which model is used + - [ ] allow customising the agent's system prompt? +- [p] a CI AI agent to check for any doc drift and update docs based on the latest code +- [ ] convert supportAttachments.finalizeUpload into an action + internal mutation pipeline so we can add real signature checks too. The current finalizeUpload boundary is a Convex mutation and ctx.storage.get() is only available in actions. Doing true magic-byte validation would need a larger refactor of that finalize flow. +- [ ] add URL param deep links for the widget - Go to a url like ?open-widget-tab=home to open the widget to that tab, etc. + + + apps/web/src/app/outbound/[id]/OutboundTriggerPanel.tsx +Comment on lines +67 to 71 + value={value.delaySeconds ?? 5} + onChange={(e) => + onChange({ ...value, delaySeconds: Number.parseInt(e.target.value, 10) }) + onChange({ ...value, delaySeconds: parseOptionalInteger(e.target.value) }) + } + min={1} +Switching from || to ?? means a value of 0 will now be treated as valid and shown in the number input, even though the input has min={1}. Consider normalizing/clamping parsed values (e.g., treat <= 0 as undefined/default) to avoid persisting an invalid delaySeconds state. + + + + +- [p] Check AI chat / article suggestions setup is working + - [p] Add links to relevant help center articles in the widget AI responses, and maybe in chat (suggested articles) +- [p] deploy necessary packages to NPM or Github and fix instructions for Mobile SDK install (npm package + release pipeline) +- [p] AI Autotranslate for speaking to people in any language +- [p] make mobile app match inbox functionality (understand AI review, which messages were sent by AI, visitors list and details/navigation flows) + + + + +- [ ] ensure domain validation is working + - 2/27/2026, 11:45:52 AM [CONVEX M(widgetSessions:boot)] Uncaught Error: Origin validation failed: Origin not in allowed list + at requireValidOrigin (../../convex/originValidation.ts:116:0) + at async handler (../../convex/widgetSessions.ts:119:4) + + + + + + + +- [ ] featurebase feature exploration + - [ ] learn from featurebase docs and site for landing app + - [ ] slick animations + - [ ] suggested queries / replies for visitors + + + +- [ ] offer JWT identity verification as alternative to HMAC? +- [ ] ensure HMAC identity verification is working on landing page setup +- [ ] switch to https://github.com/axelmarciano/expo-open-ota if EAS MAU costs become an issue - [ ] **(P0 | 0.95)** Finalize Convex integration: - CI for Convex functions on push. @@ -93,6 +163,19 @@ Goal: ship a professional open-source customer messaging platform with strong de - [ ] **(P2 | 0.60)** AI assistant roadmap: - Better answer quality, context strategy, handoff rules, and evaluation prompts. +RN & Native SDKs + Installation + Configuration + Using Opencom + Help Center + Push Notifications + Secure Your Messenger + Deep Linking + Identity Verification + Supported Versions + Data Hosting Region Configuration + Code Samples + ## Intercom-Parity Status Snapshot - [x] Inbox @@ -128,3 +211,80 @@ Legend: `[x]` complete, `[~]` present but still maturing, `[ ]` not yet implemen - [ ] Should OTP be the only default path for new users, with password optional? - [ ] How strict should hosted-tier limits be for email campaigns and push usage? - [ ] What is required for Production Readiness, vs nice-to-haves? + + + + +I followed the AGENTS.md memory/instructions (focused, repo-scoped audit with PNPM-era conventions) and used jcodemunch + repo grep to map this. + +1) Manual makeFunctionReference hotspots + +RAG-follow-up critical + +packages/convex/convex/aiAgentActionsKnowledge.ts:45 (GET_CONTENT_BY_ID_REF) +packages/convex/convex/aiAgentActionsKnowledge.ts:54 (GET_EMBEDDING_BY_ID_REF) +packages/convex/convex/aiAgentActions.ts:162 (GET_RELEVANT_KNOWLEDGE_FOR_RUNTIME_ACTION_REF) +packages/convex/convex/embeddings.ts:172 (GENERATE_BATCH_INTERNAL_REF) +packages/convex/convex/embeddings.ts:137 (LIST_BY_CONTENT_REF) and adjacent refs used in embedding pipeline +Broader repo hotspots (same pattern) + +packages/convex/convex/notifications/functionRefs.ts +packages/convex/convex/push/functionRefs.ts +packages/convex/convex/series/scheduler.ts +packages/convex/convex/pushCampaigns.ts +packages/convex/convex/testing/helpers/notifications.ts +packages/convex/convex/emailChannel.ts +packages/convex/convex/embeddings/functionRefs.ts +packages/convex/convex/carousels/triggering.ts +packages/convex/convex/events.ts +packages/convex/convex/http.ts +packages/convex/convex/outboundMessages.ts +packages/convex/convex/snippets.ts +packages/convex/convex/testAdmin.ts +packages/convex/convex/visitors/mutations.ts +packages/convex/convex/widgetSessions.ts +packages/convex/convex/workspaceMembers.ts +packages/convex/convex/tickets.ts +2) as unknown as reduction targets + +Immediate (RAG path) + +packages/convex/convex/aiAgentActionsKnowledge.ts:39 +packages/convex/convex/aiAgentActionsKnowledge.ts:49 +packages/convex/convex/aiAgentActionsKnowledge.ts:58 +packages/convex/convex/aiAgentActions.ts:123 +packages/convex/convex/aiAgentActions.ts:155 +packages/convex/convex/aiAgentActions.ts:172 +packages/convex/convex/aiAgentActions.ts:189 +packages/convex/convex/embeddings.ts:30 +packages/convex/convex/embeddings.ts:37 +packages/convex/convex/embeddings.ts:44 +packages/convex/convex/embeddings.ts:123 +packages/convex/convex/embeddings.ts:176 +Full broader set + +Same file list as section 1 (19 Convex files matched for makeFunctionReference ... as unknown as). +3) Batching/perf refactor sites + +packages/convex/convex/embeddings.ts:371 (generateBatch loops serially over args.items) +packages/convex/convex/embeddings.ts:465 (backfillExisting runs batch chunks sequentially) +packages/convex/convex/embeddings.ts:509 (generateBatchInternal loops serially over args.items) +These are the concrete places to introduce concurrency-limited parallelism / true batched embedding work. + +4) SDK route migration sites (getRelevantKnowledge old query path) + +Source + +packages/sdk-core/src/api/aiAgent.ts:12 (aiAgent:getRelevantKnowledge ref) +packages/sdk-core/src/api/aiAgent.ts:76 (getRelevantKnowledge exported function) +packages/sdk-core/src/api/aiAgent.ts:83 (client.query(GET_RELEVANT_KNOWLEDGE_REF, ...)) +Tests/contracts expecting old route + +packages/sdk-core/tests/contracts.test.ts:478 +packages/sdk-core/tests/api.test.ts:56 +packages/sdk-core/tests/api.test.ts:150 +Backend fallback currently kept for compatibility + +packages/convex/convex/aiAgent.ts:318 (getRelevantKnowledge public query) +If you want, I can turn this into a prioritized migration checklist (P0/P1/P2) with exact replacement strategy per file. + diff --git a/apps/mobile/app/(app)/conversation/[id].tsx b/apps/mobile/app/(app)/conversation/[id].tsx index e87cfaa..77d6ae5 100644 --- a/apps/mobile/app/(app)/conversation/[id].tsx +++ b/apps/mobile/app/(app)/conversation/[id].tsx @@ -11,17 +11,9 @@ import { ActivityIndicator, } from "react-native"; import { useLocalSearchParams } from "expo-router"; -import { useQuery, useMutation } from "convex/react"; -import { api } from "@opencom/convex"; import { useAuth } from "../../../src/contexts/AuthContext"; -import type { Id } from "@opencom/convex/dataModel"; - -interface Message { - _id: string; - content: string; - senderType: "user" | "visitor" | "agent" | "bot"; - createdAt: number; -} +import { useConversationConvex } from "../../../src/hooks/convex/useConversationConvex"; +import type { MobileConversationMessage as Message } from "../../../src/hooks/convex/types"; function formatTime(timestamp: number): string { return new Date(timestamp).toLocaleTimeString([], { @@ -35,32 +27,22 @@ export default function ConversationScreen() { const { user } = useAuth(); const [inputText, setInputText] = useState(""); const flatListRef = useRef(null); - - const conversation = useQuery( - api.conversations.get, - id ? { id: id as Id<"conversations"> } : "skip" - ); - - const visitor = useQuery( - api.visitors.get, - conversation?.visitorId ? { id: conversation.visitorId } : "skip" - ); - - const messages = useQuery( - api.messages.list, - id ? { conversationId: id as Id<"conversations"> } : "skip" - ); - - const sendMessage = useMutation(api.messages.send); - const updateStatus = useMutation(api.conversations.updateStatus); - const markAsRead = useMutation(api.conversations.markAsRead); + const { + resolvedConversationId, + conversation, + visitor, + messages, + sendMessage, + updateConversationStatus: updateStatus, + markConversationRead: markAsRead, + } = useConversationConvex(id); // Mark conversation as read when viewing useEffect(() => { - if (id && conversation) { - markAsRead({ id: id as Id<"conversations">, readerType: "agent" }).catch(console.error); + if (resolvedConversationId && conversation) { + markAsRead({ id: resolvedConversationId, readerType: "agent" }).catch(console.error); } - }, [id, conversation, markAsRead]); + }, [conversation, markAsRead, resolvedConversationId]); useEffect(() => { if (messages && messages.length > 0) { @@ -71,14 +53,14 @@ export default function ConversationScreen() { }, [messages]); const handleSend = async () => { - if (!inputText.trim() || !id || !user) return; + if (!inputText.trim() || !resolvedConversationId || !user) return; const content = inputText.trim(); setInputText(""); try { await sendMessage({ - conversationId: id as Id<"conversations">, + conversationId: resolvedConversationId, senderId: user._id, senderType: "agent", content, @@ -90,10 +72,10 @@ export default function ConversationScreen() { }; const handleStatusChange = async (status: "open" | "closed" | "snoozed") => { - if (!id) return; + if (!resolvedConversationId) return; try { await updateStatus({ - id: id as Id<"conversations">, + id: resolvedConversationId, status, }); } catch (error) { diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index d35ae48..4d9d3cf 100644 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -1,32 +1,15 @@ import { View, Text, StyleSheet, FlatList, TouchableOpacity, RefreshControl } from "react-native"; -import { useQuery } from "convex/react"; -import { api } from "@opencom/convex"; import { useAuth } from "../../src/contexts/AuthContext"; import { router } from "expo-router"; import { useState, useCallback } from "react"; -import type { Id } from "@opencom/convex/dataModel"; - -interface ConversationItem { - _id: string; - visitorId?: string; - status: "open" | "closed" | "snoozed"; - lastMessageAt?: number; - createdAt: number; - unreadByAgent?: number; - visitor: { - name?: string; - email?: string; - readableId?: string; - } | null; - lastMessage: { - content: string; - senderType: string; - createdAt: number; - } | null; -} +import { useInboxConvex, useVisitorPresenceConvex } from "../../src/hooks/convex/useInboxConvex"; +import type { + MobileConversationItem as ConversationItem, + MobileConversationStatus, +} from "../../src/hooks/convex/types"; function PresenceIndicator({ visitorId }: { visitorId: string }) { - const isOnline = useQuery(api.visitors.isOnline, { visitorId: visitorId as Id<"visitors"> }); + const { isOnline } = useVisitorPresenceConvex(visitorId); return ( ( - undefined - ); - - const inboxPage = useQuery( - api.conversations.listForInbox, - activeWorkspaceId ? { workspaceId: activeWorkspaceId, status: statusFilter } : "skip" - ); - const conversations = (Array.isArray(inboxPage) ? inboxPage : inboxPage?.conversations) as - | ConversationItem[] - | undefined; + const [statusFilter, setStatusFilter] = useState(undefined); + const { inboxPage } = useInboxConvex({ workspaceId: activeWorkspaceId, status: statusFilter }); + const conversations = inboxPage?.conversations as ConversationItem[] | undefined; const onRefresh = useCallback(() => { setRefreshing(true); diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx index 51f3724..2256b60 100644 --- a/apps/mobile/app/(app)/onboarding.tsx +++ b/apps/mobile/app/(app)/onboarding.tsx @@ -10,10 +10,9 @@ import { } from "react-native"; import * as Clipboard from "expo-clipboard"; import { router } from "expo-router"; -import { useMutation, useQuery } from "convex/react"; -import { api } from "@opencom/convex"; import { useAuth } from "../../src/contexts/AuthContext"; import { useBackend } from "../../src/contexts/BackendContext"; +import { useOnboardingConvex } from "../../src/hooks/convex/useOnboardingConvex"; type VerificationStatus = "idle" | "checking" | "success" | "error"; @@ -52,19 +51,13 @@ export default function OnboardingScreen() { const startRequestedRef = useRef(false); const tokenRequestedRef = useRef(false); const verifyTimeoutRef = useRef | null>(null); - - const onboardingState = useQuery( - api.workspaces.getHostedOnboardingState, - workspaceId ? { workspaceId } : "skip" - ); - const integrationSignals = useQuery( - api.workspaces.getHostedOnboardingIntegrationSignals, - workspaceId ? { workspaceId } : "skip" - ); - - const startHostedOnboarding = useMutation(api.workspaces.startHostedOnboarding); - const issueVerificationToken = useMutation(api.workspaces.issueHostedOnboardingVerificationToken); - const completeWidgetStep = useMutation(api.workspaces.completeHostedOnboardingWidgetStep); + const { + onboardingState, + integrationSignals, + startHostedOnboarding, + issueVerificationToken, + completeWidgetStep, + } = useOnboardingConvex(workspaceId); useEffect(() => { if (!onboardingState?.verificationToken) { @@ -331,7 +324,10 @@ await OpencomSDK.initialize({ - {signal.origin ?? signal.currentUrl ?? signal.clientIdentifier ?? "Unknown source"} + {signal.origin ?? + signal.currentUrl ?? + signal.clientIdentifier ?? + "Unknown source"} {" · Last seen "} {formatTimestamp(signal.lastSeenAt)} {" · Active sessions "} diff --git a/apps/mobile/app/(app)/settings.tsx b/apps/mobile/app/(app)/settings.tsx index 27986f6..eae69b4 100644 --- a/apps/mobile/app/(app)/settings.tsx +++ b/apps/mobile/app/(app)/settings.tsx @@ -14,8 +14,7 @@ import { router } from "expo-router"; import { useAuth } from "../../src/contexts/AuthContext"; import { useBackend } from "../../src/contexts/BackendContext"; import { useNotifications } from "../../src/contexts/NotificationContext"; -import { useQuery, useMutation, useAction } from "convex/react"; -import { api } from "@opencom/convex"; +import { useSettingsConvex } from "../../src/hooks/convex/useSettingsConvex"; import { useEffect, useState } from "react"; import type { Id } from "@opencom/convex/dataModel"; @@ -40,31 +39,21 @@ export default function SettingsScreen() { const [signupModalVisible, setSignupModalVisible] = useState(false); const [isSwitchingWorkspace, setIsSwitchingWorkspace] = useState(false); const [pendingWorkspaceId, setPendingWorkspaceId] = useState | null>(null); - const myNotificationPreferences = useQuery( - api.notificationSettings.getMyPreferences, - activeWorkspaceId ? { workspaceId: activeWorkspaceId } : "skip" - ); - - const workspace = useQuery( - api.workspaces.get, - activeWorkspaceId ? { id: activeWorkspaceId } : "skip" - ); - - const members = useQuery( - api.workspaceMembers.listByWorkspace, - activeWorkspaceId ? { workspaceId: activeWorkspaceId } : "skip" - ); - const pushTokens = useQuery( - api.pushTokens.getByUser, - user?._id ? { userId: user._id as Id<"users"> } : "skip" - ); - - const updateAllowedOrigins = useMutation(api.workspaces.updateAllowedOrigins); - const inviteToWorkspace = useAction(api.workspaceMembers.inviteToWorkspace); - const updateRole = useMutation(api.workspaceMembers.updateRole); - const removeMember = useMutation(api.workspaceMembers.remove); - const updateSignupSettings = useMutation(api.workspaces.updateSignupSettings); - const updateMyNotificationPreferences = useMutation(api.notificationSettings.updateMyPreferences); + const { + myNotificationPreferences, + workspace, + members, + pushTokens, + updateAllowedOrigins, + inviteToWorkspace, + updateRole, + removeMember, + updateSignupSettings, + updateMyNotificationPreferences, + } = useSettingsConvex({ + workspaceId: activeWorkspaceId, + userId: user?._id, + }); const isAdmin = activeWorkspace?.role === "admin" || activeWorkspace?.role === "owner"; diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 3fa0485..61fe2b4 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -36,10 +36,10 @@ function AuthNavigationGuard({ children }: { children: React.ReactNode }) { const onBackendScreen = inAuthGroup && segments.length <= 1; const inAppGroup = segments[0] === "(app)"; const onAppRoot = inAppGroup && segments.length === 1; - const currentAppRoute = (segments[1] ?? "") as string; + const currentAppRoute = segments.at(1) ?? ""; const onWorkspaceRoute = inAppGroup && currentAppRoute === "workspace"; const onOnboardingRoute = inAppGroup && currentAppRoute === "onboarding"; - const onHomeEntryRoute = inAppGroup && (onAppRoot || currentAppRoute === "index"); + const onHomeEntryRoute = inAppGroup && (onAppRoot || (currentAppRoute as string) === "index"); const homeRoute = defaultHomePath === "/workspace" diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7b1b715..c32a756 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -18,7 +18,7 @@ "@opencom/convex": "workspace:*", "@opencom/types": "workspace:*", "@react-native-async-storage/async-storage": "^2.1.2", - "convex": "^1.31.7", + "convex": "^1.32.0", "expo": "~54.0.33", "expo-clipboard": "^8.0.8", "expo-constants": "~18.0.0", diff --git a/apps/mobile/src/contexts/AuthContext.tsx b/apps/mobile/src/contexts/AuthContext.tsx index 9c8c07e..7f317c5 100644 --- a/apps/mobile/src/contexts/AuthContext.tsx +++ b/apps/mobile/src/contexts/AuthContext.tsx @@ -1,28 +1,12 @@ import React, { createContext, useContext, useCallback, useEffect, useMemo, useState } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useMutation, useQuery } from "convex/react"; import { useAuthActions } from "@convex-dev/auth/react"; -import { api } from "@opencom/convex"; import type { Id } from "@opencom/convex/dataModel"; import { useBackend } from "./BackendContext"; +import { useAuthContextConvex, useAuthHomeRouteConvex } from "../hooks/convex/useAuthConvex"; +import type { MobileAuthUser as User, MobileWorkspace as Workspace } from "../hooks/convex/types"; import { parseStoredWorkspaceId, resolveActiveWorkspaceId } from "../utils/workspaceSelection"; -interface User { - _id: Id<"users">; - email: string; - name?: string; - workspaceId: Id<"workspaces">; - role: "owner" | "admin" | "agent" | "viewer"; - avatarUrl?: string; -} - -interface Workspace { - _id: Id<"workspaces">; - name: string; - role: "owner" | "admin" | "agent" | "viewer"; - allowedOrigins?: string[]; -} - type HomePath = "/workspace" | "/onboarding" | "/inbox"; interface AuthContextType { @@ -56,19 +40,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Convex Auth hooks const { signIn: convexSignIn, signOut: convexSignOut } = useAuthActions(); - - // Query current user from Convex Auth session - const convexAuthUser = useQuery(api.auth.currentUser); - const switchWorkspaceMutation = useMutation(api.auth.switchWorkspace); - const completeSignupProfileMutation = useMutation(api.auth.completeSignupProfile); - const unregisterAllPushTokensMutation = useMutation(api.pushTokens.unregisterAllForCurrentUser); + const { + currentUser: convexAuthUser, + switchWorkspace: switchWorkspaceMutation, + completeSignupProfile: completeSignupProfileMutation, + unregisterAllPushTokens: unregisterAllPushTokensMutation, + } = useAuthContextConvex(); // Derive state from query - const user = useMemo(() => (convexAuthUser?.user as User | null) ?? null, [convexAuthUser]); - const workspaces = useMemo( - () => (convexAuthUser?.workspaces as Workspace[] | undefined) ?? [], - [convexAuthUser] - ); + const user = useMemo(() => convexAuthUser?.user ?? null, [convexAuthUser]); + const workspaces = useMemo(() => convexAuthUser?.workspaces ?? [], [convexAuthUser]); const workspaceIds = useMemo(() => workspaces.map((workspace) => workspace._id), [workspaces]); const workspaceIdsKey = useMemo(() => workspaceIds.join(","), [workspaceIds]); const workspaceStorageKey = useMemo(() => { @@ -156,11 +137,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const shouldRequireWorkspaceSelection = requiresWorkspaceSelection && workspaces.length > 1; const shouldResolveHostedOnboarding = isAuthenticated && !!workspaceIdForHomeRouting && !shouldRequireWorkspaceSelection; - const hostedOnboardingState = useQuery( - api.workspaces.getHostedOnboardingState, - shouldResolveHostedOnboarding && workspaceIdForHomeRouting - ? { workspaceId: workspaceIdForHomeRouting } - : "skip" + const { hostedOnboardingState } = useAuthHomeRouteConvex( + workspaceIdForHomeRouting, + shouldResolveHostedOnboarding ); const isHomeRouteLoading = shouldResolveHostedOnboarding && hostedOnboardingState === undefined; const defaultHomePath: HomePath = shouldRequireWorkspaceSelection diff --git a/apps/mobile/src/contexts/NotificationContext.tsx b/apps/mobile/src/contexts/NotificationContext.tsx index 2fda6a8..28bc92c 100644 --- a/apps/mobile/src/contexts/NotificationContext.tsx +++ b/apps/mobile/src/contexts/NotificationContext.tsx @@ -1,11 +1,10 @@ -import React, { createContext, useContext, useEffect, useRef, useState } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import * as Notifications from "expo-notifications"; import Constants from "expo-constants"; import { Platform } from "react-native"; -import { useMutation } from "convex/react"; -import { api } from "@opencom/convex"; import { useAuth } from "./AuthContext"; import { router, usePathname } from "expo-router"; +import { useNotificationRegistrationConvex } from "../hooks/convex/useNotificationRegistrationConvex"; import { getActiveConversationIdFromPath, getConversationIdFromPayload, @@ -46,8 +45,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } const pathname = usePathname(); const { user, isAuthenticated } = useAuth(); - const registerToken = useMutation(api.pushTokens.register); - const debugLog = useMutation(api.pushTokens.debugLog); + const { registerPushToken: registerToken, debugLog } = useNotificationRegistrationConvex(); useEffect(() => { activeConversationIdRef.current = getActiveConversationIdFromPath(pathname); @@ -75,13 +73,16 @@ export function NotificationProvider({ children }: { children: React.ReactNode } }); }, []); - const sendDebugLog = async (stage: string, details?: string) => { - try { - await debugLog({ stage, details }); - } catch (error) { - console.warn("Failed to write push registration debug log", error); - } - }; + const sendDebugLog = useCallback( + async (stage: string, details?: string) => { + try { + await debugLog({ stage, details }); + } catch (error) { + console.warn("Failed to write push registration debug log", error); + } + }, + [debugLog] + ); useEffect(() => { if (!isAuthenticated || !user) return; @@ -197,7 +198,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } responseListener.current.remove(); } }; - }, [isAuthenticated, user, registerToken]); + }, [isAuthenticated, user, registerToken, sendDebugLog]); return ( ; + email: string; + name?: string; + workspaceId: Id<"workspaces">; + role: MobileWorkspaceRole; + avatarUrl?: string; +} + +export interface MobileWorkspace { + _id: Id<"workspaces">; + name: string; + role: MobileWorkspaceRole; + allowedOrigins?: string[]; +} + +export type MobileCurrentUserRecord = { + user: MobileAuthUser | null; + workspaces: MobileWorkspace[]; +} | null; + +export type HostedOnboardingStatus = "not_started" | "in_progress" | "completed"; + +export type HostedOnboardingView = { + status: HostedOnboardingStatus; + currentStep: number; + completedSteps: string[]; + onboardingVerificationToken: string | null; + verificationToken: string | null; + verificationTokenIssuedAt: number | null; + widgetVerifiedAt: number | null; + isWidgetVerified: boolean; + updatedAt: number | null; +}; + +export type HostedOnboardingState = + | (HostedOnboardingView & { + hasRecognizedInstall: boolean; + latestDetectedAt: number | null; + latestRecognizedDetectedAt: number | null; + detectedIntegrationCount: number; + }) + | null; + +export type HostedOnboardingVerificationTokenResult = { + token: string; + issuedAt: number; +}; + +export type CompleteHostedOnboardingWidgetStepResult = + | { + success: true; + status: "completed"; + currentStep: number; + completedSteps: string[]; + updatedAt: number; + } + | { + success: false; + reason: "token_mismatch" | "not_verified"; + }; + +export type HostedOnboardingIntegrationSignal = { + id: string; + clientType: string; + clientVersion: string | null; + clientIdentifier: string | null; + origin: string | null; + currentUrl: string | null; + devicePlatform: string | null; + sessionCount: number; + activeSessionCount: number; + lastSeenAt: number; + latestSessionExpiresAt: number; + isActiveNow: boolean; + matchesCurrentVerificationWindow: boolean; +}; + +export type HostedOnboardingIntegrationSignals = { + tokenIssuedAt: number | null; + hasRecognizedInstall: boolean; + latestDetectedAt: number | null; + latestRecognizedDetectedAt: number | null; + integrations: HostedOnboardingIntegrationSignal[]; +} | null; + +export type MobileNotificationPreferencesRecord = { + defaults: { + newVisitorMessageEmail: boolean; + newVisitorMessagePush: boolean; + }; + overrides: { + newVisitorMessageEmail: boolean | null; + newVisitorMessagePush: boolean | null; + }; + effective: { + newVisitorMessageEmail: boolean; + newVisitorMessagePush: boolean; + }; + muted: boolean; +} | null; + +export type MobileWorkspaceRecord = { + _id: Id<"workspaces">; + allowedOrigins?: string[]; + signupMode?: "invite-only" | "domain-allowlist"; + allowedDomains?: string[]; +} | null; + +export interface MobileWorkspaceMemberRecord { + _id: Id<"workspaceMembers">; + userId: Id<"users">; + name?: string; + email?: string; + role: MobileWorkspaceRole; +} + +export type InviteToWorkspaceResult = { + status: "added" | "invited"; +}; + +export type MobilePushTokenRecord = { + _id: string; +}; + +export interface MobileConversationItem { + _id: string; + visitorId?: string; + status: MobileConversationStatus; + lastMessageAt?: number; + createdAt: number; + unreadByAgent?: number; + visitor: { + name?: string; + email?: string; + readableId?: string; + } | null; + lastMessage: { + content: string; + senderType: string; + createdAt: number; + } | null; +} + +export type MobileInboxPageResult = { + conversations: MobileConversationItem[]; + nextCursor: string | null; +}; + +export type MobileConversationRecord = { + _id: Id<"conversations">; + visitorId?: Id<"visitors">; + status: MobileConversationStatus; +}; + +export type MobileVisitorRecord = { + _id: Id<"visitors">; + name?: string; + email?: string; + readableId?: string; + location?: { city?: string; country?: string }; + device?: { browser?: string; os?: string }; +} | null; + +export interface MobileConversationMessage { + _id: string; + content: string; + senderType: "user" | "visitor" | "agent" | "bot"; + createdAt: number; +} diff --git a/apps/mobile/src/hooks/convex/useAuthConvex.ts b/apps/mobile/src/hooks/convex/useAuthConvex.ts new file mode 100644 index 0000000..b24fd0b --- /dev/null +++ b/apps/mobile/src/hooks/convex/useAuthConvex.ts @@ -0,0 +1,79 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; +import type { HostedOnboardingState, MobileAuthUser, MobileCurrentUserRecord } from "./types"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type SwitchWorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CompleteSignupProfileArgs = { + name?: string; + workspaceName?: string; +}; + +type SwitchWorkspaceResult = { + user: MobileAuthUser; +}; + +type CompleteSignupProfileResult = { + success: true; + userNameUpdated: boolean; + workspaceNameUpdated: boolean; +}; + +type UnregisterAllPushTokensResult = { + success: true; + removed: number; +}; + +const CURRENT_USER_QUERY_REF = makeFunctionReference< + "query", + Record, + MobileCurrentUserRecord +>("auth:currentUser"); +const SWITCH_WORKSPACE_MUTATION_REF = makeFunctionReference< + "mutation", + SwitchWorkspaceArgs, + SwitchWorkspaceResult +>("auth:switchWorkspace"); +const COMPLETE_SIGNUP_PROFILE_MUTATION_REF = makeFunctionReference< + "mutation", + CompleteSignupProfileArgs, + CompleteSignupProfileResult +>("auth:completeSignupProfile"); +const UNREGISTER_ALL_PUSH_TOKENS_MUTATION_REF = makeFunctionReference< + "mutation", + Record, + UnregisterAllPushTokensResult +>("pushTokens:unregisterAllForCurrentUser"); +const HOSTED_ONBOARDING_STATE_QUERY_REF = makeFunctionReference< + "query", + WorkspaceArgs, + HostedOnboardingState +>("workspaces:getHostedOnboardingState"); + +export function useAuthContextConvex() { + return { + completeSignupProfile: useMobileMutation(COMPLETE_SIGNUP_PROFILE_MUTATION_REF), + currentUser: useMobileQuery(CURRENT_USER_QUERY_REF, {}), + switchWorkspace: useMobileMutation(SWITCH_WORKSPACE_MUTATION_REF), + unregisterAllPushTokens: useMobileMutation(UNREGISTER_ALL_PUSH_TOKENS_MUTATION_REF), + }; +} + +export function useAuthHomeRouteConvex( + workspaceIdForHomeRouting?: Id<"workspaces"> | null, + enabled = true +) { + return { + hostedOnboardingState: useMobileQuery( + HOSTED_ONBOARDING_STATE_QUERY_REF, + enabled && workspaceIdForHomeRouting ? { workspaceId: workspaceIdForHomeRouting } : "skip" + ), + }; +} diff --git a/apps/mobile/src/hooks/convex/useConversationConvex.ts b/apps/mobile/src/hooks/convex/useConversationConvex.ts new file mode 100644 index 0000000..6e45957 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useConversationConvex.ts @@ -0,0 +1,95 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; +import type { + MobileConversationMessage, + MobileConversationRecord, + MobileConversationStatus, + MobileVisitorRecord, +} from "./types"; + +type ConversationIdArgs = { + id: Id<"conversations">; +}; + +type MessagesListArgs = { + conversationId: Id<"conversations">; +}; + +type SendMessageArgs = { + conversationId: Id<"conversations">; + senderId: Id<"users">; + senderType: "agent"; + content: string; +}; + +type UpdateConversationStatusArgs = { + id: Id<"conversations">; + status: MobileConversationStatus; +}; + +type MarkConversationReadArgs = { + id: Id<"conversations">; + readerType: "agent" | "visitor"; +}; + +const CONVERSATION_GET_QUERY_REF = makeFunctionReference< + "query", + ConversationIdArgs, + MobileConversationRecord | null +>("conversations:get"); +const VISITOR_GET_QUERY_REF = makeFunctionReference< + "query", + { id: Id<"visitors"> }, + MobileVisitorRecord +>("visitors:get"); +const MESSAGES_LIST_QUERY_REF = makeFunctionReference< + "query", + MessagesListArgs, + MobileConversationMessage[] +>("messages:list"); +const SEND_MESSAGE_MUTATION_REF = makeFunctionReference< + "mutation", + SendMessageArgs, + Id<"messages"> +>("messages:send"); +const UPDATE_CONVERSATION_STATUS_MUTATION_REF = makeFunctionReference< + "mutation", + UpdateConversationStatusArgs, + null +>("conversations:updateStatus"); +const MARK_CONVERSATION_READ_MUTATION_REF = makeFunctionReference< + "mutation", + MarkConversationReadArgs, + null +>("conversations:markAsRead"); + +function resolveConversationId( + conversationId?: string | Id<"conversations"> | null +): Id<"conversations"> | null { + return conversationId ? (conversationId as Id<"conversations">) : null; +} + +export function useConversationConvex(conversationId?: string | Id<"conversations"> | null) { + const resolvedConversationId = resolveConversationId(conversationId); + const conversation = useMobileQuery( + CONVERSATION_GET_QUERY_REF, + resolvedConversationId ? { id: resolvedConversationId } : "skip" + ); + + return { + conversation, + markConversationRead: useMobileMutation(MARK_CONVERSATION_READ_MUTATION_REF), + messages: useMobileQuery( + MESSAGES_LIST_QUERY_REF, + resolvedConversationId ? { conversationId: resolvedConversationId } : "skip" + ), + resolvedConversationId, + sendMessage: useMobileMutation(SEND_MESSAGE_MUTATION_REF), + updateConversationStatus: useMobileMutation(UPDATE_CONVERSATION_STATUS_MUTATION_REF), + visitor: useMobileQuery( + VISITOR_GET_QUERY_REF, + conversation?.visitorId ? { id: conversation.visitorId } : "skip" + ), + }; +} diff --git a/apps/mobile/src/hooks/convex/useInboxConvex.ts b/apps/mobile/src/hooks/convex/useInboxConvex.ts new file mode 100644 index 0000000..63af4e7 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useInboxConvex.ts @@ -0,0 +1,49 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileQuery } from "../../lib/convex/hooks"; +import type { MobileConversationStatus, MobileInboxPageResult } from "./types"; + +type VisitorArgs = { + visitorId: Id<"visitors">; +}; + +type InboxArgs = { + workspaceId: Id<"workspaces">; + status?: MobileConversationStatus; +}; + +const VISITOR_IS_ONLINE_QUERY_REF = makeFunctionReference<"query", VisitorArgs, boolean>( + "visitors:isOnline" +); +const INBOX_LIST_QUERY_REF = makeFunctionReference<"query", InboxArgs, MobileInboxPageResult>( + "conversations:listForInbox" +); + +type UseInboxConvexOptions = { + workspaceId?: Id<"workspaces"> | null; + status?: MobileConversationStatus; +}; + +export function useInboxConvex({ workspaceId, status }: UseInboxConvexOptions) { + const inboxArgs = workspaceId + ? { + workspaceId, + ...(status ? { status } : {}), + } + : "skip"; + + return { + inboxPage: useMobileQuery(INBOX_LIST_QUERY_REF, inboxArgs), + }; +} + +export function useVisitorPresenceConvex(visitorId?: string | Id<"visitors"> | null) { + const resolvedVisitorId = visitorId ? (visitorId as Id<"visitors">) : null; + + return { + isOnline: useMobileQuery( + VISITOR_IS_ONLINE_QUERY_REF, + resolvedVisitorId ? { visitorId: resolvedVisitorId } : "skip" + ), + }; +} diff --git a/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts b/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts new file mode 100644 index 0000000..f886f33 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts @@ -0,0 +1,37 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation } from "../../lib/convex/hooks"; + +type RegisterPushTokenArgs = { + token: string; + userId: Id<"users">; + platform: "ios" | "android"; +}; + +type PushDebugLogArgs = { + stage: string; + details?: string; +}; + +type PushDebugLogResult = { + success: true; + authUserId: Id<"users"> | null; +}; + +const REGISTER_PUSH_TOKEN_MUTATION_REF = makeFunctionReference< + "mutation", + RegisterPushTokenArgs, + Id<"pushTokens"> +>("pushTokens:register"); +const PUSH_DEBUG_LOG_MUTATION_REF = makeFunctionReference< + "mutation", + PushDebugLogArgs, + PushDebugLogResult +>("pushTokens:debugLog"); + +export function useNotificationRegistrationConvex() { + return { + debugLog: useMobileMutation(PUSH_DEBUG_LOG_MUTATION_REF), + registerPushToken: useMobileMutation(REGISTER_PUSH_TOKEN_MUTATION_REF), + }; +} diff --git a/apps/mobile/src/hooks/convex/useOnboardingConvex.ts b/apps/mobile/src/hooks/convex/useOnboardingConvex.ts new file mode 100644 index 0000000..f92f895 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useOnboardingConvex.ts @@ -0,0 +1,61 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; +import type { + CompleteHostedOnboardingWidgetStepResult, + HostedOnboardingIntegrationSignals, + HostedOnboardingState, + HostedOnboardingVerificationTokenResult, + HostedOnboardingView, +} from "./types"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CompleteWidgetStepArgs = { + workspaceId: Id<"workspaces">; + token?: string; +}; + +const HOSTED_ONBOARDING_STATE_QUERY_REF = makeFunctionReference< + "query", + WorkspaceArgs, + HostedOnboardingState +>("workspaces:getHostedOnboardingState"); +const HOSTED_ONBOARDING_SIGNALS_QUERY_REF = makeFunctionReference< + "query", + WorkspaceArgs, + HostedOnboardingIntegrationSignals +>("workspaces:getHostedOnboardingIntegrationSignals"); +const START_HOSTED_ONBOARDING_MUTATION_REF = makeFunctionReference< + "mutation", + WorkspaceArgs, + HostedOnboardingView +>("workspaces:startHostedOnboarding"); +const ISSUE_VERIFICATION_TOKEN_MUTATION_REF = makeFunctionReference< + "mutation", + WorkspaceArgs, + HostedOnboardingVerificationTokenResult +>("workspaces:issueHostedOnboardingVerificationToken"); +const COMPLETE_WIDGET_STEP_MUTATION_REF = makeFunctionReference< + "mutation", + CompleteWidgetStepArgs, + CompleteHostedOnboardingWidgetStepResult +>("workspaces:completeHostedOnboardingWidgetStep"); + +export function useOnboardingConvex(workspaceId?: Id<"workspaces"> | null) { + return { + completeWidgetStep: useMobileMutation(COMPLETE_WIDGET_STEP_MUTATION_REF), + integrationSignals: useMobileQuery( + HOSTED_ONBOARDING_SIGNALS_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + issueVerificationToken: useMobileMutation(ISSUE_VERIFICATION_TOKEN_MUTATION_REF), + onboardingState: useMobileQuery( + HOSTED_ONBOARDING_STATE_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + startHostedOnboarding: useMobileMutation(START_HOSTED_ONBOARDING_MUTATION_REF), + }; +} diff --git a/apps/mobile/src/hooks/convex/useSettingsConvex.ts b/apps/mobile/src/hooks/convex/useSettingsConvex.ts new file mode 100644 index 0000000..44fd7c8 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useSettingsConvex.ts @@ -0,0 +1,137 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileAction, useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; +import type { + InviteToWorkspaceResult, + MobileNotificationPreferencesRecord, + MobilePushTokenRecord, + MobileWorkspaceMemberRecord, + MobileWorkspaceRecord, +} from "./types"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type WorkspaceGetArgs = { + id: Id<"workspaces">; +}; + +type PushTokensByUserArgs = { + userId: Id<"users">; +}; + +type UpdateAllowedOriginsArgs = { + workspaceId: Id<"workspaces">; + allowedOrigins: string[]; +}; + +type InviteToWorkspaceArgs = { + workspaceId: Id<"workspaces">; + email: string; + role: "admin" | "agent"; + baseUrl: string; +}; + +type UpdateWorkspaceRoleArgs = { + membershipId: Id<"workspaceMembers">; + role: "admin" | "agent"; +}; + +type RemoveWorkspaceMemberArgs = { + membershipId: Id<"workspaceMembers">; +}; + +type UpdateSignupSettingsArgs = { + workspaceId: Id<"workspaces">; + signupMode: "invite-only" | "domain-allowlist"; + allowedDomains: string[]; +}; + +type UpdateMyNotificationPreferencesArgs = { + workspaceId: Id<"workspaces">; + muted: boolean; +}; + +type MutationSuccessResult = { + success: true; +}; + +const MY_NOTIFICATION_PREFERENCES_QUERY_REF = makeFunctionReference< + "query", + WorkspaceArgs, + MobileNotificationPreferencesRecord +>("notificationSettings:getMyPreferences"); +const WORKSPACE_GET_QUERY_REF = makeFunctionReference< + "query", + WorkspaceGetArgs, + MobileWorkspaceRecord +>("workspaces:get"); +const WORKSPACE_MEMBERS_LIST_QUERY_REF = makeFunctionReference< + "query", + WorkspaceArgs, + MobileWorkspaceMemberRecord[] +>("workspaceMembers:listByWorkspace"); +const PUSH_TOKENS_BY_USER_QUERY_REF = makeFunctionReference< + "query", + PushTokensByUserArgs, + MobilePushTokenRecord[] +>("pushTokens:getByUser"); +const UPDATE_ALLOWED_ORIGINS_MUTATION_REF = makeFunctionReference< + "mutation", + UpdateAllowedOriginsArgs, + void +>("workspaces:updateAllowedOrigins"); +const INVITE_TO_WORKSPACE_ACTION_REF = makeFunctionReference< + "action", + InviteToWorkspaceArgs, + InviteToWorkspaceResult +>("workspaceMembers:inviteToWorkspace"); +const UPDATE_WORKSPACE_ROLE_MUTATION_REF = makeFunctionReference< + "mutation", + UpdateWorkspaceRoleArgs, + MutationSuccessResult +>("workspaceMembers:updateRole"); +const REMOVE_WORKSPACE_MEMBER_MUTATION_REF = makeFunctionReference< + "mutation", + RemoveWorkspaceMemberArgs, + MutationSuccessResult +>("workspaceMembers:remove"); +const UPDATE_SIGNUP_SETTINGS_MUTATION_REF = makeFunctionReference< + "mutation", + UpdateSignupSettingsArgs, + void +>("workspaces:updateSignupSettings"); +const UPDATE_MY_NOTIFICATION_PREFERENCES_MUTATION_REF = makeFunctionReference< + "mutation", + UpdateMyNotificationPreferencesArgs, + Id<"notificationPreferences"> +>("notificationSettings:updateMyPreferences"); + +type UseSettingsConvexOptions = { + workspaceId?: Id<"workspaces"> | null; + userId?: Id<"users"> | null; +}; + +export function useSettingsConvex({ workspaceId, userId }: UseSettingsConvexOptions) { + return { + inviteToWorkspace: useMobileAction(INVITE_TO_WORKSPACE_ACTION_REF), + members: useMobileQuery( + WORKSPACE_MEMBERS_LIST_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + myNotificationPreferences: useMobileQuery( + MY_NOTIFICATION_PREFERENCES_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + pushTokens: useMobileQuery(PUSH_TOKENS_BY_USER_QUERY_REF, userId ? { userId } : "skip"), + removeMember: useMobileMutation(REMOVE_WORKSPACE_MEMBER_MUTATION_REF), + updateAllowedOrigins: useMobileMutation(UPDATE_ALLOWED_ORIGINS_MUTATION_REF), + updateMyNotificationPreferences: useMobileMutation( + UPDATE_MY_NOTIFICATION_PREFERENCES_MUTATION_REF + ), + updateRole: useMobileMutation(UPDATE_WORKSPACE_ROLE_MUTATION_REF), + updateSignupSettings: useMobileMutation(UPDATE_SIGNUP_SETTINGS_MUTATION_REF), + workspace: useMobileQuery(WORKSPACE_GET_QUERY_REF, workspaceId ? { id: workspaceId } : "skip"), + }; +} diff --git a/apps/mobile/src/lib/convex/hooks.ts b/apps/mobile/src/lib/convex/hooks.ts new file mode 100644 index 0000000..dbe5849 --- /dev/null +++ b/apps/mobile/src/lib/convex/hooks.ts @@ -0,0 +1,59 @@ +import { + type OptionalRestArgsOrSkip, + type ReactAction, + type ReactMutation, + useAction, + useMutation, + useQuery, +} from "convex/react"; +import type { FunctionReference } from "convex/server"; + +type MobileArgs = Record; + +export type MobileQueryRef = FunctionReference< + "query", + "public", + Args, + Result +>; + +export type MobileMutationRef = FunctionReference< + "mutation", + "public", + Args, + Result +>; + +export type MobileActionRef = FunctionReference< + "action", + "public", + Args, + Result +>; + +function toMobileQueryArgs( + args: Args | "skip" +): OptionalRestArgsOrSkip> { + return (args === "skip" ? ["skip"] : [args]) as OptionalRestArgsOrSkip< + MobileQueryRef + >; +} + +export function useMobileQuery( + queryRef: MobileQueryRef, + args: Args | "skip" +): Result | undefined { + return useQuery(queryRef, ...toMobileQueryArgs(args)); +} + +export function useMobileMutation( + mutationRef: MobileMutationRef +): ReactMutation> { + return useMutation(mutationRef); +} + +export function useMobileAction( + actionRef: MobileActionRef +): ReactAction> { + return useAction(actionRef); +} diff --git a/apps/mobile/src/typeHardeningGuard.test.ts b/apps/mobile/src/typeHardeningGuard.test.ts new file mode 100644 index 0000000..e165abd --- /dev/null +++ b/apps/mobile/src/typeHardeningGuard.test.ts @@ -0,0 +1,181 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, extname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const MOBILE_SRC_DIR = dirname(fileURLToPath(import.meta.url)); +const MOBILE_ROOT_DIR = resolve(MOBILE_SRC_DIR, ".."); +const MOBILE_APP_DIR = resolve(MOBILE_ROOT_DIR, "app"); + +const MOBILE_CONVEX_ADAPTER_PATH = resolve(MOBILE_SRC_DIR, "lib/convex/hooks.ts"); +const MOBILE_PROVIDER_BOUNDARY_PATH = resolve(MOBILE_ROOT_DIR, "app/_layout.tsx"); +const APPROVED_DIRECT_CONVEX_IMPORT_FILES = [ + MOBILE_CONVEX_ADAPTER_PATH, + MOBILE_PROVIDER_BOUNDARY_PATH, + resolve(MOBILE_SRC_DIR, "typeHardeningGuard.test.ts"), +]; + +const WRAPPER_LAYER_FILES = [ + "hooks/convex/useAuthConvex.ts", + "hooks/convex/useConversationConvex.ts", + "hooks/convex/useInboxConvex.ts", + "hooks/convex/useNotificationRegistrationConvex.ts", + "hooks/convex/useOnboardingConvex.ts", + "hooks/convex/useSettingsConvex.ts", +].map((path) => resolve(MOBILE_SRC_DIR, path)); +const APPROVED_DIRECT_REF_FACTORY_FILES = [ + ...WRAPPER_LAYER_FILES, + resolve(MOBILE_SRC_DIR, "typeHardeningGuard.test.ts"), +]; + +const MIGRATED_MOBILE_CONSUMERS = [ + ["src/contexts/AuthContext.tsx", ["useAuthContextConvex", "useAuthHomeRouteConvex"]], + ["src/contexts/NotificationContext.tsx", ["useNotificationRegistrationConvex"]], + ["app/(app)/conversation/[id].tsx", ["useConversationConvex"]], + ["app/(app)/index.tsx", ["useInboxConvex", "useVisitorPresenceConvex"]], + ["app/(app)/onboarding.tsx", ["useOnboardingConvex"]], + ["app/(app)/settings.tsx", ["useSettingsConvex"]], +] as const; + +const DIRECT_CONVEX_IMPORT_PATTERN = /from ["']convex\/react["']/; +const DIRECT_REF_FACTORY_PATTERN = /\bmakeFunctionReference(?:\s*<[\s\S]*?>)?\s*\(/; +const MOBILE_ADAPTER_HOOK_PATTERN = /\buseMobile(?:Query|Mutation|Action)\b/; +const COMPONENT_SCOPED_CONVEX_REF_PATTERNS = [ + /^\s{2,}(const|let)\s+\w+\s*=\s*makeFunctionReference(?:<|\()/, + /use(?:Query|Mutation|Action)\(\s*makeFunctionReference(?:<|\()/, +]; + +function collectSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const entryPath = resolve(dir, entry.name); + + if (entry.isDirectory()) { + return collectSourceFiles(entryPath); + } + + if (!entry.isFile()) { + return []; + } + + const extension = extname(entry.name); + return extension === ".ts" || extension === ".tsx" ? [entryPath] : []; + }); +} + +function toPortableRelativePath(filePath: string): string { + return relative(MOBILE_ROOT_DIR, filePath).replace(/\\/g, "/"); +} + +function isApprovedDirectConvexImport(filePath: string): boolean { + return APPROVED_DIRECT_CONVEX_IMPORT_FILES.includes(filePath); +} + +function isApprovedDirectRefFactory(filePath: string): boolean { + return APPROVED_DIRECT_REF_FACTORY_FILES.includes(filePath); +} + +function findUnexpectedMobileDirectConvexBoundaries(): string[] { + return [...collectSourceFiles(MOBILE_APP_DIR), ...collectSourceFiles(MOBILE_SRC_DIR)].flatMap( + (filePath) => { + const source = readFileSync(filePath, "utf8"); + const violations: string[] = []; + + if (DIRECT_CONVEX_IMPORT_PATTERN.test(source) && !isApprovedDirectConvexImport(filePath)) { + violations.push(`${toPortableRelativePath(filePath)}: direct convex/react import`); + } + + if (DIRECT_REF_FACTORY_PATTERN.test(source) && !isApprovedDirectRefFactory(filePath)) { + violations.push(`${toPortableRelativePath(filePath)}: direct makeFunctionReference call`); + } + + return violations; + } + ); +} + +function findComponentScopedConvexRefs(dir: string): string[] { + return collectSourceFiles(dir).flatMap((filePath) => { + if (!isApprovedDirectRefFactory(filePath)) { + return []; + } + + const source = readFileSync(filePath, "utf8"); + + return source + .split("\n") + .flatMap((line, index) => + COMPONENT_SCOPED_CONVEX_REF_PATTERNS.some((pattern) => pattern.test(line)) + ? [`${toPortableRelativePath(filePath)}:${index + 1}`] + : [] + ); + }); +} + +describe("mobile convex ref hardening guards", () => { + it("keeps mobile React files free of component-scoped Convex ref factories", () => { + expect([ + ...findComponentScopedConvexRefs(MOBILE_APP_DIR), + ...findComponentScopedConvexRefs(MOBILE_SRC_DIR), + ]).toEqual([]); + }); + + it("keeps direct convex imports and ref factories limited to approved boundaries", () => { + expect(findUnexpectedMobileDirectConvexBoundaries()).toEqual([]); + }); + + it("keeps the approved direct convex import boundaries explicit", () => { + expect( + APPROVED_DIRECT_CONVEX_IMPORT_FILES.map((filePath) => toPortableRelativePath(filePath)) + ).toEqual(["src/lib/convex/hooks.ts", "app/_layout.tsx", "src/typeHardeningGuard.test.ts"]); + }); + + it("keeps the approved direct ref factory files explicit", () => { + expect( + APPROVED_DIRECT_REF_FACTORY_FILES.map((filePath) => toPortableRelativePath(filePath)) + ).toEqual([ + "src/hooks/convex/useAuthConvex.ts", + "src/hooks/convex/useConversationConvex.ts", + "src/hooks/convex/useInboxConvex.ts", + "src/hooks/convex/useNotificationRegistrationConvex.ts", + "src/hooks/convex/useOnboardingConvex.ts", + "src/hooks/convex/useSettingsConvex.ts", + "src/typeHardeningGuard.test.ts", + ]); + }); + + it("provides a mobile-local Convex adapter layer for typed wrapper hooks", () => { + const source = readFileSync(MOBILE_CONVEX_ADAPTER_PATH, "utf8"); + + expect(source).toContain("export type MobileQueryRef"); + expect(source).toContain("export type MobileMutationRef"); + expect(source).toContain("export type MobileActionRef"); + expect(source).toContain("export function useMobileQuery"); + expect(source).toContain("export function useMobileMutation"); + expect(source).toContain("export function useMobileAction"); + expect(source).toContain("function toMobileQueryArgs"); + expect(source).toContain("OptionalRestArgsOrSkip"); + expect(source).not.toContain("makeFunctionReference("); + }); + + it("keeps wrapper-layer escape hatches in mobile-local wrapper files", () => { + for (const filePath of WRAPPER_LAYER_FILES) { + const source = readFileSync(filePath, "utf8"); + + expect(MOBILE_ADAPTER_HOOK_PATTERN.test(source)).toBe(true); + expect(DIRECT_REF_FACTORY_PATTERN.test(source)).toBe(true); + } + }); + + it("keeps migrated mobile consumers on local wrapper hooks", () => { + for (const [relativePath, markers] of MIGRATED_MOBILE_CONSUMERS) { + const source = readFileSync(resolve(MOBILE_ROOT_DIR, relativePath), "utf8"); + + expect(source).not.toContain('from "convex/react"'); + expect(source).not.toContain("makeFunctionReference("); + + for (const marker of markers) { + expect(source).toContain(marker); + } + } + }); +}); diff --git a/apps/web/e2e/SKIP_REGISTRY.md b/apps/web/e2e/SKIP_REGISTRY.md index 35067c7..84df3f4 100644 --- a/apps/web/e2e/SKIP_REGISTRY.md +++ b/apps/web/e2e/SKIP_REGISTRY.md @@ -31,7 +31,6 @@ All 5 are in `apps/web/e2e/widget-features.spec.ts` and are currently conditiona These are guardrails that may skip depending on environment/auth/data state: - `TEST_ADMIN_SECRET` prerequisites: - - `apps/web/e2e/public-pages.spec.ts` - `apps/web/e2e/widget-outbound-and-tour-recovery.spec.ts` - Auth bootstrap guard (`Could not authenticate test page`) in: - `apps/web/e2e/audit-logs.spec.ts` @@ -42,7 +41,6 @@ These are guardrails that may skip depending on environment/auth/data state: - `apps/web/e2e/snippets.spec.ts` - `apps/web/e2e/widget-features.spec.ts` - Feature/route availability guards: - - `apps/web/e2e/knowledge.spec.ts` - `apps/web/e2e/segments.spec.ts` - `apps/web/e2e/home-settings.spec.ts` (`Messenger Home section not visible after retry`) diff --git a/apps/web/e2e/ai-agent-settings.spec.ts b/apps/web/e2e/ai-agent-settings.spec.ts index 11e58d4..f1dc6da 100644 --- a/apps/web/e2e/ai-agent-settings.spec.ts +++ b/apps/web/e2e/ai-agent-settings.spec.ts @@ -1,9 +1,5 @@ import { test, expect } from "./fixtures"; -import { - ensureAuthenticatedInPage, - gotoWithAuthRecovery, - refreshAuthState, -} from "./helpers/auth-refresh"; +import { ensureAuthenticatedInPage, gotoWithAuthRecovery } from "./helpers/auth-refresh"; const AUTH_ROUTE_RE = /\/(login|signup)(\/|$|\?)/; @@ -18,6 +14,7 @@ function isAuthRoute(page: import("@playwright/test").Page): boolean { async function openSettings(page: import("@playwright/test").Page): Promise { for (let attempt = 0; attempt < 4; attempt += 1) { await gotoWithAuthRecovery(page, "/settings"); + await page.waitForLoadState("networkidle").catch(() => {}); if (isAuthRoute(page)) { const recovered = await ensureAuthenticatedInPage(page); @@ -27,24 +24,32 @@ async function openSettings(page: import("@playwright/test").Page): Promise false)) { + await expect(page.getByRole("heading", { name: /^settings$/i })).toBeVisible({ + timeout: 10000, + }); + + const aiSection = page.locator("#ai-agent"); + await aiSection.scrollIntoViewIfNeeded().catch(() => {}); + + const aiSectionToggle = page.getByTestId("settings-section-toggle-ai-agent"); + if (await aiSectionToggle.isVisible({ timeout: 10000 }).catch(() => false)) { + const isExpanded = (await aiSectionToggle.getAttribute("aria-expanded")) === "true"; + if (!isExpanded) { + await aiSectionToggle.click({ timeout: 5000 }); + } + + await expect(page.locator("#ai-agent-content")).toBeVisible({ timeout: 10000 }); return; } await page.waitForTimeout(500); } - await expect(page.getByRole("heading", { name: /ai agent/i })).toBeVisible({ timeout: 15000 }); + await expect(page.locator("#ai-agent-content")).toBeVisible({ timeout: 15000 }); } test.describe("Web Admin - AI Agent Settings", () => { - test.beforeAll(async () => { - await refreshAuthState(); - }); - test.beforeEach(async ({ page }) => { - await refreshAuthState(); const ok = await ensureAuthenticatedInPage(page); if (!ok) { throw new Error("Failed to authenticate AI settings E2E context"); @@ -54,9 +59,7 @@ test.describe("Web Admin - AI Agent Settings", () => { test("should display AI Agent section on settings page", async ({ page }) => { await openSettings(page); - // The AI Agent section is a Card with h2 heading on the settings page - const aiHeading = page.getByRole("heading", { name: /ai agent/i }); - await expect(aiHeading).toBeVisible({ timeout: 15000 }); + await expect(page.locator("#ai-agent-content")).toBeVisible({ timeout: 15000 }); }); test("should toggle AI agent enable/disable", async ({ page }) => { @@ -67,7 +70,7 @@ test.describe("Web Admin - AI Agent Settings", () => { await expect(enableText).toBeVisible({ timeout: 5000 }); // The toggle is a sibling button with rounded-full class - const toggleButton = enableText.locator("..").locator("..").locator("button.rounded-full"); + const toggleButton = enableText.locator("..").locator("..").locator("button").first(); await expect(toggleButton).toBeVisible({ timeout: 5000 }); await toggleButton.click(); @@ -97,7 +100,7 @@ test.describe("Web Admin - AI Agent Settings", () => { if (!isEnabled) { // Toggle enable const enableText = page.getByText("Enable AI Agent"); - const toggleButton = enableText.locator("..").locator("..").locator("button.rounded-full"); + const toggleButton = enableText.locator("..").locator("..").locator("button").first(); await toggleButton.click(); } diff --git a/apps/web/e2e/auth.spec.ts b/apps/web/e2e/auth.spec.ts index a1ef224..3cc79d9 100644 --- a/apps/web/e2e/auth.spec.ts +++ b/apps/web/e2e/auth.spec.ts @@ -280,7 +280,9 @@ test.describe("Authentication - Signup Flows", () => { } // Check if we need to switch to password signup mode - const passwordSignupButton = page.getByRole("button", { name: /sign up with password/i }); + const passwordSignupButton = page + .getByRole("button", { name: /sign up with password/i }) + .or(page.getByRole("button", { name: /^password$/i })); if (await passwordSignupButton.isVisible({ timeout: 2000 }).catch(() => false)) { await passwordSignupButton.click(); await page.waitForTimeout(1000); @@ -302,8 +304,8 @@ test.describe("Authentication - Signup Flows", () => { await workspaceField.fill(`Auth Test Workspace ${Date.now()}`); } - // Submit signup - look for sign up button - await page.getByRole("button", { name: /sign up$/i }).click(); + // Submit signup - current UI uses "Create Account" for password signup. + await page.getByRole("button", { name: /create account|sign up$/i }).click(); // After signup, user should land in an authenticated area (inbox/dashboard/onboarding) // or on login if email verification/sign-in is still required. diff --git a/apps/web/e2e/carousels.spec.ts b/apps/web/e2e/carousels.spec.ts index f56e396..6453d0c 100644 --- a/apps/web/e2e/carousels.spec.ts +++ b/apps/web/e2e/carousels.spec.ts @@ -9,7 +9,6 @@ import { import { ensureAuthenticatedInPage, gotoWithAuthRecovery, - refreshAuthState, } from "./helpers/auth-refresh"; import { getTestState } from "./helpers/test-state"; @@ -26,8 +25,6 @@ function requireTestContext(): { workspaceId: Id<"workspaces">; userEmail: strin } async function ensureAuthenticated(page: Page): Promise { - const refreshed = await refreshAuthState(); - expect(refreshed).toBe(true); const authed = await ensureAuthenticatedInPage(page); expect(authed).toBe(true); } @@ -130,8 +127,8 @@ test.describe.serial("Web Admin - Carousel Management", () => { } await openCarouselsTab(page); - page.once("dialog", (dialog) => dialog.accept()); await page.getByTestId(`carousel-delete-${seeded.carouselId}`).click(); + await page.getByRole("button", { name: /^confirm$/i }).click(); await expect(page.getByTestId(`carousel-row-${seeded.carouselId}`)).toHaveCount(0, { timeout: 10000, }); diff --git a/apps/web/e2e/csat.spec.ts b/apps/web/e2e/csat.spec.ts index 7a5be01..3d9d5bd 100644 --- a/apps/web/e2e/csat.spec.ts +++ b/apps/web/e2e/csat.spec.ts @@ -27,7 +27,6 @@ const VIEWPORT_CASES: ViewportCase[] = [ { name: "mobile", width: 390, height: 844 }, ]; -const WIDGET_TEST_EMAIL = "e2e_test_visitor@test.opencom.dev"; const AUTH_ROUTE_RE = /\/(login|signup)(\/|$|\?)/; function requireWorkspaceId(): Id<"workspaces"> { @@ -38,8 +37,12 @@ function requireWorkspaceId(): Id<"workspaces"> { return state.workspaceId as Id<"workspaces">; } -function widgetDemoUrl(workspaceId: Id<"workspaces">): string { - return `/widget-demo?workspaceId=${workspaceId}`; +function widgetVisitorEmail(visitorKey: string): string { + return `e2e_test_visitor_${visitorKey}@test.opencom.dev`; +} + +function widgetDemoUrl(workspaceId: Id<"workspaces">, visitorKey: string): string { + return `/widget-demo?workspaceId=${workspaceId}&visitorKey=${visitorKey}`; } function isAuthRoute(page: Page): boolean { @@ -69,9 +72,10 @@ async function gotoProtectedRoute(page: Page, path: string, readyLocator: Locato async function openFirstWidgetConversation( page: Page, - workspaceId: Id<"workspaces"> + workspaceId: Id<"workspaces">, + visitorKey: string ): Promise { - await gotoWithAuthRecovery(page, widgetDemoUrl(workspaceId)); + await gotoWithAuthRecovery(page, widgetDemoUrl(workspaceId, visitorKey)); const widget = await openWidgetChat(page); @@ -132,9 +136,10 @@ test.describe("CSAT deterministic lifecycle", () => { for (const viewport of VIEWPORT_CASES) { test(`shows CSAT prompt interaction on ${viewport.name} viewport`, async ({ page }) => { await page.setViewportSize({ width: viewport.width, height: viewport.height }); + const visitorKey = `csat-${viewport.name}`; const seeded = await createInboxConversationFixture(workspaceId, { - visitorEmail: WIDGET_TEST_EMAIL, + visitorEmail: widgetVisitorEmail(visitorKey), visitorName: `E2E CSAT ${viewport.name}`, status: "open", initialMessages: [ @@ -142,7 +147,7 @@ test.describe("CSAT deterministic lifecycle", () => { ], }); - const widget = await openFirstWidgetConversation(page, workspaceId); + const widget = await openFirstWidgetConversation(page, workspaceId, visitorKey); await expect(widget.getByTestId("widget-csat-prompt")).toHaveCount(0); await setInboxConversationStatus(seeded.conversationId, "closed"); @@ -161,14 +166,15 @@ test.describe("CSAT deterministic lifecycle", () => { } test("resolve -> prompt -> submit -> report visibility", async ({ page }) => { + const visitorKey = "csat-flow"; const seeded = await createInboxConversationFixture(workspaceId, { - visitorEmail: WIDGET_TEST_EMAIL, + visitorEmail: widgetVisitorEmail(visitorKey), visitorName: "E2E CSAT Flow", status: "open", initialMessages: [{ content: "Please close and rate this", senderType: "visitor" }], }); - const widget = await openFirstWidgetConversation(page, workspaceId); + const widget = await openFirstWidgetConversation(page, workspaceId, visitorKey); await setInboxConversationStatus(seeded.conversationId, "closed"); await expect(widget.getByTestId("widget-csat-prompt")).toBeVisible({ timeout: 10000 }); @@ -183,7 +189,7 @@ test.describe("CSAT deterministic lifecycle", () => { await page.reload(); - const reopenedWidget = await openFirstWidgetConversation(page, workspaceId); + const reopenedWidget = await openFirstWidgetConversation(page, workspaceId, visitorKey); await expect(reopenedWidget.getByTestId("widget-csat-prompt")).toHaveCount(0, { timeout: 10000, }); @@ -215,6 +221,7 @@ test.describe("CSAT deterministic lifecycle", () => { }); test("disabled Ask for Rating suppresses prompt", async ({ page }) => { + const visitorKey = "csat-disabled"; await upsertAutomationSettings(workspaceId, { askForRatingEnabled: false, collectEmailEnabled: false, @@ -223,7 +230,7 @@ test.describe("CSAT deterministic lifecycle", () => { }); const seeded = await createInboxConversationFixture(workspaceId, { - visitorEmail: WIDGET_TEST_EMAIL, + visitorEmail: widgetVisitorEmail(visitorKey), visitorName: "E2E CSAT Disabled", status: "closed", initialMessages: [{ content: "Closed with CSAT disabled", senderType: "visitor" }], @@ -231,7 +238,7 @@ test.describe("CSAT deterministic lifecycle", () => { await setInboxConversationStatus(seeded.conversationId, "closed"); - const widget = await openFirstWidgetConversation(page, workspaceId); + const widget = await openFirstWidgetConversation(page, workspaceId, visitorKey); await expect(widget.getByTestId("widget-conversation-status")).toBeVisible({ timeout: 10000 }); await expect(widget.getByTestId("widget-conversation-status")).toContainText(/disabled/i); await expect(widget.getByTestId("widget-csat-prompt")).toHaveCount(0); diff --git a/apps/web/e2e/fixtures.ts b/apps/web/e2e/fixtures.ts index 75c2e6f..a1ab990 100644 --- a/apps/web/e2e/fixtures.ts +++ b/apps/web/e2e/fixtures.ts @@ -9,7 +9,9 @@ import { test as base, type Browser, type BrowserContext, type Page } from "@playwright/test"; import * as fs from "fs"; import * as path from "path"; +import { refreshAuthState } from "./helpers/auth-refresh"; import { resolveE2EBackendUrl } from "./helpers/e2e-env"; +import { sanitizeStorageStateFile } from "./helpers/storage-state"; import { readTestStateFromPath, type E2ETestState } from "./helpers/test-state"; const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; @@ -340,7 +342,15 @@ export const test = base.extend< }, WorkerFixtures >({ - storageState: ({ workerStorageState }, use) => use(workerStorageState), + storageState: async ({ workerStorageState, workerTestState }, use) => { + setWorkerStateEnv(workerTestState); + const refreshed = await refreshAuthState(); + if (!refreshed) { + console.warn("[fixtures] Failed to refresh worker auth state before creating test context."); + } + sanitizeStorageStateFile(workerStorageState); + await use(workerStorageState); + }, page: async ({ page, workerTestState }, use) => { await use(page); @@ -360,6 +370,7 @@ export const test = base.extend< .catch((error) => { console.warn(`[fixtures] Failed to persist worker auth state: ${formatError(error)}`); }); + sanitizeStorageStateFile(workerTestState.authStoragePath); }, workerStorageState: [ diff --git a/apps/web/e2e/helpers/auth-refresh.ts b/apps/web/e2e/helpers/auth-refresh.ts index 00e48c2..a3dd375 100644 --- a/apps/web/e2e/helpers/auth-refresh.ts +++ b/apps/web/e2e/helpers/auth-refresh.ts @@ -4,7 +4,7 @@ * when the Convex auth JWT has expired mid-suite. */ -import { chromium, type Page } from "@playwright/test"; +import { chromium, type BrowserContext, type Page } from "@playwright/test"; import * as fs from "fs"; import { getAuthStatePath, @@ -13,6 +13,7 @@ import { type E2ETestState, } from "./test-state"; import { resolveE2EBackendUrl } from "./e2e-env"; +import { sanitizeStorageStateFile } from "./storage-state"; const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; const BACKEND_URL = resolveE2EBackendUrl(); @@ -26,9 +27,15 @@ const NAV_BACKOFF_MS = readEnvNumber("E2E_NAV_BACKOFF_MS", 500); const PROTECTED_LANDING_RE = /\/(inbox|dashboard)(\/|$|\?)/; const LATE_AUTH_REDIRECT_TIMEOUT_MS = readEnvNumber("E2E_LATE_AUTH_REDIRECT_TIMEOUT_MS", 5000); const PASSWORD_LOGIN_RE = - /log in with password|sign in with password|sign in with password instead|log in with password instead/i; + /log in with password|sign in with password|sign in with password instead|log in with password instead|^password$/i; const ROUTE_RECOVERY_TIMEOUT_MS = readEnvNumber("E2E_ROUTE_RECOVERY_TIMEOUT_MS", 12000); +type StorageStateLike = { + origins?: Array<{ + localStorage?: Array<{ name: string; value: string }>; + }>; +}; + function readEnvNumber(name: string, fallback: number): number { const raw = process.env[name]; const parsed = Number(raw); @@ -109,6 +116,44 @@ function decodeJwtExp(token: string): number | null { } } +function isJwtFresh(token: string | null): boolean { + if (!token) { + return false; + } + + const exp = decodeJwtExp(token); + if (!exp) { + return false; + } + + const now = Math.floor(Date.now() / 1000); + return exp - now >= AUTH_EXPIRY_BUFFER_SECONDS; +} + +function readJwtFromStorageState(storageState: StorageStateLike | null | undefined): string | null { + for (const origin of storageState?.origins ?? []) { + for (const entry of origin.localStorage ?? []) { + if (entry.name.startsWith("__convexAuthJWT_")) { + return entry.value; + } + } + } + + return null; +} + +function hasStoredBackend(storageState: StorageStateLike | null | undefined): boolean { + for (const origin of storageState?.origins ?? []) { + for (const entry of origin.localStorage ?? []) { + if (entry.name === "opencom_backends" && entry.value.trim().length > 0) { + return true; + } + } + } + + return false; +} + function isAuthRoute(url: string): boolean { return AUTH_ROUTE_RE.test(url); } @@ -173,6 +218,141 @@ async function isAuthUiVisible(page: Page): Promise { return passwordButton.isVisible({ timeout: 1200 }).catch(() => false); } +async function isCurrentContextAuthFresh(page: Page): Promise { + if (page.isClosed()) { + return false; + } + + try { + const storageState = (await page.context().storageState()) as StorageStateLike; + return isJwtFresh(readJwtFromStorageState(storageState)); + } catch { + return false; + } +} + +async function seedBackendStorage(context: BrowserContext): Promise { + await context.addInitScript((backendUrl: string) => { + const payload = { + backends: [ + { + url: backendUrl, + name: "Opencom Hosted", + convexUrl: backendUrl, + lastUsed: new Date().toISOString(), + signupMode: "open", + authMethods: ["password", "otp"], + }, + ], + activeBackend: backendUrl, + }; + window.localStorage.setItem("opencom_backends", JSON.stringify(payload)); + }, BACKEND_URL); +} + +async function ensureBackendStorage(context: BrowserContext): Promise { + try { + const storageState = (await context.storageState()) as StorageStateLike; + if (hasStoredBackend(storageState)) { + return; + } + } catch { + // Fall through and seed storage. + } + + await seedBackendStorage(context); +} + +async function waitForAuthSurface(page: Page, timeout = 15000): Promise { + const deadline = Date.now() + timeout; + const emailInput = page.getByLabel("Email", { exact: true }).first(); + const passwordInput = page.getByLabel("Password", { exact: true }).first(); + const sendCodeButton = page.getByRole("button", { name: /send verification code/i }).first(); + const backendInput = page.getByLabel(/backend url/i).first(); + + while (!page.isClosed() && Date.now() < deadline) { + if (!isAuthRoute(page.url())) { + return true; + } + + if (await emailInput.isVisible({ timeout: 250 }).catch(() => false)) { + return true; + } + + if (await passwordInput.isVisible({ timeout: 250 }).catch(() => false)) { + return true; + } + + if (await sendCodeButton.isVisible({ timeout: 250 }).catch(() => false)) { + return true; + } + + const backendVisible = await backendInput.isVisible({ timeout: 250 }).catch(() => false); + if (!backendVisible && (await isAuthUiVisible(page))) { + return true; + } + + await page.waitForTimeout(200).catch(() => {}); + } + + return !isAuthRoute(page.url()) || (await isAuthUiVisible(page)); +} + +async function waitForPasswordLoginForm(page: Page, timeout = 12000): Promise { + const deadline = Date.now() + timeout; + const emailInput = page.getByLabel("Email", { exact: true }).first(); + const passwordInput = page.getByLabel("Password", { exact: true }).first(); + + while (!page.isClosed() && Date.now() < deadline) { + if (!isAuthRoute(page.url())) { + return true; + } + + const emailVisible = await emailInput.isVisible({ timeout: 250 }).catch(() => false); + const passwordVisible = await passwordInput.isVisible({ timeout: 250 }).catch(() => false); + if (emailVisible && passwordVisible) { + return true; + } + + await page.waitForTimeout(200).catch(() => {}); + } + + return !isAuthRoute(page.url()); +} + +async function waitForActiveWorkspaceStorage(page: Page, timeout = 10000): Promise { + const deadline = Date.now() + timeout; + + while (!page.isClosed() && Date.now() < deadline) { + const hasWorkspace = await page + .evaluate(() => { + try { + const stored = window.localStorage.getItem("opencom_active_workspace"); + if (!stored) { + return false; + } + const parsed = JSON.parse(stored) as { _id?: string }; + return typeof parsed._id === "string" && parsed._id.length > 0; + } catch { + return false; + } + }) + .catch(() => false); + + if (hasWorkspace) { + return true; + } + + await page.waitForTimeout(200).catch(() => {}); + } + + return false; +} + +async function safeCloseContext(context: BrowserContext): Promise { + await context.close().catch(() => {}); +} + async function safeGoto( page: Page, target: string, @@ -253,7 +433,7 @@ async function connectBackendIfRequired(page: Page): Promise { const connectButton = page.getByRole("button", { name: /connect/i }).first(); await connectButton.click({ timeout: 10000 }); await page.waitForLoadState("domcontentloaded", { timeout: 10000 }).catch(() => {}); - return true; + return waitForAuthSurface(page, 15000); } catch (error) { console.warn(`[auth-refresh] Failed to connect backend in auth flow: ${formatError(error)}`); return false; @@ -261,6 +441,11 @@ async function connectBackendIfRequired(page: Page): Promise { } async function switchToPasswordLoginIfNeeded(page: Page): Promise { + const passwordField = page.getByLabel("Password", { exact: true }).first(); + if (await passwordField.isVisible({ timeout: 1000 }).catch(() => false)) { + return; + } + const passwordLoginButton = page .getByRole("button", { name: PASSWORD_LOGIN_RE, @@ -269,6 +454,7 @@ async function switchToPasswordLoginIfNeeded(page: Page): Promise { if (await passwordLoginButton.isVisible({ timeout: 2000 }).catch(() => false)) { await passwordLoginButton.click({ timeout: 10000 }); await page.waitForLoadState("domcontentloaded", { timeout: 10000 }).catch(() => {}); + await waitForPasswordLoginForm(page, 12000).catch(() => {}); } } @@ -399,10 +585,20 @@ async function performPasswordLogin(page: Page, state: E2ETestState): Promise false))) { + if (!(await emailInput.isVisible({ timeout: 2000 }).catch(() => false))) { // When an existing session auto-redirects from /login, login inputs might never render. return waitForAuthenticatedLanding(page, 8000); } @@ -442,23 +638,7 @@ async function waitForAuthRedirect( * and confirming that an authenticated route does not redirect to login. */ function isAuthStale(): boolean { - const jwt = readStoredJwt(); - if (!jwt) { - return true; - } - - const exp = decodeJwtExp(jwt); - if (!exp) { - return true; - } - - const now = Math.floor(Date.now() / 1000); - // Refresh slightly before expiry to avoid race conditions during a test. - if (exp - now < AUTH_EXPIRY_BUFFER_SECONDS) { - return true; - } - - return false; + return !isJwtFresh(readStoredJwt()); } /** @@ -492,7 +672,11 @@ export async function refreshAuthState(): Promise { const browser = await chromium.launch(); try { - const context = await browser.newContext(); + const authStatePath = getAuthStatePath(); + const context = await browser.newContext({ + storageState: fs.existsSync(authStatePath) ? authStatePath : undefined, + }); + await ensureBackendStorage(context); const page = await context.newPage(); const openedLogin = await safeGoto(page, "/login", { @@ -502,29 +686,36 @@ export async function refreshAuthState(): Promise { }); if (!openedLogin) { console.warn("[auth-refresh] Could not open /login during refresh"); - await context.close(); + await safeCloseContext(context); return false; } // Already authenticated in this context. if (!isAuthRoute(page.url())) { - await context.storageState({ path: getAuthStatePath() }); - await context.close(); + await waitForRouteSettled(page, Math.min(NAV_TIMEOUT_MS, 12000)).catch(() => {}); + await waitForActiveWorkspaceStorage(page, 10000).catch(() => {}); + await context.storageState({ path: authStatePath }); + sanitizeStorageStateFile(authStatePath); + await safeCloseContext(context); return true; } const loggedIn = await performPasswordLogin(page, state); if (!loggedIn) { console.warn("[auth-refresh] Login did not reach an authenticated route"); - await context.close(); + await safeCloseContext(context); return false; } + await waitForRouteSettled(page, Math.min(NAV_TIMEOUT_MS, 12000)).catch(() => {}); + await waitForActiveWorkspaceStorage(page, 10000).catch(() => {}); + // Save fresh auth state - await context.storageState({ path: getAuthStatePath() }); + await context.storageState({ path: authStatePath }); + sanitizeStorageStateFile(authStatePath); console.log("[auth-refresh] Auth state refreshed successfully"); - await context.close(); + await safeCloseContext(context); return true; } catch (error) { console.error("[auth-refresh] Re-authentication failed:", error); @@ -545,17 +736,19 @@ export async function ensureAuthenticatedInPage(page: Page): Promise { const currentUrl = page.url(); const authUiVisible = await isAuthUiVisible(page); + const currentContextAuthFresh = await isCurrentContextAuthFresh(page); + + // Fast path: when the page is already healthy on a non-auth route, avoid + // probing /login just because exported storage state cannot represent every + // transient auth detail used by the app runtime. + if (!isAuthRoute(currentUrl) && !authUiVisible) { + if (isInitialPageUrl(currentUrl)) { + return currentContextAuthFresh; + } - // Fast path: worker-auth contexts with fresh tokens do not need auth-route probing - // on every test hook. This keeps beforeEach hooks short and avoids unnecessary login UI churn. - if ( - !isInitialPageUrl(currentUrl) && - !isAuthRoute(currentUrl) && - !authUiVisible && - !isAuthStale() - ) { const settled = await waitForRouteSettled(page, 3000); - if (settled) { + const hasWorkspace = await waitForActiveWorkspaceStorage(page, 5000); + if (settled && hasWorkspace && !(await isRouteErrorBoundaryVisible(page))) { return true; } } @@ -584,7 +777,8 @@ export async function ensureAuthenticatedInPage(page: Page): Promise { ); if (autoRecovered && !isAuthRoute(page.url())) { const settled = await waitForRouteSettled(page, Math.min(NAV_TIMEOUT_MS, 12000)); - if (settled) { + const hasWorkspace = await waitForActiveWorkspaceStorage(page, 10000); + if (settled && hasWorkspace) { return true; } } @@ -601,7 +795,13 @@ export async function ensureAuthenticatedInPage(page: Page): Promise { console.warn("[auth-refresh] In-page login stayed on auth route"); return false; } + const hasWorkspace = await waitForActiveWorkspaceStorage(page, 10000); + if (!hasWorkspace) { + console.warn("[auth-refresh] In-page login completed without active workspace storage"); + return false; + } await page.context().storageState({ path: getAuthStatePath() }); + sanitizeStorageStateFile(getAuthStatePath()); return true; } catch (error) { console.error("[auth-refresh] In-page authentication failed:", error); diff --git a/apps/web/e2e/helpers/storage-state.ts b/apps/web/e2e/helpers/storage-state.ts new file mode 100644 index 0000000..bd64548 --- /dev/null +++ b/apps/web/e2e/helpers/storage-state.ts @@ -0,0 +1,97 @@ +import type { Page } from "@playwright/test"; +import * as fs from "fs"; + +type StorageStateEntry = { + name: string; + value: string; +}; + +type StorageStateOrigin = { + localStorage?: StorageStateEntry[]; + [key: string]: unknown; +}; + +type StorageStateLike = { + origins?: StorageStateOrigin[]; + [key: string]: unknown; +}; + +const VOLATILE_LOCAL_STORAGE_KEYS = new Set([ + "opencom_session_id", + "opencom_visitor_id", + "opencom_settings_cache", +]); + +const VOLATILE_LOCAL_STORAGE_PREFIXES = ["opencom_settings_cache_"]; + +function isVolatileLocalStorageKey(name: string): boolean { + if (VOLATILE_LOCAL_STORAGE_KEYS.has(name)) { + return true; + } + + return VOLATILE_LOCAL_STORAGE_PREFIXES.some((prefix) => name.startsWith(prefix)); +} + +export function sanitizeStorageState(storageState: T): T { + if (!storageState.origins?.length) { + return storageState; + } + + return { + ...storageState, + origins: storageState.origins.map((origin) => { + if (!origin.localStorage?.length) { + return origin; + } + + return { + ...origin, + localStorage: origin.localStorage.filter( + (entry) => !isVolatileLocalStorageKey(entry.name) + ), + }; + }), + }; +} + +export function sanitizeStorageStateFile(storageStatePath: string): void { + if (!fs.existsSync(storageStatePath)) { + return; + } + + try { + const raw = fs.readFileSync(storageStatePath, "utf-8"); + const parsed = JSON.parse(raw) as StorageStateLike; + const sanitized = sanitizeStorageState(parsed); + fs.writeFileSync(storageStatePath, JSON.stringify(sanitized, null, 2), { mode: 0o600 }); + } catch (error) { + console.warn(`[storage-state] Failed to sanitize ${storageStatePath}:`, error); + } +} + +export async function clearVolatileWidgetClientState(page: Page): Promise { + await page.evaluate( + ({ volatileKeys, volatilePrefixes }) => { + for (const key of volatileKeys) { + localStorage.removeItem(key); + } + + for (let index = localStorage.length - 1; index >= 0; index -= 1) { + const key = localStorage.key(index); + if (!key) { + continue; + } + + if (volatilePrefixes.some((prefix) => key.startsWith(prefix))) { + localStorage.removeItem(key); + } + } + + sessionStorage.removeItem("opencom_email_dismissed"); + }, + { + volatileKeys: [...VOLATILE_LOCAL_STORAGE_KEYS], + volatilePrefixes: VOLATILE_LOCAL_STORAGE_PREFIXES, + } + ); +} diff --git a/apps/web/e2e/helpers/widget-helpers.ts b/apps/web/e2e/helpers/widget-helpers.ts index 92335be..5e615e0 100644 --- a/apps/web/e2e/helpers/widget-helpers.ts +++ b/apps/web/e2e/helpers/widget-helpers.ts @@ -166,6 +166,19 @@ export async function searchHelpCenter(page: Page, query: string): Promise await page.waitForLoadState("networkidle").catch(() => {}); } +/** + * Waits for a help center article to be visible. + */ +export async function waitForHelpArticleVisible(page: Page, timeout = 10000): Promise { + const frame = getWidgetContainer(page); + const articleLink = frame + .locator(".opencom-article-item, button:has(.opencom-article-item), button:has-text('articles')") + .first(); + + await expect(articleLink).toBeVisible({ timeout }); + return articleLink; +} + /** * Clicks an article in the help center. */ @@ -181,17 +194,12 @@ export async function clickHelpArticle(page: Page, articleTitle: string): Promis /** * Checks if a tour step is visible. */ -export async function isTourStepVisible(page: Page): Promise { - try { - await expect( - page.locator("[data-testid='tour-step-card'], [data-testid='tour-overlay']").first() - ).toBeVisible({ - timeout: 6000, - }); - return true; - } catch { - return false; - } +export async function isTourStepVisible(page: Page, timeout = 6000): Promise { + return page + .locator("[data-testid='tour-step-card'], [data-testid='tour-overlay']") + .first() + .isVisible({ timeout }) + .catch(() => false); } /** @@ -219,7 +227,7 @@ export async function dismissTour(page: Page): Promise { ]; for (let attempt = 0; attempt < 6; attempt += 1) { - const visible = await isTourStepVisible(page); + const visible = await isTourStepVisible(page, 500); if (!visible) { return; } @@ -272,7 +280,11 @@ export async function isSurveyVisible(page: Page): Promise { try { await expect( - frame.locator(".oc-survey-small, .oc-survey-overlay, .oc-survey-large").first() + frame + .locator( + ".oc-survey-small, .oc-survey-overlay, .oc-survey-large, .oc-survey-question, .oc-survey-intro, .oc-survey-thank-you" + ) + .first() ).toBeVisible({ timeout: 2000, }); @@ -282,6 +294,21 @@ export async function isSurveyVisible(page: Page): Promise { } } +/** + * Waits for a survey to be visible. + */ +export async function waitForSurveyVisible(page: Page, timeout = 10000): Promise { + const frame = getWidgetContainer(page); + + await expect( + frame + .locator( + ".oc-survey-small, .oc-survey-overlay, .oc-survey-large, .oc-survey-question, .oc-survey-intro, .oc-survey-thank-you, .oc-survey-nps-button, .oc-survey-numeric-button" + ) + .first() + ).toBeVisible({ timeout }); +} + /** * Submits an NPS rating in a survey. */ @@ -300,6 +327,20 @@ export async function submitNPSRating(page: Page, rating: number): Promise await page.waitForLoadState("networkidle").catch(() => {}); } +/** + * Submits the current survey step. + */ +export async function submitSurvey(page: Page): Promise { + const frame = getWidgetContainer(page); + + await frame + .locator(".oc-survey-actions .oc-survey-button-primary, button:has-text('Submit')") + .first() + .click(); + + await page.waitForLoadState("networkidle").catch(() => {}); +} + /** * Dismisses a survey. */ @@ -310,6 +351,10 @@ export async function dismissSurvey(page: Page): Promise { .locator(".oc-survey-dismiss, [data-testid='survey-dismiss'], .survey-dismiss") .first() .click(); + + await expect( + frame.locator(".oc-survey-small, .oc-survey-overlay, .oc-survey-large").first() + ).not.toBeVisible({ timeout: 5000 }); } /** @@ -357,6 +402,32 @@ export async function waitForAIResponse(page: Page, timeout = 15000): Promise { + const frame = getWidgetContainer(page); + const handoffButton = frame.locator( + "[data-testid='handoff-button'], button:has-text('Talk to human'), button:has-text('Talk to a human'), button:has-text('human'), button:has-text('agent')" + ); + + await expect(handoffButton.first()).toBeVisible({ timeout }); + return handoffButton.first(); +} + +/** + * Waits for the AI feedback buttons to be visible. + */ +export async function waitForAIFeedbackButtons(page: Page, timeout = 15000): Promise { + const frame = getWidgetContainer(page); + const feedbackButtons = frame.locator( + "[data-testid='feedback-helpful'], [data-testid='feedback-not-helpful'], .feedback-button, button[aria-label*='helpful'], button[aria-label*='not helpful']" + ); + + await expect(feedbackButtons.first()).toBeVisible({ timeout }); + return feedbackButtons; +} + /** * Clicks the "Talk to human" button for AI handoff. */ diff --git a/apps/web/e2e/home-settings.spec.ts b/apps/web/e2e/home-settings.spec.ts index da5b854..c129d6c 100644 --- a/apps/web/e2e/home-settings.spec.ts +++ b/apps/web/e2e/home-settings.spec.ts @@ -1,37 +1,70 @@ import { test, expect } from "./fixtures"; -import { - ensureAuthenticatedInPage, - gotoWithAuthRecovery, - refreshAuthState, -} from "./helpers/auth-refresh"; +import { ensureAuthenticatedInPage, gotoWithAuthRecovery } from "./helpers/auth-refresh"; async function openSettings(page: import("@playwright/test").Page): Promise { await gotoWithAuthRecovery(page, "/settings"); await page.waitForLoadState("networkidle").catch(() => {}); + + const sectionToggle = page.getByTestId("settings-section-toggle-messenger-home"); + const isExpanded = (await sectionToggle.getAttribute("aria-expanded")) === "true"; + if (!isExpanded) { + await sectionToggle.click({ timeout: 5000 }); + } } async function expectHomeSection(page: import("@playwright/test").Page): Promise { - const messengerHome = page.getByRole("heading", { name: "Messenger Home" }); - - for (let attempt = 0; attempt < 2; attempt++) { - await messengerHome.scrollIntoViewIfNeeded().catch(() => {}); - const isVisible = await messengerHome.isVisible({ timeout: 8000 }).catch(() => false); - if (isVisible) { - await expect(messengerHome).toBeVisible({ timeout: 5000 }); - return; - } + const sectionToggle = page.getByTestId("settings-section-toggle-messenger-home"); + await sectionToggle.scrollIntoViewIfNeeded().catch(() => {}); + await expect(sectionToggle).toHaveAttribute("aria-expanded", "true", { timeout: 10000 }); + await expect(page.locator("#messenger-home-content")).toBeVisible({ timeout: 10000 }); +} - if (attempt === 0) { - await openSettings(page); - } +function getHomeEnabledToggle(page: import("@playwright/test").Page) { + return page.locator("#messenger-home-content button").first(); +} + +async function expectHomeEnabled(page: import("@playwright/test").Page): Promise { + await expect(page.getByRole("button", { name: /add card/i })).toBeVisible({ timeout: 10000 }); +} + +async function isHomeEnabled(page: import("@playwright/test").Page): Promise { + return page + .getByRole("button", { name: /add card/i }) + .isVisible({ timeout: 500 }) + .catch(() => false); +} + +async function expectHomeDisabled(page: import("@playwright/test").Page): Promise { + await expect(page.getByText("Enable Messenger Home to configure cards")).toBeVisible({ + timeout: 10000, + }); +} + +async function ensureHomeEnabled(page: import("@playwright/test").Page): Promise { + if (await isHomeEnabled(page)) { + return; } - test.skip(true, "Messenger Home section not visible after retry"); + const toggleButton = getHomeEnabledToggle(page); + await expect(toggleButton).toBeVisible({ timeout: 5000 }); + await toggleButton.click(); + await expectHomeEnabled(page); +} + +async function ensureHomeDisabled(page: import("@playwright/test").Page): Promise { + if (!(await isHomeEnabled(page))) { + await expectHomeDisabled(page); + return; + } + + const toggleButton = getHomeEnabledToggle(page); + await expect(toggleButton).toBeVisible({ timeout: 5000 }); + await toggleButton.click(); + await expectHomeDisabled(page); } test.describe("Web Admin - Home Settings", () => { test.beforeEach(async ({ page }) => { - await refreshAuthState(); const ok = await ensureAuthenticatedInPage(page); if (!ok) { test.skip(true, "[home-settings.spec] Could not authenticate test page"); @@ -46,46 +79,18 @@ test.describe("Web Admin - Home Settings", () => { test("should toggle home enabled/disabled", async ({ page }) => { await openSettings(page); await expectHomeSection(page); - - // Find toggle using role-based selector (switch or checkbox role) - const toggleButton = page - .getByRole("switch", { name: /home|messenger/i }) - .or( - page - .locator( - "[aria-label*='home' i][role='switch'], [aria-label*='messenger' i][role='switch']" - ) - .first() - ) - .or(page.locator("text=Messenger Home").locator("..").getByRole("button").first()); - - if (await toggleButton.isVisible({ timeout: 3000 }).catch(() => false)) { - // Click toggle to enable - await toggleButton.click(); - await page.waitForTimeout(1000); - - // Wait for toggle effect — "Add Card" button confirms home is enabled - const addCardButton = page.getByRole("button", { name: /add card/i }); - await expect(addCardButton).toBeVisible({ timeout: 5000 }); - } + await ensureHomeDisabled(page); + await ensureHomeEnabled(page); }); test("should add a card to home configuration", async ({ page }) => { await openSettings(page); await expectHomeSection(page); + await ensureHomeEnabled(page); + await expectHomeEnabled(page); - // Enable home if needed - const toggleButton = page - .getByRole("switch", { name: /home|messenger/i }) - .or(page.locator("text=Messenger Home").locator("..").getByRole("button").first()); - if (await toggleButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await toggleButton.click(); - await page.waitForTimeout(1000); - } - - // Wait for toggle effect — "Add Card" button confirms home is enabled const addCardButton = page.getByRole("button", { name: /add card/i }); - if (await addCardButton.isVisible({ timeout: 5000 }).catch(() => false)) { + if (await addCardButton.isVisible({ timeout: 3000 }).catch(() => false)) { await addCardButton.click(); await page.waitForTimeout(300); @@ -101,21 +106,8 @@ test.describe("Web Admin - Home Settings", () => { test("should change card visibility setting", async ({ page }) => { await openSettings(page); await expectHomeSection(page); - - // Enable home - const toggleButton = page - .getByRole("switch", { name: /home|messenger/i }) - .or(page.locator("text=Messenger Home").locator("..").getByRole("button").first()); - if (await toggleButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await toggleButton.click(); - await page.waitForTimeout(1000); - } - - // Wait for "Add Card" to confirm toggle took effect - await page - .getByRole("button", { name: /add card/i }) - .isVisible({ timeout: 5000 }) - .catch(() => {}); + await ensureHomeEnabled(page); + await expectHomeEnabled(page); // Find visibility dropdown for any card const visibilitySelect = page @@ -131,21 +123,8 @@ test.describe("Web Admin - Home Settings", () => { test("should save home settings", async ({ page }) => { await openSettings(page); await expectHomeSection(page); - - // Enable home - const toggleButton = page - .getByRole("switch", { name: /home|messenger/i }) - .or(page.locator("text=Messenger Home").locator("..").getByRole("button").first()); - if (await toggleButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await toggleButton.click(); - await page.waitForTimeout(1000); - } - - // Wait for "Add Card" to confirm toggle took effect - await page - .getByRole("button", { name: /add card/i }) - .isVisible({ timeout: 5000 }) - .catch(() => {}); + await ensureHomeEnabled(page); + await expectHomeEnabled(page); // Find save button for home settings const saveButton = page.getByRole("button", { name: /save home settings/i }); @@ -160,21 +139,8 @@ test.describe("Web Admin - Home Settings", () => { test("should show home preview when cards are added", async ({ page }) => { await openSettings(page); await expectHomeSection(page); - - // Enable home - const toggleButton = page - .getByRole("switch", { name: /home|messenger/i }) - .or(page.locator("text=Messenger Home").locator("..").getByRole("button").first()); - if (await toggleButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await toggleButton.click(); - await page.waitForTimeout(1000); - } - - // Wait for "Add Card" to confirm toggle took effect - await page - .getByRole("button", { name: /add card/i }) - .isVisible({ timeout: 5000 }) - .catch(() => {}); + await ensureHomeEnabled(page); + await expectHomeEnabled(page); // Check for preview section const previewSection = page.getByText("Home Preview"); diff --git a/apps/web/e2e/inbox.spec.ts b/apps/web/e2e/inbox.spec.ts index 2373b7a..7ce0f4c 100644 --- a/apps/web/e2e/inbox.spec.ts +++ b/apps/web/e2e/inbox.spec.ts @@ -82,6 +82,36 @@ async function sendReply(page: import("@playwright/test").Page, replyText: strin await input.press("Enter"); } +async function convertConversationToTicket( + page: import("@playwright/test").Page +): Promise { + const convertButton = page.getByTestId("inbox-convert-ticket-button"); + const workflowError = page.getByTestId("inbox-workflow-error"); + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(convertButton).toBeVisible({ timeout: 10000 }); + await expect(convertButton).toBeEnabled({ timeout: 10000 }); + await convertButton.click({ timeout: 10000 }); + + const navigated = await page + .waitForURL(/\/tickets\/.+/, { timeout: 15000 }) + .then(() => true) + .catch(() => false); + if (navigated) { + return; + } + + const errorVisible = await workflowError.isVisible({ timeout: 1000 }).catch(() => false); + if (errorVisible) { + throw new Error(await workflowError.innerText()); + } + + await page.waitForTimeout(500); + } + + throw new Error("Create Ticket did not navigate to ticket detail."); +} + test.describe("Inbox deterministic flow", () => { let workspaceId: Id<"workspaces">; @@ -249,12 +279,7 @@ test.describe("Inbox deterministic flow", () => { await openInbox(page); await openFirstConversationThread(page); - - const convertButton = page.getByTestId("inbox-convert-ticket-button"); - await expect(convertButton).toBeVisible({ timeout: 10000 }); - await convertButton.click(); - - await expect(page).toHaveURL(/\/tickets\/.+/, { timeout: 10000 }); + await convertConversationToTicket(page); }); test("uses visitor id fallback label and restores selected thread when returning from visitor profile", async ({ @@ -376,6 +401,8 @@ test.describe("Inbox deterministic flow", () => { await openFirstConversationThread(page); + await expect(page.getByTestId("inbox-open-suggestions")).toBeVisible({ timeout: 10000 }); + await page.getByTestId("inbox-open-suggestions").click(); await expect(page.getByTestId("inbox-sidecar-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByTestId("inbox-suggestions-sidecar")).toBeVisible({ timeout: 10000 }); diff --git a/apps/web/e2e/knowledge.spec.ts b/apps/web/e2e/knowledge.spec.ts index 64262a7..fcda58d 100644 --- a/apps/web/e2e/knowledge.spec.ts +++ b/apps/web/e2e/knowledge.spec.ts @@ -1,348 +1,78 @@ import { test, expect } from "./fixtures"; -import type { Locator, Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { ensureAuthenticatedInPage, gotoWithAuthRecovery, refreshAuthState, } from "./helpers/auth-refresh"; -// Auth is handled by global setup via storageState in playwright.config.ts - -async function openKnowledge(page: Page): Promise { - await gotoWithAuthRecovery(page, "/knowledge"); - if (page.isClosed()) { - return false; - } - - await page.waitForLoadState("domcontentloaded").catch(() => {}); - - const heading = page.getByRole("heading", { name: /knowledge hub|knowledge/i }).first(); - if (await heading.isVisible({ timeout: 6000 }).catch(() => false)) { - return true; - } - - const fallbackMarker = page - .locator( - "a[href*='/knowledge/internal/new'], button:has-text('New Internal Article'), button:has-text('Create Article'), input[placeholder*='Search']" - ) - .first(); - if (await fallbackMarker.isVisible({ timeout: 4000 }).catch(() => false)) { - return true; - } - - return false; -} - -function getFolderSidebar(page: Page): Locator { - return page - .getByTestId("knowledge-folder-sidebar") - .or(page.locator("div.w-64.border-r.bg-gray-50")); -} - -function getCreateFolderButton(page: Page): Locator { - return getFolderSidebar(page).locator("div.p-3.border-b button").first(); -} - -function getFolderNameLabels(page: Page): Locator { - return getFolderSidebar(page).locator( - "[data-testid='folder-name-label'], span.flex-1.text-sm.truncate" - ); -} - -function getFolderMenuTriggers(page: Page): Locator { - return getFolderSidebar(page).locator( - "[data-testid='folder-menu-trigger'], div.group > div.relative > button" - ); -} - -async function createFolderAndWait(page: Page): Promise { - const createFolderButton = getCreateFolderButton(page); - const folderNameLabels = getFolderNameLabels(page); - const initialCount = await folderNameLabels.count(); - - await expect(createFolderButton).toBeVisible({ timeout: 5000 }); - await expect - .poll( - async () => { - await createFolderButton.click(); - await page.waitForTimeout(250); - return folderNameLabels.count(); - }, - { timeout: 10000 } - ) - .toBeGreaterThan(initialCount); - - return initialCount; -} - -test.describe("Knowledge Hub - Folder Management", () => { - test.beforeEach(async ({ page }) => { - await refreshAuthState(); - const ok = await ensureAuthenticatedInPage(page); - if (!ok) { - test.skip(true, "[knowledge.spec] Could not authenticate test page"); - } - }); - - test("should navigate to knowledge hub", async ({ page }) => { - const opened = await openKnowledge(page); - test.skip(!opened, "Knowledge hub is unavailable in this run"); - }); - - test("should display folder sidebar", async ({ page }) => { - const opened = await openKnowledge(page); - test.skip(!opened, "Knowledge hub is unavailable in this run"); - await expect(page.getByText(/folders/i)).toBeVisible({ timeout: 5000 }); - await expect(page.getByText(/all content/i)).toBeVisible({ timeout: 5000 }); - }); - - test("should create a new folder", async ({ page }) => { - const opened = await openKnowledge(page); - test.skip(!opened, "Knowledge hub is unavailable in this run"); - await createFolderAndWait(page); - }); - - test("should rename a folder", async ({ page }) => { - const opened = await openKnowledge(page); - test.skip(!opened, "Knowledge hub is unavailable in this run"); - await createFolderAndWait(page); - - const moreButton = getFolderMenuTriggers(page).last(); - await expect(moreButton).toBeVisible({ timeout: 5000 }); - await moreButton.click({ force: true }); - - const renameButton = page.getByRole("button", { name: /^rename$/i }); - await expect(renameButton).toBeVisible({ timeout: 5000 }); - await renameButton.click(); - - const renamedFolder = `Renamed Folder ${Date.now()}`; - const input = getFolderSidebar(page).locator("input[type='text']").last(); - await expect(input).toBeVisible({ timeout: 5000 }); - await input.fill(renamedFolder); - await input.press("Enter"); - - await expect(page.getByText(renamedFolder)).toBeVisible({ timeout: 5000 }); - }); - - test("should delete a folder", async ({ page }) => { - const opened = await openKnowledge(page); - test.skip(!opened, "Knowledge hub is unavailable in this run"); - const initialCount = await createFolderAndWait(page); - - const moreButton = getFolderMenuTriggers(page).last(); - await expect(moreButton).toBeVisible({ timeout: 5000 }); - await moreButton.click({ force: true }); - - const deleteButton = page.getByRole("button", { name: /^delete$/i }); - await expect(deleteButton).toBeVisible({ timeout: 5000 }); - - page.once("dialog", (dialog) => dialog.accept()); - await deleteButton.click(); - - await expect - .poll(async () => getFolderNameLabels(page).count(), { timeout: 10000 }) - .toBe(initialCount); +async function openArticles(page: Page, path = "/articles"): Promise { + await gotoWithAuthRecovery(page, path); + await expect(page).toHaveURL(/\/articles(?:\/|$|\?)/, { timeout: 15000 }); + await expect(page.getByRole("heading", { name: /^articles$/i })).toBeVisible({ + timeout: 10000, }); -}); +} -test.describe("Knowledge Hub - Internal Article Creation", () => { +test.describe("Articles Admin", () => { test.describe.configure({ timeout: 90000 }); test.beforeEach(async ({ page }) => { await refreshAuthState(); const ok = await ensureAuthenticatedInPage(page); - if (!ok) { - test.skip(true, "[knowledge.spec] Could not authenticate test page"); - } + expect(ok).toBe(true); }); - test("should navigate to new internal article page", async ({ page }) => { - const opened = await openKnowledge(page); - test.skip(!opened, "Knowledge hub is unavailable in this run"); - await page.waitForLoadState("networkidle").catch(() => {}); - - // Try different possible button names - const newArticleButton = page - .getByRole("link", { name: /new internal article/i }) - .or(page.getByRole("button", { name: /new article/i })) - .or(page.getByRole("button", { name: /create article/i })); - - if (await newArticleButton.isVisible({ timeout: 10000 }).catch(() => false)) { - await newArticleButton.click(); - } - - if (/knowledge\/internal\/new/.test(page.url())) { - return; - } - - // Fallback: navigate directly if click did not navigate. - await gotoWithAuthRecovery(page, "/knowledge/internal/new"); - await page.waitForLoadState("networkidle").catch(() => {}); - test.skip( - !/knowledge\/internal\/new/.test(page.url()), - "Internal article editor route is unavailable in this run" - ); + test("redirects legacy knowledge route to articles", async ({ page }) => { + await openArticles(page, "/knowledge"); }); - test("should create an internal article", async ({ page }) => { - await gotoWithAuthRecovery(page, "/knowledge/internal/new"); - test.skip( - !/knowledge\/internal\/new/.test(page.url()), - "Internal article editor route is unavailable in this run" - ); - - // Wait for the editor to load - const titleInput = page.getByPlaceholder(/title/i).or(page.getByLabel(/title/i)); - if (await titleInput.isVisible({ timeout: 5000 }).catch(() => false)) { - await titleInput.fill("Test Internal Article"); - - // Find content editor (could be textarea or contenteditable) - const contentArea = page.locator("textarea, [contenteditable='true']").first(); - if (await contentArea.isVisible({ timeout: 3000 }).catch(() => false)) { - await contentArea.fill("This is test content for the internal article."); - } - - // Save the article - const saveButton = page.getByRole("button", { name: /save|create|publish/i }); - if (await saveButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await saveButton.click({ noWaitAfter: true }); - // Should redirect or show success - await expect(page.getByText(/saved|created|success/i)) - .toBeVisible({ timeout: 5000 }) - .catch(() => { - // Or redirect to the article page - expect(page.url()).toMatch(/knowledge\/internal\/[a-z0-9]+/i); - }); - } - } + test("shows the current article management surface", async ({ page }) => { + await openArticles(page); + + await expect(page.getByText(/manage public and internal knowledge articles/i)).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByRole("link", { name: /manage collections/i })).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByRole("button", { name: /^new internal article$/i })).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByRole("button", { name: /^new article$/i })).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByPlaceholder(/search articles/i)).toBeVisible({ timeout: 10000 }); }); - test("should show article in knowledge hub list", async ({ page }) => { - // First create an article - await gotoWithAuthRecovery(page, "/knowledge/internal/new"); - test.skip( - !/knowledge\/internal\/new/.test(page.url()), - "Internal article editor route is unavailable in this run" - ); - - const titleInput = page.getByPlaceholder(/title/i).or(page.getByLabel(/title/i)); - if (await titleInput.isVisible({ timeout: 5000 }).catch(() => false)) { - const articleTitle = `Test Article ${Date.now()}`; - await titleInput.fill(articleTitle); + test("creates and lists an internal article", async ({ page }) => { + await openArticles(page); - const contentArea = page.locator("textarea, [contenteditable='true']").first(); - if (await contentArea.isVisible({ timeout: 3000 }).catch(() => false)) { - await contentArea.fill("Test content"); - } + await page.getByRole("button", { name: /^new internal article$/i }).click(); + await page.waitForURL(/\/articles\/[a-z0-9]+(?:\?.*)?$/i, { timeout: 15000 }); - const saveButton = page.getByRole("button", { name: /save|create|publish/i }); - if (await saveButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await saveButton.click({ noWaitAfter: true }); - await page.waitForTimeout(2000); - } - - // Navigate to knowledge hub and verify article appears - await gotoWithAuthRecovery(page, "/knowledge"); - await expect(page.getByText(articleTitle)) - .toBeVisible({ timeout: 10000 }) - .catch(() => { - // Article might be in a different view - }); - } - }); -}); - -test.describe("Knowledge Hub - Search from Inbox", () => { - test.beforeEach(async ({ page }) => { - await refreshAuthState(); - const ok = await ensureAuthenticatedInPage(page); - if (!ok) { - test.skip(true, "[knowledge.spec] Could not authenticate test page"); - } - }); - - test("should show knowledge search panel in inbox", async ({ page }) => { - await gotoWithAuthRecovery(page, "/inbox"); - await expect(page).toHaveURL(/inbox/, { timeout: 10000 }); - - // Look for knowledge search panel or button - const knowledgeButton = page.getByRole("button", { name: /knowledge|search.*content/i }); - const knowledgePanel = page.getByText(/knowledge|search.*content/i); - - const hasKnowledgeAccess = - (await knowledgeButton.isVisible({ timeout: 3000 }).catch(() => false)) || - (await knowledgePanel.isVisible({ timeout: 1000 }).catch(() => false)); - - // Knowledge integration should be accessible from inbox - test.skip( - !hasKnowledgeAccess, - "Knowledge access not visible in inbox — may require active conversation" - ); - expect(hasKnowledgeAccess).toBe(true); - }); - - test("should search knowledge content", async ({ page }) => { - const opened = await openKnowledge(page); - test.skip(!opened, "Knowledge hub is unavailable in this run"); - - // Find search input - const searchInput = page.getByPlaceholder(/search/i); - await expect(searchInput).toBeVisible({ timeout: 5000 }); - await searchInput.fill("test"); - await page.waitForLoadState("networkidle").catch(() => {}); - - // Results should appear (or "no results" message) - const resultsOrEmpty = page.getByText(/no results|found|article|content/i).first(); - await expect(resultsOrEmpty).toBeVisible({ timeout: 5000 }); - }); - - test("should filter content by type", async ({ page }) => { - const opened = await openKnowledge(page); - test.skip(!opened, "Knowledge hub is unavailable in this run"); - - // Click filters button - const filtersButton = page.getByRole("button", { name: /filters/i }); - if (await filtersButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await filtersButton.click(); - - // Should show content type filters - await expect(page.getByText(/content type/i)).toBeVisible({ timeout: 3000 }); - - // Filter options should be visible - const articleFilter = page.getByText(/article/i).first(); - const internalFilter = page.getByText(/internal/i).first(); - const snippetFilter = page.getByText(/snippet/i).first(); - - const hasFilters = - (await articleFilter.isVisible({ timeout: 2000 }).catch(() => false)) || - (await internalFilter.isVisible({ timeout: 1000 }).catch(() => false)) || - (await snippetFilter.isVisible({ timeout: 1000 }).catch(() => false)); - - expect(hasFilters).toBe(true); - } - }); + const articleTitle = `E2E Internal Article ${Date.now()}`; + await page.getByPlaceholder("Article title").fill(articleTitle); + await page.getByPlaceholder("billing, enterprise, refunds").fill("e2e, internal"); + await page + .getByPlaceholder("Write your article content here...") + .fill("Internal-only content for the unified knowledge flow."); - test("should toggle between list and grid view", async ({ page }) => { - const opened = await openKnowledge(page); - test.skip(!opened, "Knowledge hub is unavailable in this run"); + const saveButton = page.getByRole("button", { name: /^save$/i }); + await expect(saveButton).toBeEnabled({ timeout: 5000 }); + await saveButton.click(); + await expect(saveButton).toBeDisabled({ timeout: 10000 }); - // Find view toggle buttons - try multiple selectors - const listButton = page - .locator("button") - .filter({ has: page.locator("svg.lucide-list, [data-icon='list']") }) - .first() - .or(page.getByRole("button", { name: /list/i })); - const gridButton = page - .locator("button") - .filter({ has: page.locator("svg.lucide-layout-grid, [data-icon='grid']") }) - .first() - .or(page.getByRole("button", { name: /grid/i })); + await expect(page.getByText(/internal articles are available only inside agent-facing/i)) + .toBeVisible({ timeout: 10000 }); - const hasGridButton = await gridButton.isVisible({ timeout: 5000 }).catch(() => false); - test.skip(!hasGridButton, "View toggle buttons not visible — UI may have changed"); + await openArticles(page); + await page.getByPlaceholder(/search articles/i).fill(articleTitle); - await gridButton.click(); - await expect(listButton).toBeVisible({ timeout: 3000 }); - await listButton.click(); + const articleRow = page.locator("tr").filter({ + has: page.getByRole("link", { name: articleTitle }), + }); + await expect(articleRow).toBeVisible({ timeout: 10000 }); + await expect(articleRow.getByText(/^internal$/i)).toBeVisible({ timeout: 10000 }); }); }); diff --git a/apps/web/e2e/outbound.spec.ts b/apps/web/e2e/outbound.spec.ts index 5b6d9ab..77be7a2 100644 --- a/apps/web/e2e/outbound.spec.ts +++ b/apps/web/e2e/outbound.spec.ts @@ -181,9 +181,6 @@ test.describe("Outbound Messages", () => { }); test("should delete message", async ({ page }) => { - // Handle confirmation dialog before any action that could trigger it - page.on("dialog", (dialog) => dialog.accept()); - await gotoWithAuthRecovery(page, "/outbound"); await page.waitForLoadState("networkidle").catch(() => {}); @@ -191,6 +188,10 @@ test.describe("Outbound Messages", () => { const deleteBtn = page.getByRole("button", { name: /delete/i }).first(); if (await deleteBtn.isVisible({ timeout: 3000 }).catch(() => false)) { await deleteBtn.click(); + const confirmButton = page.getByRole("button", { name: /^confirm$/i }).first(); + if (await confirmButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await confirmButton.click(); + } } // Page should still be on outbound @@ -356,9 +357,6 @@ test.describe("Checklists", () => { await expect(page).toHaveURL(/checklists/, { timeout: 12000 }); await page.waitForLoadState("networkidle").catch(() => {}); - // Handle confirmation dialog if present - page.on("dialog", (dialog) => dialog.accept()); - // Look for delete button on a checklist item - try multiple selectors const deleteBtn = page .locator("button[title='Delete']") @@ -369,6 +367,10 @@ test.describe("Checklists", () => { if (await deleteBtn.isVisible({ timeout: 5000 }).catch(() => false)) { await deleteBtn.click(); + const confirmButton = page.getByRole("button", { name: /^confirm$/i }).first(); + if (await confirmButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await confirmButton.click(); + } await page.waitForLoadState("networkidle").catch(() => {}); } diff --git a/apps/web/e2e/public-pages.spec.ts b/apps/web/e2e/public-pages.spec.ts index b33d3b6..808f8c7 100644 --- a/apps/web/e2e/public-pages.spec.ts +++ b/apps/web/e2e/public-pages.spec.ts @@ -1,10 +1,7 @@ import { test, expect } from "@playwright/test"; import { resolveE2EBackendUrl } from "./helpers/e2e-env"; -import { getPublicWorkspaceContext, updateHelpCenterAccessPolicy } from "./helpers/test-data"; -import type { Id } from "@opencom/convex/dataModel"; const BACKEND_URL = resolveE2EBackendUrl(); -const hasAdminSecret = Boolean(process.env.TEST_ADMIN_SECRET); const ENCODED_BACKEND_URL = encodeURIComponent(BACKEND_URL); function withBackendQuery(pathname: string): string { @@ -39,15 +36,12 @@ test.describe("Web Admin - Public Pages (Help Center)", () => { await page.addInitScript((backendState) => { localStorage.setItem("opencom_backends", backendState); }, getBackendState(new Date().toISOString())); - - if (hasAdminSecret) { - const workspace = await getPublicWorkspaceContext(); - if (workspace) { - await updateHelpCenterAccessPolicy(workspace._id as Id<"workspaces">, "public"); - } - } }); + // `/help` resolves the deployment's default public workspace when no workspace is specified. + // In a multi-workspace environment, forcing one workspace to `restricted` does not deterministically + // make the global public route private, so that boundary is covered by backend policy tests instead. + test("should access public help center without authentication", async ({ page }) => { await page.goto(withBackendQuery("/help")); @@ -78,27 +72,4 @@ test.describe("Web Admin - Public Pages (Help Center)", () => { timeout: 10000, }); }); - - test("should show explicit restricted boundary when public access is disabled", async ({ - page, - }) => { - if (!hasAdminSecret) { - test.skip(true, "TEST_ADMIN_SECRET is required"); - } - const workspace = await getPublicWorkspaceContext(); - test.skip(!workspace, "A public workspace context is required"); - - await updateHelpCenterAccessPolicy(workspace!._id as Id<"workspaces">, "restricted"); - - await page.goto(withBackendQuery("/help")); - await expect(page.getByRole("heading", { name: /help center is private/i })).toBeVisible({ - timeout: 10000, - }); - await expect(page.getByRole("link", { name: /sign in/i })).toBeVisible({ timeout: 10000 }); - - await page.goto(withBackendQuery("/help/some-article-slug")); - await expect(page.getByRole("heading", { name: /help center is private/i })).toBeVisible({ - timeout: 10000, - }); - }); }); diff --git a/apps/web/e2e/reports.spec.ts b/apps/web/e2e/reports.spec.ts index 2756d86..2aec97e 100644 --- a/apps/web/e2e/reports.spec.ts +++ b/apps/web/e2e/reports.spec.ts @@ -1,19 +1,8 @@ import { test, expect } from "./fixtures"; -import { - ensureAuthenticatedInPage, - gotoWithAuthRecovery, - refreshAuthState, -} from "./helpers/auth-refresh"; +import { ensureAuthenticatedInPage, gotoWithAuthRecovery } from "./helpers/auth-refresh"; test.describe("Web Admin - Reports & Analytics", () => { - // Auth is handled by global setup via storageState in playwright.config.ts - test.beforeAll(async () => { - await refreshAuthState(); - }); - test.beforeEach(async ({ page }) => { - const refreshed = await refreshAuthState(); - expect(refreshed).toBe(true); const authed = await ensureAuthenticatedInPage(page); expect(authed).toBe(true); }); diff --git a/apps/web/e2e/snippets.spec.ts b/apps/web/e2e/snippets.spec.ts index 8c47021..928e299 100644 --- a/apps/web/e2e/snippets.spec.ts +++ b/apps/web/e2e/snippets.spec.ts @@ -18,9 +18,7 @@ test.describe("Web Admin - Saved Reply Snippets", () => { test.beforeEach(async ({ page }) => { await refreshAuthState(); const ok = await ensureAuthenticatedInPage(page); - if (!ok) { - test.skip(true, "[snippets.spec] Could not authenticate test page"); - } + expect(ok).toBe(true); }); test("should navigate to snippets page", async ({ page }) => { diff --git a/apps/web/e2e/tooltips.spec.ts b/apps/web/e2e/tooltips.spec.ts index 1d8c719..b37371b 100644 --- a/apps/web/e2e/tooltips.spec.ts +++ b/apps/web/e2e/tooltips.spec.ts @@ -26,6 +26,28 @@ async function ensureAuthed(page: Page): Promise { expect(authed).toBe(true); } +async function readActiveWorkspaceId( + page: Page, + fallback: Id<"workspaces"> +): Promise> { + const activeWorkspaceId = await page + .evaluate(() => { + try { + const stored = window.localStorage.getItem("opencom_active_workspace"); + if (!stored) { + return null; + } + const parsed = JSON.parse(stored) as { _id?: string }; + return typeof parsed._id === "string" ? parsed._id : null; + } catch { + return null; + } + }) + .catch(() => null); + + return (activeWorkspaceId as Id<"workspaces"> | null) ?? fallback; +} + async function openTooltipsPage(page: Page): Promise { await page.goto("/tooltips", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("tooltips-page-heading")).toBeVisible({ timeout: 15000 }); @@ -52,6 +74,7 @@ test.describe.serial("Tooltips", () => { await cleanupTestData(workspaceId); await updateWorkspaceMemberPermissions(workspaceId, userEmail, []); await openTooltipsPage(page); + workspaceId = await readActiveWorkspaceId(page, workspaceId); }); test.afterEach(async () => { @@ -61,10 +84,13 @@ test.describe.serial("Tooltips", () => { test("covers create/edit/delete, visual picker completion, warnings, and invalid token rejection", async ({ page, }) => { + const crudTooltipName = `e2e_test_tooltip_crud_${Date.now()}`; + const pickerTooltipName = `e2e_test_tooltip_picker_${Date.now()}`; + // Create + edit + delete tooltip. await page.getByTestId("tooltips-new-button").click(); await expect(page.getByTestId("tooltip-modal")).toBeVisible({ timeout: 5000 }); - await page.getByTestId("tooltip-name-input").fill("Tooltip CRUD Test"); + await page.getByTestId("tooltip-name-input").fill(crudTooltipName); await page.getByTestId("tooltip-selector-input").fill("#tour-target-1"); await page.getByTestId("tooltip-content-input").fill("Initial tooltip content"); await submitTooltipForm(page); @@ -72,19 +98,19 @@ test.describe.serial("Tooltips", () => { const crudCard = page .locator("[data-testid^='tooltip-card-']") - .filter({ hasText: "Tooltip CRUD Test" }); + .filter({ hasText: crudTooltipName }); await expect(crudCard).toHaveCount(1, { timeout: 10000 }); await crudCard.locator("[data-testid^='tooltip-edit-']").click(); await page.getByTestId("tooltip-content-input").fill("Updated tooltip content"); await submitTooltipForm(page); await expect(crudCard).toContainText("Updated tooltip content"); - page.once("dialog", (dialog) => dialog.accept()); await crudCard.locator("[data-testid^='tooltip-delete-']").click(); + await page.getByRole("button", { name: /^confirm$/i }).click(); await expect(crudCard).toHaveCount(0, { timeout: 10000 }); // Visual picker completion (deterministic backend completion). await page.getByTestId("tooltips-new-button").click(); - await page.getByTestId("tooltip-name-input").fill("Tooltip Picker Test"); + await page.getByTestId("tooltip-name-input").fill(pickerTooltipName); await page.getByTestId("tooltip-content-input").fill("Selected visually"); await page.getByTestId("tooltip-pick-element-button").click(); await expect(page.getByTestId("tooltip-picker-modal")).toBeVisible({ timeout: 5000 }); diff --git a/apps/web/e2e/widget-features.spec.ts b/apps/web/e2e/widget-features.spec.ts index 7f157e2..038c953 100644 --- a/apps/web/e2e/widget-features.spec.ts +++ b/apps/web/e2e/widget-features.spec.ts @@ -9,8 +9,12 @@ import { advanceTourStep, dismissTour, isSurveyVisible, + waitForSurveyVisible, submitNPSRating, + submitSurvey, dismissSurvey, + waitForHelpArticleVisible, + waitForAIResponse, } from "./helpers/widget-helpers"; import { ensureAuthenticatedInPage, @@ -36,8 +40,12 @@ import { Id } from "@opencom/convex/dataModel"; * Tests pass workspaceId as a URL param to connect to the test workspace. */ -function getWidgetDemoUrl(workspaceId: string): string { - return `/widget-demo?workspaceId=${workspaceId}`; +function getWidgetDemoUrl(workspaceId: string, visitorKey?: string): string { + const params = new URLSearchParams({ workspaceId }); + if (visitorKey) { + params.set("visitorKey", visitorKey); + } + return `/widget-demo?${params.toString()}`; } async function gotoWidgetDemoAndWait(page: import("@playwright/test").Page, url: string) { @@ -45,6 +53,18 @@ async function gotoWidgetDemoAndWait(page: import("@playwright/test").Page, url: await waitForWidgetLoad(page, 15000); } +async function gotoFreshWidgetDemoAndWait(page: import("@playwright/test").Page, url: string) { + await page.context().clearCookies(); + await page.goto("about:blank"); + await page + .evaluate(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }) + .catch(() => {}); + await gotoWidgetDemoAndWait(page, url); +} + async function openConversationComposer(page: import("@playwright/test").Page) { const frame = await openWidgetChat(page); const messageInput = frame @@ -60,12 +80,71 @@ async function openConversationComposer(page: import("@playwright/test").Page) { return frame; } +async function waitForTourToRender( + page: import("@playwright/test").Page, + timeout = 15000 +): Promise { + let widgetOpened = false; + + await expect + .poll( + async () => { + const visible = await isTourStepVisible(page); + if (visible) { + return true; + } + + if (!widgetOpened) { + widgetOpened = true; + await openWidgetChat(page).catch(() => {}); + } + + return isTourStepVisible(page); + }, + { timeout } + ) + .toBe(true); +} + +async function expectTourToBeHidden(page: import("@playwright/test").Page): Promise { + await expect.poll(async () => isTourStepVisible(page, 250), { timeout: 6000 }).toBe(false); +} + +async function ensureTourAvailableForDismissal( + page: import("@playwright/test").Page +): Promise { + const autoDisplayDeadline = Date.now() + 15000; + let widgetOpened = false; + + while (Date.now() < autoDisplayDeadline) { + if (await isTourStepVisible(page, 500)) { + return; + } + + if (!widgetOpened) { + widgetOpened = true; + await openWidgetChat(page).catch(() => {}); + } + + await page.waitForTimeout(500); + } + + const widget = getWidgetContainer(page); + const toursTab = widget.getByTitle("Product Tours").first(); + await expect(toursTab).toBeVisible({ timeout: 10000 }); + await toursTab.click(); + + const availableTour = widget.locator("[data-testid^='tour-item-']:not([disabled])").first(); + await expect(availableTour).toBeVisible({ timeout: 10000 }); + await availableTour.click(); + + await expect.poll(async () => isTourStepVisible(page, 500), { timeout: 10000 }).toBe(true); +} + test.beforeEach(async ({ page }) => { await refreshAuthState(); const ok = await ensureAuthenticatedInPage(page); - if (!ok) { - test.skip(true, "[widget-features.spec] Could not authenticate test page"); - } + expect(ok).toBe(true); }); test.describe("Widget E2E Tests - Product Tours", () => { @@ -117,15 +196,13 @@ test.describe("Widget E2E Tests - Product Tours", () => { await gotoWidgetDemoAndWait(page, widgetDemoUrl); // Tour should be visible for a first-time visitor on the target page - await expect.poll(async () => isTourStepVisible(page), { timeout: 15000 }).toBe(true); + await waitForTourToRender(page); }); test("tour step navigation works (next/prev/skip)", async ({ page }) => { if (!workspaceId) return test.skip(); await gotoWidgetDemoAndWait(page, widgetDemoUrl); - - const tourVisible = await isTourStepVisible(page); - test.skip(!tourVisible, "Tour not visible – may have been completed by a prior test"); + await waitForTourToRender(page); // Advance to next step await advanceTourStep(page); @@ -137,43 +214,44 @@ test.describe("Widget E2E Tests - Product Tours", () => { test("tour can be dismissed", async ({ page }) => { if (!workspaceId) return test.skip(); await gotoWidgetDemoAndWait(page, widgetDemoUrl); - - const tourVisible = await isTourStepVisible(page); - test.skip(!tourVisible, "Tour not visible – cannot test dismissal"); + await waitForTourToRender(page); await dismissTour(page); // Tour should no longer be visible - const stillVisible = await isTourStepVisible(page); - expect(stillVisible).toBe(false); + await expectTourToBeHidden(page); }); test("completed tour does not show again for same visitor", async ({ page }) => { if (!workspaceId) return test.skip(); + test.slow(); + // First visit - complete or dismiss tour await gotoWidgetDemoAndWait(page, widgetDemoUrl); - - const tourVisible = await isTourStepVisible(page); - if (tourVisible) { - await dismissTour(page); - } else { - // Tour wasn't shown on first visit – still verify second visit - } + await ensureTourAvailableForDismissal(page); + await dismissTour(page); + await expectTourToBeHidden(page); // Second visit - tour should not appear - await page.reload(); + await page.reload({ waitUntil: "domcontentloaded" }); await waitForWidgetLoad(page, 15000); - - const tourVisibleAfterReload = await isTourStepVisible(page); - // Tour should not show again (frequency: first_time_only) - expect(tourVisibleAfterReload).toBe(false); + if (!(await isTourStepVisible(page, 500))) { + await openWidgetChat(page).catch(() => {}); + } + await expectTourToBeHidden(page); }); }); // Skipped: These tests require additional seeding infrastructure test.describe("Widget E2E Tests - Surveys", () => { let workspaceId: Id<"workspaces"> | null = null; - let widgetDemoUrl = "/widget-demo"; + + function surveyWidgetDemoUrl(visitorKey: string): string { + if (!workspaceId) { + throw new Error("workspaceId is required for survey widget tests"); + } + return getWidgetDemoUrl(workspaceId, visitorKey); + } test.beforeAll(async () => { await refreshAuthState(); @@ -184,7 +262,6 @@ test.describe("Widget E2E Tests - Surveys", () => { return; } workspaceId = state.workspaceId as Id<"workspaces">; - widgetDemoUrl = getWidgetDemoUrl(state.workspaceId); // Seed a test NPS survey try { @@ -209,46 +286,44 @@ test.describe("Widget E2E Tests - Surveys", () => { test("small format survey displays as floating banner", async ({ page }) => { if (!workspaceId) return test.skip(); - await gotoWidgetDemoAndWait(page, widgetDemoUrl); + await gotoFreshWidgetDemoAndWait(page, surveyWidgetDemoUrl("survey-banner")); // Survey should be visible for a first-time visitor with immediate trigger - const surveyVisible = await isSurveyVisible(page); - expect(surveyVisible).toBe(true); + await waitForSurveyVisible(page, 15000); }); test("NPS question allows 0-10 scale interaction", async ({ page }) => { if (!workspaceId) return test.skip(); - await gotoWidgetDemoAndWait(page, widgetDemoUrl); + await gotoFreshWidgetDemoAndWait(page, surveyWidgetDemoUrl("survey-nps-scale")); - const surveyVisible = await isSurveyVisible(page); - test.skip(!surveyVisible, "Survey not visible – cannot test NPS interaction"); + await waitForSurveyVisible(page, 10000); // Submit a rating and verify the interaction completes await submitNPSRating(page, 8); // Widget should still be functional after rating await expect(page.locator(".opencom-widget")).toBeVisible(); + await expect(getWidgetContainer(page)).toBeVisible(); }); test("survey completion shows thank you step", async ({ page }) => { if (!workspaceId) return test.skip(); - await gotoWidgetDemoAndWait(page, widgetDemoUrl); + await gotoFreshWidgetDemoAndWait(page, surveyWidgetDemoUrl("survey-thank-you")); - const surveyVisible = await isSurveyVisible(page); - test.skip(!surveyVisible, "Survey not visible – cannot test completion flow"); + await waitForSurveyVisible(page, 10000); await submitNPSRating(page, 9); + await submitSurvey(page); // Thank you message should appear after completion const frame = getWidgetContainer(page); - await expect(frame.getByText(/thank you|thanks|appreciated/i)).toBeVisible({ timeout: 3000 }); + await expect(frame.getByText(/thank you|thanks|appreciated/i)).toBeVisible({ timeout: 5000 }); }); test("survey can be dismissed", async ({ page }) => { if (!workspaceId) return test.skip(); - await gotoWidgetDemoAndWait(page, widgetDemoUrl); + await gotoFreshWidgetDemoAndWait(page, surveyWidgetDemoUrl("survey-dismiss")); - const surveyVisible = await isSurveyVisible(page); - test.skip(!surveyVisible, "Survey not visible – cannot test dismissal"); + await waitForSurveyVisible(page, 10000); await dismissSurvey(page); @@ -258,12 +333,14 @@ test.describe("Widget E2E Tests - Surveys", () => { test("survey frequency controls (show once) work", async ({ page }) => { if (!workspaceId) return test.skip(); + const frequencyTestUrl = surveyWidgetDemoUrl("survey-frequency-once"); // First visit - complete survey - await gotoWidgetDemoAndWait(page, widgetDemoUrl); + await gotoWidgetDemoAndWait(page, frequencyTestUrl); const surveyVisible = await isSurveyVisible(page); if (surveyVisible) { await submitNPSRating(page, 7); + await submitSurvey(page); } // Second visit - survey should not appear @@ -364,13 +441,15 @@ test.describe("Widget E2E Tests - Help Center", () => { // Navigate to help tab await navigateToWidgetTab(page, "help"); - // Click an article link - const articleLink = frame.locator(".opencom-article-item").first(); - const articleVisible = await articleLink.isVisible({ timeout: 3000 }).catch(() => false); - test.skip(!articleVisible, "No articles visible in help center"); + // Click a collection first, then open an article detail view + const collectionButton = await waitForHelpArticleVisible(page, 10000); + await collectionButton.click(); - await articleLink.click(); - // Article content or detail view should be visible + const articleButton = frame + .locator(".opencom-article-item, button:has(.opencom-article-item)") + .first(); + await expect(articleButton).toBeVisible({ timeout: 10000 }); + await articleButton.click(); await expect( frame.locator("[data-testid='article-content'], .article-content, .opencom-chat") ).toBeVisible({ timeout: 5000 }); @@ -388,12 +467,15 @@ test.describe("Widget E2E Tests - Help Center", () => { // Navigate to help tab await navigateToWidgetTab(page, "help"); - // Click an article to enter detail view - const articleLink = frame.locator(".opencom-article-item").first(); - const articleVisible = await articleLink.isVisible({ timeout: 3000 }).catch(() => false); - test.skip(!articleVisible, "No articles visible – cannot test breadcrumb nav"); + // Click a collection first, then open an article detail view + const collectionButton = await waitForHelpArticleVisible(page, 10000); + await collectionButton.click(); - await articleLink.click(); + const articleButton = frame + .locator(".opencom-article-item, button:has(.opencom-article-item)") + .first(); + await expect(articleButton).toBeVisible({ timeout: 10000 }); + await articleButton.click(); await expect( frame.locator("[data-testid='article-content'], .article-content, .opencom-chat") ).toBeVisible({ timeout: 5000 }); @@ -470,20 +552,16 @@ test.describe("Widget E2E Tests - AI Agent", () => { if (!workspaceId) return test.skip(); await gotoWithAuthRecovery(page, widgetDemoUrl); const frame = await openConversationComposer(page); + await sendWidgetMessage(page, "I need a human to help me"); + await waitForAIResponse(page, 15000); - // Look for handoff button - const handoffButton = frame.locator( - "button:has-text('human'), button:has-text('agent'), [data-testid='handoff-button']" - ); - const handoffVisible = await handoffButton - .first() - .isVisible({ timeout: 5000 }) - .catch(() => false); - test.skip(!handoffVisible, "Handoff button not visible – AI agent may not have responded yet"); - - await handoffButton.first().click(); - // Widget should remain functional after handoff - await expect(frame).toBeVisible(); + await expect( + frame + .locator( + ":text('Waiting for human support'), :text('connect you with a human agent'), button:has-text('Talk to a human')" + ) + .first() + ).toBeVisible({ timeout: 15000 }); }); test("feedback buttons work (helpful/not helpful)", async ({ page }) => { @@ -491,21 +569,26 @@ test.describe("Widget E2E Tests - AI Agent", () => { await gotoWithAuthRecovery(page, widgetDemoUrl); const frame = await openConversationComposer(page); await sendWidgetMessage(page, "Help me with setup"); + await waitForAIResponse(page, 15000); - // Verify message sent - await expect(frame.getByText("Help me with setup")).toBeVisible({ timeout: 5000 }); - - // Look for feedback buttons (appear after AI response) + // Feedback should render when supported; if the conversation is handed off immediately, + // assert the AI response/handoff state instead of waiting on non-existent controls. const feedbackButtons = frame.locator( - "[data-testid='feedback-helpful'], [data-testid='feedback-not-helpful'], .feedback-button" + "[data-testid='feedback-helpful'], [data-testid='feedback-not-helpful'], .feedback-button, button[aria-label*='helpful'], button[aria-label*='not helpful']" ); const feedbackVisible = await feedbackButtons .first() - .isVisible({ timeout: 10000 }) + .isVisible({ timeout: 3000 }) .catch(() => false); - test.skip(!feedbackVisible, "Feedback buttons not visible – AI agent may not have responded"); - await feedbackButtons.first().click(); + if (feedbackVisible) { + await feedbackButtons.first().click(); + } else { + await expect( + frame.getByText(/waiting for human support|connect you with a human agent|AI/i) + ).toBeVisible({ timeout: 15000 }); + } + await expect(frame).toBeVisible(); }); }); diff --git a/apps/web/e2e/widget.spec.ts b/apps/web/e2e/widget.spec.ts index 47518f9..a5774ff 100644 --- a/apps/web/e2e/widget.spec.ts +++ b/apps/web/e2e/widget.spec.ts @@ -33,8 +33,14 @@ async function openWidgetChatOrSkip(page: import("@playwright/test").Page): Prom return await openWidgetChat(page); } +function getWidgetMessageInput(widget: Locator): Locator { + return widget + .locator("[data-testid='widget-message-input'], input.opencom-input, textarea.opencom-input") + .first(); +} + async function startConversationIfNeeded(widget: Locator, required = true): Promise { - const input = widget.locator("input.opencom-input"); + const input = getWidgetMessageInput(widget); if (await input.isVisible({ timeout: 1500 }).catch(() => false)) { return true; } @@ -45,7 +51,10 @@ async function startConversationIfNeeded(widget: Locator, required = true): Prom await outboundDismiss.click({ force: true }).catch(() => {}); } - const messagesTab = widget.getByRole("button", { name: /^Messages$/i }).first(); + const messagesTab = widget + .getByRole("button", { name: /^Messages$/i }) + .or(widget.getByTitle(/conversations/i)) + .first(); if (await messagesTab.isVisible({ timeout: 1000 }).catch(() => false)) { await messagesTab.click({ timeout: 4000 }).catch(() => {}); } @@ -115,17 +124,17 @@ test.describe("Widget Integration - Core", () => { await startConversationIfNeeded(frame); // Find and fill message input - const messageInput = frame.locator("input.opencom-input"); + const messageInput = getWidgetMessageInput(frame); await expect(messageInput).toBeVisible({ timeout: 5000 }); await messageInput.fill("Hello from E2E test!"); // Send message - const sendBtn = frame.locator(".opencom-send"); + const sendBtn = frame.locator("[data-testid='widget-send-button'], .opencom-send").first(); await expect(sendBtn).toBeVisible({ timeout: 2000 }); await sendBtn.click(); // Wait for message to appear in the conversation - await expect(frame.getByText("Hello from E2E test!")).toBeVisible({ timeout: 5000 }); + await expect(frame.getByText("Hello from E2E test!")).toBeVisible({ timeout: 10000 }); }); test("should sync widget message to admin inbox", async ({ page }) => { @@ -134,16 +143,16 @@ test.describe("Widget Integration - Core", () => { await startConversationIfNeeded(frame); const testMessage = `Sync test ${Date.now()}`; - const messageInput = frame.locator("input.opencom-input"); + const messageInput = getWidgetMessageInput(frame); await expect(messageInput).toBeVisible({ timeout: 5000 }); await messageInput.fill(testMessage); - const sendBtn = frame.locator(".opencom-send"); + const sendBtn = frame.locator("[data-testid='widget-send-button'], .opencom-send").first(); await expect(sendBtn).toBeVisible({ timeout: 2000 }); await sendBtn.click(); // Verify message appears in widget conversation - await expect(frame.getByText(testMessage)).toBeVisible({ timeout: 5000 }); + await expect(frame.getByText(testMessage)).toBeVisible({ timeout: 10000 }); // Note: Full sync test would require admin login and inbox verification // This is handled in the integration test suite with proper auth setup @@ -287,6 +296,8 @@ test.describe("Widget Email Capture", () => { await page.evaluate( ({ wsId, convexUrl }) => { window.OpencomWidget?.destroy(); + localStorage.removeItem("opencom_session_id"); + localStorage.removeItem("opencom_visitor_id"); sessionStorage.removeItem("opencom_email_dismissed"); window.OpencomWidget?.init({ @@ -350,7 +361,8 @@ test.describe("Widget Email Capture", () => { await expect(capturePrompt).not.toBeVisible({ timeout: 20000 }); }); - test("should dismiss email capture prompt", async ({ page }) => { +// Skipped since we have hidden the dismiss button for now + test.skip("should dismiss email capture prompt", async ({ page }) => { const widget = await openWidgetChatOrSkip(page); await startConversationIfNeeded(widget); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 2a887fc..5021f99 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -41,7 +41,7 @@ const SECURITY_HEADERS = [ ]; const nextConfig = { - transpilePackages: ["@opencom/ui"], + transpilePackages: ["@opencom/ui", "@opencom/web-shared"], experimental: { // Reduce memory usage during webpack compilation webpackMemoryOptimizations: true, diff --git a/apps/web/package.json b/apps/web/package.json index db5d0bb..59e7c4f 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" @@ -14,10 +14,10 @@ "dependencies": { "@convex-dev/auth": "^0.0.90", "@opencom/convex": "workspace:*", - "@opencom/sdk-core": "workspace:*", "@opencom/types": "workspace:*", "@opencom/ui": "workspace:*", - "convex": "^1.31.7", + "@opencom/web-shared": "workspace:*", + "convex": "^1.32.0", "dompurify": "^3.3.1", "fflate": "^0.8.2", "lucide-react": "^0.469.0", diff --git a/apps/web/src/app/articles/ArticlesImportSection.tsx b/apps/web/src/app/articles/ArticlesImportSection.tsx new file mode 100644 index 0000000..de6675c --- /dev/null +++ b/apps/web/src/app/articles/ArticlesImportSection.tsx @@ -0,0 +1,355 @@ +"use client"; + +import type { ChangeEvent, MutableRefObject } from "react"; +import type { Id } from "@opencom/convex/dataModel"; +import { Button, Input } from "@opencom/ui"; +import { Download, History, Upload } from "lucide-react"; +import { + type CollectionListItem, + type ImportHistoryListItem, + type ImportSourceListItem, + type MarkdownImportPreview, +} from "./articlesAdminTypes"; +import { formatPreviewPathSample, getCollectionLabel } from "./articlesAdminUtils"; + +type ArticlesImportSectionProps = { + folderInputRef: MutableRefObject; + collections: CollectionListItem[] | undefined; + importSources: ImportSourceListItem[] | undefined; + importHistory: ImportHistoryListItem[] | undefined; + importSourceName: string; + importTargetCollectionId: Id<"collections"> | undefined; + selectedImportPaths: string[]; + selectedImportAssetPaths: string[]; + importPreview: MarkdownImportPreview | null; + hasCurrentPreview: boolean; + isPreviewStale: boolean; + isPreviewingImport: boolean; + isImporting: boolean; + isExporting: boolean; + exportSourceId: Id<"helpCenterImportSources"> | undefined; + importError: string | null; + importNotice: string | null; + restoringRunId: string | null; + onFolderSelection: (event: ChangeEvent) => void; + onImportSourceNameChange: (value: string) => void; + onImportTargetCollectionChange: (value: string) => void; + onPreviewImport: () => void; + onStartImport: () => void; + onExportSourceChange: (value: string) => void; + onExportMarkdown: () => void; + onRestoreRun: (sourceId: Id<"helpCenterImportSources">, importRunId: string) => void; +}; + +export function ArticlesImportSection({ + folderInputRef, + collections, + importSources, + importHistory, + importSourceName, + importTargetCollectionId, + selectedImportPaths, + selectedImportAssetPaths, + importPreview, + hasCurrentPreview, + isPreviewStale, + isPreviewingImport, + isImporting, + isExporting, + exportSourceId, + importError, + importNotice, + restoringRunId, + onFolderSelection, + onImportSourceNameChange, + onImportTargetCollectionChange, + onPreviewImport, + onStartImport, + onExportSourceChange, + onExportMarkdown, + onRestoreRun, +}: ArticlesImportSectionProps): React.JSX.Element { + return ( +
+
+
+

Import Markdown Folder

+

+ Sync markdown files into Help Center collections. Reuploading overwrites matching + paths, adds new files, and archives removed paths for restore. Preview changes before + applying. +

+

+ Imports normalize folder uploads on the backend so the selected upload root folder does + not become an extra collection level. +

+
+ { + folderInputRef.current = node; + if (node) { + node.setAttribute("webkitdirectory", ""); + node.setAttribute("directory", ""); + } + }} + type="file" + className="hidden" + multiple + onChange={onFolderSelection} + /> + +
+ +
+
+ + onImportSourceNameChange(event.target.value)} + placeholder="docs" + /> +
+
+ + +
+
+ + {(selectedImportPaths.length > 0 || selectedImportAssetPaths.length > 0) && ( +
+
+ {selectedImportPaths.length} markdown file{selectedImportPaths.length !== 1 ? "s" : ""}{" "} + and {selectedImportAssetPaths.length} image file + {selectedImportAssetPaths.length !== 1 ? "s" : ""} selected +
+
+ {selectedImportPaths.slice(0, 6).map((path) => ( +
+ md: {path} +
+ ))} + {selectedImportAssetPaths.slice(0, 6).map((path) => ( +
+ img: {path} +
+ ))} + {selectedImportPaths.length > 6 && ( +
+ {selectedImportPaths.length - 6} more markdown files...
+ )} + {selectedImportAssetPaths.length > 6 && ( +
+ {selectedImportAssetPaths.length - 6} more image files...
+ )} +
+
+ )} + + {hasCurrentPreview && importPreview && ( +
+
Import Preview
+
+
+ Collections: +{importPreview.createdCollections} / ~{importPreview.updatedCollections}{" "} + / -{importPreview.deletedCollections} +
+
+ Articles: +{importPreview.createdArticles} / ~{importPreview.updatedArticles} / - + {importPreview.deletedArticles} +
+
+ {importPreview.strippedRootFolder && ( +
+ Upload root folder “{importPreview.strippedRootFolder}” will be ignored. +
+ )} + {importPreview.unresolvedImageReferences && + importPreview.unresolvedImageReferences.length > 0 && ( +
+ Unresolved image references ({importPreview.unresolvedImageReferences.length}):{" "} + + {formatPreviewPathSample(importPreview.unresolvedImageReferences)} + +
+ )} +
+
+
Article Changes
+
+ Create: {importPreview.preview.articles.create.length}, Update:{" "} + {importPreview.preview.articles.update.length}, Delete:{" "} + {importPreview.preview.articles.delete.length} +
+
+
+
Collection Changes
+
+ Create: {importPreview.preview.collections.create.length}, Update:{" "} + {importPreview.preview.collections.update.length}, Delete:{" "} + {importPreview.preview.collections.delete.length} +
+
+
+
+ {importPreview.preview.articles.delete.length > 0 && ( +
+ Articles to delete:{" "} + + {formatPreviewPathSample(importPreview.preview.articles.delete)} + +
+ )} + {importPreview.preview.articles.create.length > 0 && ( +
+ Articles to create:{" "} + + {formatPreviewPathSample(importPreview.preview.articles.create)} + +
+ )} + {importPreview.preview.collections.create.length > 0 && ( +
+ Collections to create:{" "} + + {formatPreviewPathSample(importPreview.preview.collections.create)} + +
+ )} + {importPreview.preview.collections.delete.length > 0 && ( +
+ Collections to delete:{" "} + + {formatPreviewPathSample(importPreview.preview.collections.delete)} + +
+ )} +
+
+ )} + + {isPreviewStale && ( +
+ Import inputs changed after preview. Run Preview Changes again before applying. +
+ )} + +
+ + + + +
+ + {importError && ( +
+ {importError} +
+ )} + {importNotice && ( +
+ {importNotice} +
+ )} + + {(importSources?.length ?? 0) > 0 && ( +
+

Active Import Sources

+
+ {importSources?.map((source) => ( +
+ + {source.sourceName} + {source.rootCollectionName ? ` -> ${source.rootCollectionName}` : " -> root"} + + {source.lastImportedFileCount ?? 0} files +
+ ))} +
+
+ )} + + {(importHistory?.length ?? 0) > 0 && ( +
+
+ +

Deletion History

+
+
+ {importHistory?.map((run) => ( +
+
+
{run.sourceName}
+
+ Removed {run.deletedArticles} article{run.deletedArticles !== 1 ? "s" : ""} + {" + "} + {run.deletedCollections} collection{run.deletedCollections !== 1 ? "s" : ""} +
+
+ +
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/articles/ArticlesListSection.tsx b/apps/web/src/app/articles/ArticlesListSection.tsx new file mode 100644 index 0000000..36fae19 --- /dev/null +++ b/apps/web/src/app/articles/ArticlesListSection.tsx @@ -0,0 +1,265 @@ +"use client"; + +import Link from "next/link"; +import { Button, Input } from "@opencom/ui"; +import { Eye, EyeOff, FileText, Pencil, Plus, Search, Trash2 } from "lucide-react"; +import type { + ArticleEditorId, + ArticleListItem, + CollectionFilter, + CollectionFilterItem, + CollectionListItem, +} from "./articlesAdminTypes"; +import { + ALL_STATUS_FILTER, + ALL_VISIBILITY_FILTER, + formatDate, + getArticleCollectionFilter, + getCollectionName, + type StatusFilter, + type VisibilityFilter, +} from "./articlesAdminUtils"; + +type ArticlesListSectionProps = { + searchQuery: string; + collectionFilter: CollectionFilter; + visibilityFilter: VisibilityFilter; + statusFilter: StatusFilter; + collectionFilterItems: CollectionFilterItem[]; + filteredArticles: ArticleListItem[]; + collections: CollectionListItem[] | undefined; + hasArticles: boolean; + hasActiveFilters: boolean; + onSearchQueryChange: (value: string) => void; + onCollectionFilterChange: (value: CollectionFilter) => void; + onVisibilityFilterChange: (value: VisibilityFilter) => void; + onStatusFilterChange: (value: StatusFilter) => void; + onClearAllFilters: () => void; + onCreateArticle: () => void; + onCreateInternalArticle: () => void; + onTogglePublish: (id: ArticleEditorId, isPublished: boolean) => void; + onDeleteRequest: (id: ArticleEditorId, title: string) => void; +}; + +export function ArticlesListSection({ + searchQuery, + collectionFilter, + visibilityFilter, + statusFilter, + collectionFilterItems, + filteredArticles, + collections, + hasArticles, + hasActiveFilters, + onSearchQueryChange, + onCollectionFilterChange, + onVisibilityFilterChange, + onStatusFilterChange, + onClearAllFilters, + onCreateArticle, + onCreateInternalArticle, + onTogglePublish, + onDeleteRequest, +}: ArticlesListSectionProps): React.JSX.Element { + return ( + <> +
+
+ + onSearchQueryChange(event.target.value)} + className="pl-10" + /> +
+ + + {hasActiveFilters && ( + + )} +
+ +
+ {collectionFilterItems.map((filterItem) => { + const isActive = collectionFilter === filterItem.id; + return ( + + ); + })} +
+ + {filteredArticles.length === 0 ? ( +
+ +

+ {hasArticles ? "No matching articles" : "No articles yet"} +

+

+ {hasArticles + ? "Try another search term or collection filter." + : "Create your first article to help your team or your customers"} +

+ {hasArticles ? ( + + ) : ( +
+ + +
+ )} +
+ ) : ( +
+ + + + + + + + + + + + + {filteredArticles.map((article) => { + const articleCollectionFilter = getArticleCollectionFilter(article.collectionId); + return ( + + + + + + + + + ); + })} + +
Title + Collection + + Visibility + StatusUpdated
+ + {article.title} + + + + + + {(article.visibility ?? "public") === "internal" ? "Internal" : "Public"} + + + + {article.status} + + {formatDate(article.updatedAt)} +
+ + + + + +
+
+
+ )} + + ); +} diff --git a/apps/web/src/app/articles/DeleteArticleDialog.tsx b/apps/web/src/app/articles/DeleteArticleDialog.tsx new file mode 100644 index 0000000..34137e5 --- /dev/null +++ b/apps/web/src/app/articles/DeleteArticleDialog.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Button } from "@opencom/ui"; +import type { DeleteArticleTarget } from "./articlesAdminTypes"; + +type DeleteArticleDialogProps = { + target: DeleteArticleTarget | null; + error: string | null; + isDeleting: boolean; + onCancel: () => void; + onConfirm: () => void; +}; + +export function DeleteArticleDialog({ + target, + error, + isDeleting, + onCancel, + onConfirm, +}: DeleteArticleDialogProps) { + if (!target) { + return null; + } + + return ( +
+
+

Delete Article

+

+ Are you sure you want to delete {target.title}? This action cannot be + undone. +

+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/articles/[id]/page.test.tsx b/apps/web/src/app/articles/[id]/page.test.tsx new file mode 100644 index 0000000..646b38c --- /dev/null +++ b/apps/web/src/app/articles/[id]/page.test.tsx @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const { + apiMock, + useAuthMock, + useParamsMock, + useQueryMock, + useMutationMock, + updateArticleMock, + publishArticleMock, + unpublishArticleMock, + archiveArticleMock, + generateAssetUploadUrlMock, + saveAssetMock, + deleteAssetMock, +} = vi.hoisted(() => ({ + apiMock: { + articles: { + get: "articles.get", + listAssets: "articles.listAssets", + update: "articles.update", + publish: "articles.publish", + unpublish: "articles.unpublish", + archive: "articles.archive", + generateAssetUploadUrl: "articles.generateAssetUploadUrl", + saveAsset: "articles.saveAsset", + deleteAsset: "articles.deleteAsset", + }, + collections: { + listHierarchy: "collections.listHierarchy", + }, + }, + useAuthMock: vi.fn(), + useParamsMock: vi.fn(), + useQueryMock: vi.fn(), + useMutationMock: vi.fn(), + updateArticleMock: vi.fn(), + publishArticleMock: vi.fn(), + unpublishArticleMock: vi.fn(), + archiveArticleMock: vi.fn(), + generateAssetUploadUrlMock: vi.fn(), + saveAssetMock: vi.fn(), + deleteAssetMock: vi.fn(), +})); + +vi.mock("@opencom/convex", () => ({ + api: apiMock, +})); + +vi.mock("convex/react", () => ({ + useQuery: (...args: unknown[]) => useQueryMock(...args), + useMutation: (...args: unknown[]) => useMutationMock(...args), +})); + +vi.mock("next/navigation", () => ({ + useParams: () => useParamsMock(), +})); + +vi.mock("@/contexts/AuthContext", () => ({ + useAuth: () => useAuthMock(), +})); + +vi.mock("@/components/AudienceRuleBuilder", () => ({ + AudienceRuleBuilder: () =>
, +})); + +vi.mock("next/link", () => ({ + default: ({ href, children, ...rest }: React.ComponentProps<"a">) => ( + + {children} + + ), +})); + +import ArticleEditorPage from "./page"; + +function resolveFunctionPath(ref: unknown): string { + if (typeof ref === "string") { + return ref; + } + + if (!ref || typeof ref !== "object") { + return ""; + } + + const maybeRef = ref as { + functionName?: string; + reference?: { functionName?: string; name?: string }; + name?: string; + referencePath?: string; + function?: { name?: string }; + }; + + const symbolFunctionName = Object.getOwnPropertySymbols(ref).find((symbol) => + String(symbol).includes("functionName") + ); + + const symbolValue = symbolFunctionName + ? (ref as Record)[symbolFunctionName] + : undefined; + + return ( + (typeof symbolValue === "string" ? symbolValue : undefined) ?? + maybeRef.functionName ?? + maybeRef.reference?.functionName ?? + maybeRef.reference?.name ?? + maybeRef.name ?? + maybeRef.referencePath ?? + maybeRef.function?.name ?? + "" + ); +} + +function renderArticleEditor(options?: { + visibility?: "public" | "internal"; + tags?: string[]; +}) { + const article = { + _id: "article-1", + title: "Refund policy", + slug: "refund-policy", + content: "Base article body", + status: "draft" as const, + visibility: options?.visibility ?? "public", + collectionId: undefined, + tags: options?.tags ?? [], + audienceRules: undefined, + }; + + useParamsMock.mockReturnValue({ id: article._id }); + useAuthMock.mockReturnValue({ + activeWorkspace: { + _id: "workspace-1", + }, + }); + + useQueryMock.mockImplementation((_, args: unknown) => { + if (args === "skip") { + return undefined; + } + + const queryArgs = args as { id?: string; articleId?: string; workspaceId?: string } | undefined; + + if (queryArgs?.id === article._id) { + return article; + } + + if (queryArgs?.articleId === article._id) { + return []; + } + + if (queryArgs?.workspaceId === "workspace-1") { + return []; + } + + return undefined; + }); + + useMutationMock.mockImplementation((mutationRef: unknown) => { + const functionPath = resolveFunctionPath(mutationRef); + + if (functionPath === "articles:update" || functionPath === "articles.update") { + return updateArticleMock; + } + + if (functionPath === "articles:publish" || functionPath === "articles.publish") { + return publishArticleMock; + } + + if (functionPath === "articles:unpublish" || functionPath === "articles.unpublish") { + return unpublishArticleMock; + } + + if (functionPath === "articles:archive" || functionPath === "articles.archive") { + return archiveArticleMock; + } + + if ( + functionPath === "articles:generateAssetUploadUrl" || + functionPath === "articles.generateAssetUploadUrl" + ) { + return generateAssetUploadUrlMock; + } + + if (functionPath === "articles:saveAsset" || functionPath === "articles.saveAsset") { + return saveAssetMock; + } + + if (functionPath === "articles:deleteAsset" || functionPath === "articles.deleteAsset") { + return deleteAssetMock; + } + + return vi.fn(); + }); + + return render(); +} + +describe("ArticleEditorPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + updateArticleMock.mockResolvedValue(undefined); + publishArticleMock.mockResolvedValue(undefined); + unpublishArticleMock.mockResolvedValue(undefined); + archiveArticleMock.mockResolvedValue(undefined); + generateAssetUploadUrlMock.mockResolvedValue(undefined); + saveAssetMock.mockResolvedValue(undefined); + deleteAssetMock.mockResolvedValue(undefined); + }); + + it("saves internal article visibility and tags through the unified editor", async () => { + renderArticleEditor(); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Article title")).toHaveValue("Refund policy"); + }); + + fireEvent.change(screen.getByPlaceholderText("Article title"), { + target: { value: "Refund policy (internal)" }, + }); + const visibilitySelect = screen.getByDisplayValue("Public help article"); + fireEvent.change(visibilitySelect, { target: { value: "internal" } }); + fireEvent.change(screen.getByPlaceholderText("billing, enterprise, refunds"), { + target: { value: "billing, vip" }, + }); + fireEvent.change(screen.getByPlaceholderText("Write your article content here..."), { + target: { value: "Internal-only refund handling steps" }, + }); + + expect(screen.getByPlaceholderText("Article title")).toHaveValue("Refund policy (internal)"); + expect(screen.getByPlaceholderText("billing, enterprise, refunds")).toHaveValue("billing, vip"); + expect(screen.getByPlaceholderText("Write your article content here...")).toHaveValue( + "Internal-only refund handling steps" + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(updateArticleMock).toHaveBeenCalledWith({ + id: "article-1", + title: "Refund policy (internal)", + content: "Internal-only refund handling steps", + collectionId: undefined, + visibility: "internal", + tags: ["billing", "vip"], + }); + }); + }); + + it("clears tags when saving an article back to public visibility", async () => { + renderArticleEditor({ + visibility: "internal", + tags: ["refunds", "vip"], + }); + + await waitFor(() => { + expect(screen.getByPlaceholderText("billing, enterprise, refunds")).toHaveValue( + "refunds, vip" + ); + }); + + fireEvent.change(screen.getByPlaceholderText("Article title"), { + target: { value: "Refund policy (public)" }, + }); + const visibilitySelect = screen.getByDisplayValue("Internal knowledge article"); + fireEvent.change(visibilitySelect, { target: { value: "public" } }); + fireEvent.change(screen.getByPlaceholderText("Write your article content here..."), { + target: { value: "Base article body (public)" }, + }); + + expect(screen.getByPlaceholderText("Article title")).toHaveValue("Refund policy (public)"); + expect(screen.getByPlaceholderText("Write your article content here...")).toHaveValue( + "Base article body (public)" + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(updateArticleMock).toHaveBeenCalledWith({ + id: "article-1", + title: "Refund policy (public)", + content: "Base article body (public)", + collectionId: undefined, + visibility: "public", + tags: [], + }); + }); + }); +}); diff --git a/apps/web/src/app/articles/[id]/page.tsx b/apps/web/src/app/articles/[id]/page.tsx index 6a17e0a..2a173b9 100644 --- a/apps/web/src/app/articles/[id]/page.tsx +++ b/apps/web/src/app/articles/[id]/page.tsx @@ -2,11 +2,9 @@ import { useState, useEffect, useRef } from "react"; import { useParams } from "next/navigation"; -import { useQuery, useMutation } from "convex/react"; -import { api } from "@opencom/convex"; import { useAuth } from "@/contexts/AuthContext"; import { Button, Input } from "@opencom/ui"; -import { ArrowLeft, Save, Eye, EyeOff, Users } from "lucide-react"; +import { Archive, ArrowLeft, Eye, EyeOff, Save, Users } from "lucide-react"; import Link from "next/link"; import type { Id } from "@opencom/convex/dataModel"; import { AudienceRuleBuilder, type AudienceRule } from "@/components/AudienceRuleBuilder"; @@ -15,15 +13,19 @@ import { toInlineAudienceRuleFromBuilder, type InlineAudienceRule, } from "@/lib/audienceRules"; +import type { ArticleEditorId } from "../articlesAdminTypes"; +import { useArticleEditorConvex } from "../hooks/useArticleEditorConvex"; export default function ArticleEditorPage() { const params = useParams(); const { activeWorkspace } = useAuth(); - const articleId = params.id as Id<"articles">; + const articleId = params.id as ArticleEditorId; const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [selectedCollectionId, setSelectedCollectionId] = useState | undefined>(); + const [visibility, setVisibility] = useState<"public" | "internal">("public"); + const [tagsInput, setTagsInput] = useState(""); const [audienceRules, setAudienceRules] = useState(null); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); @@ -31,46 +33,59 @@ export default function ArticleEditorPage() { const [assetError, setAssetError] = useState(null); const [removingAssetId, setRemovingAssetId] = useState | null>(null); const uploadInputRef = useRef(null); - - const article = useQuery(api.articles.get, { id: articleId }); - const articleAssets = useQuery( - api.articles.listAssets, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id, articleId } : "skip" - ); - const collections = useQuery( - api.collections.listHierarchy, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" - ); - - const updateArticle = useMutation(api.articles.update); - const publishArticle = useMutation(api.articles.publish); - const unpublishArticle = useMutation(api.articles.unpublish); - const generateAssetUploadUrl = useMutation(api.articles.generateAssetUploadUrl); - const saveAsset = useMutation(api.articles.saveAsset); - const deleteAsset = useMutation(api.articles.deleteAsset); + const hydratedArticleIdRef = useRef(null); + const { + archiveArticle, + article, + articleAssets, + collections, + deleteAsset, + generateAssetUploadUrl, + publishArticle, + saveAsset, + unpublishArticle, + updateArticle, + } = useArticleEditorConvex({ + articleId, + workspaceId: activeWorkspace?._id, + }); useEffect(() => { - if (article) { - setTitle(article.title); - setContent(article.content); - setSelectedCollectionId(article.collectionId); - setAudienceRules(toInlineAudienceRule(article.audienceRules)); + if (!article || articleId == null) { + return; + } + + if (hydratedArticleIdRef.current === articleId && hasChanges) { + return; } - }, [article]); + + hydratedArticleIdRef.current = articleId; + setTitle(article.title); + setContent(article.content); + setSelectedCollectionId(article.collectionId); + setVisibility(article.visibility ?? "public"); + setTagsInput((article.tags ?? []).join(", ")); + setAudienceRules(toInlineAudienceRule(article.audienceRules)); + }, [article, articleId, hasChanges]); const handleSave = async () => { if (!articleId) return; setIsSaving(true); - const mutationAudienceRules = audienceRules - ? (audienceRules as Parameters[0]["audienceRules"]) - : undefined; try { await updateArticle({ id: articleId, title, content, collectionId: selectedCollectionId, - ...(mutationAudienceRules ? { audienceRules: mutationAudienceRules } : {}), + visibility, + tags: + visibility === "internal" + ? tagsInput + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean) + : [], + ...(audienceRules ? { audienceRules } : {}), }); setHasChanges(false); } catch (error) { @@ -89,6 +104,11 @@ export default function ArticleEditorPage() { } }; + const handleArchive = async () => { + await archiveArticle({ id: articleId }); + setHasChanges(false); + }; + const handleTitleChange = (value: string) => { setTitle(value); setHasChanges(true); @@ -104,6 +124,11 @@ export default function ArticleEditorPage() { setHasChanges(true); }; + const handleVisibilityChange = (value: "public" | "internal") => { + setVisibility(value); + setHasChanges(true); + }; + const handleAudienceRulesChange = (rules: AudienceRule | null) => { setAudienceRules(toInlineAudienceRuleFromBuilder(rules)); setHasChanges(true); @@ -207,20 +232,40 @@ export default function ArticleEditorPage() { className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${ article.status === "published" ? "bg-green-100 text-green-800" - : "bg-gray-100 text-gray-800" + : article.status === "archived" + ? "bg-amber-100 text-amber-800" + : "bg-gray-100 text-gray-800" }`} > {article.status} + + {visibility === "internal" ? "Internal" : "Public"} + {hasChanges && Unsaved changes}
+
+
+
+ + +
+
+ + { + setTagsInput(e.target.value); + setHasChanges(true); + }} + placeholder="billing, enterprise, refunds" + /> +

+ Comma-separated tags help agents and AI find internal content quickly. +

+
+
+