diff --git a/.agents/skills/convex-create-component/SKILL.md b/.agents/skills/convex-create-component/SKILL.md new file mode 100644 index 0000000..22af601 --- /dev/null +++ b/.agents/skills/convex-create-component/SKILL.md @@ -0,0 +1,288 @@ +--- +name: convex-create-component +description: Designs and builds Convex components with isolated tables, clear boundaries, and app-facing wrappers. Use this skill when creating a new Convex component, extracting reusable backend logic into a component, building a third-party integration that owns its own tables, packaging Convex functionality for reuse, or when the user mentions defineComponent, app.use, ComponentApi, ctx.runQuery/runMutation across component boundaries, or wants to separate concerns into isolated Convex modules. +--- + +# Convex Create Component + +Create reusable Convex components with clear boundaries and a small app-facing API. + +## When to Use + +- Creating a new Convex component in an existing app +- Extracting reusable backend logic into a component +- Building a third-party integration that should own its own tables and workflows +- Packaging Convex functionality for reuse across multiple apps + +## When Not to Use + +- One-off business logic that belongs in the main app +- Thin utilities that do not need Convex tables or functions +- App-level orchestration that should stay in `convex/` +- Cases where a normal TypeScript library is enough + +## Workflow + +1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding. +2. Choose the shape using the decision tree below and read the matching reference file. +3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state. +4. Make a short plan for: + - what tables the component owns + - what public functions it exposes + - what data must be passed in from the app (auth, env vars, parent IDs) + - what stays in the app as wrappers or HTTP mounts +5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files. +6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files. +7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it. +8. Call the component from the app through `components.` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`. +9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly. +10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing. + +## Choose the Shape + +Ask the user, then pick one path: + +| Goal | Shape | Reference | +| ------------------------------------------------- | ---------------- | ----------------------------------- | +| Component for this app only | Local | `references/local-components.md` | +| Publish or share across apps | Packaged | `references/packaged-components.md` | +| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` | +| Not sure | Default to local | `references/local-components.md` | + +Read exactly one reference file before proceeding. + +## Default Approach + +Unless the user explicitly wants an npm package, default to a local component: + +- Put it under `convex/components//` +- Define it with `defineComponent(...)` in its own `convex.config.ts` +- Install it from the app's `convex/convex.config.ts` with `app.use(...)` +- Let `npx convex dev` generate the component's own `_generated/` files + +## Component Skeleton + +A minimal local component with a table and two functions, plus the app wiring. + +```ts +// convex/components/notifications/convex.config.ts +import { defineComponent } from "convex/server"; + +export default defineComponent("notifications"); +``` + +```ts +// convex/components/notifications/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + notifications: defineTable({ + userId: v.string(), + message: v.string(), + read: v.boolean(), + }).index("by_user", ["userId"]), +}); +``` + +```ts +// convex/components/notifications/lib.ts +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server.js"; + +export const send = mutation({ + args: { userId: v.string(), message: v.string() }, + returns: v.id("notifications"), + handler: async (ctx, args) => { + return await ctx.db.insert("notifications", { + userId: args.userId, + message: args.message, + read: false, + }); + }, +}); + +export const listUnread = query({ + args: { userId: v.string() }, + returns: v.array( + v.object({ + _id: v.id("notifications"), + _creationTime: v.number(), + userId: v.string(), + message: v.string(), + read: v.boolean(), + }), + ), + handler: async (ctx, args) => { + return await ctx.db + .query("notifications") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .filter((q) => q.eq(q.field("read"), false)) + .collect(); + }, +}); +``` + +```ts +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import notifications from "./components/notifications/convex.config.js"; + +const app = defineApp(); +app.use(notifications); + +export default app; +``` + +```ts +// convex/notifications.ts (app-side wrapper) +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { components } from "./_generated/api"; +import { getAuthUserId } from "@convex-dev/auth/server"; + +export const sendNotification = mutation({ + args: { message: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + await ctx.runMutation(components.notifications.lib.send, { + userId, + message: args.message, + }); + return null; + }, +}); + +export const myUnread = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + return await ctx.runQuery(components.notifications.lib.listUnread, { + userId, + }); + }, +}); +``` + +Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app. + +## Critical Rules + +- Keep authentication in the app, because `ctx.auth` is not available inside components. +- Keep environment access in the app, because component functions cannot read `process.env`. +- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`. +- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace. +- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files. +- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides. +- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes. +- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary. +- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts. + +## Patterns + +### Authentication and environment access + +```ts +// Bad: component code cannot rely on app auth or env +const identity = await ctx.auth.getUserIdentity(); +const apiKey = process.env.OPENAI_API_KEY; +``` + +```ts +// Good: the app resolves auth and env, then passes explicit values +const userId = await getAuthUserId(ctx); +if (!userId) throw new Error("Not authenticated"); + +await ctx.runAction(components.translator.translate, { + userId, + apiKey: process.env.OPENAI_API_KEY, + text: args.text, +}); +``` + +### Client-facing API + +```ts +// Bad: assuming a component function is directly callable by clients +export const send = components.notifications.send; +``` + +```ts +// Good: re-export through an app mutation or query +export const sendNotification = mutation({ + args: { message: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Not authenticated"); + + await ctx.runMutation(components.notifications.lib.send, { + userId, + message: args.message, + }); + return null; + }, +}); +``` + +### IDs across the boundary + +```ts +// Bad: parent app table IDs are not valid component validators +args: { + userId: v.id("users"); +} +``` + +```ts +// Good: treat parent-owned IDs as strings at the boundary +args: { + userId: v.string(); +} +``` + +### Advanced Patterns + +For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`. + +## Validation + +Try validation in this order: + +1. `npx convex codegen --component-dir convex/components/` +2. `npx convex codegen` +3. `npx convex dev` + +Important: + +- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured. +- Until codegen runs, component-local `./_generated/*` imports and app-side `components....` references will not typecheck. +- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing. + +## Reference Files + +Read exactly one of these after the user confirms the goal: + +- `references/local-components.md` +- `references/packaged-components.md` +- `references/hybrid-components.md` + +Official docs: [Authoring Components](https://docs.convex.dev/components/authoring) + +## Checklist + +- [ ] Asked the user what they want to build and confirmed the shape +- [ ] Read the matching reference file +- [ ] Confirmed a component is the right abstraction +- [ ] Planned tables, public API, boundaries, and app wrappers +- [ ] Component lives under `convex/components//` (or package layout if publishing) +- [ ] Component imports from its own `./_generated/server` +- [ ] Auth, env access, and HTTP routes stay in the app +- [ ] Parent app IDs cross the boundary as `v.string()` +- [ ] Public functions have `args` and `returns` validators +- [ ] Ran `npx convex dev` and fixed codegen or type issues diff --git a/.agents/skills/convex-create-component/agents/openai.yaml b/.agents/skills/convex-create-component/agents/openai.yaml new file mode 100644 index 0000000..ba9287e --- /dev/null +++ b/.agents/skills/convex-create-component/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Create Component" + short_description: "Design and build reusable Convex components with clear boundaries." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#14B8A6" + default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/convex-create-component/assets/icon.svg b/.agents/skills/convex-create-component/assets/icon.svg new file mode 100644 index 0000000..10f4c2c --- /dev/null +++ b/.agents/skills/convex-create-component/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agents/skills/convex-create-component/references/advanced-patterns.md b/.agents/skills/convex-create-component/references/advanced-patterns.md new file mode 100644 index 0000000..3deb684 --- /dev/null +++ b/.agents/skills/convex-create-component/references/advanced-patterns.md @@ -0,0 +1,134 @@ +# Advanced Component Patterns + +Additional patterns for Convex components that go beyond the basics covered in the main skill file. + +## Function Handles for callbacks + +When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow. + +```ts +// App side: create a handle and pass it to the component +import { createFunctionHandle } from "convex/server"; + +export const startJob = mutation({ + handler: async (ctx) => { + const handle = await createFunctionHandle(internal.myModule.processItem); + await ctx.runMutation(components.workpool.enqueue, { + callback: handle, + }); + }, +}); +``` + +```ts +// Component side: accept and invoke the handle +import { v } from "convex/values"; +import type { FunctionHandle } from "convex/server"; +import { mutation } from "./_generated/server.js"; + +export const enqueue = mutation({ + args: { callback: v.string() }, + handler: async (ctx, args) => { + const handle = args.callback as FunctionHandle<"mutation">; + await ctx.scheduler.runAfter(0, handle, {}); + }, +}); +``` + +## Deriving validators from schema + +Instead of manually repeating field types in return validators, extend the schema validator: + +```ts +import { v } from "convex/values"; +import schema from "./schema.js"; + +const notificationDoc = schema.tables.notifications.validator.extend({ + _id: v.id("notifications"), + _creationTime: v.number(), +}); + +export const getLatest = query({ + args: {}, + returns: v.nullable(notificationDoc), + handler: async (ctx) => { + return await ctx.db.query("notifications").order("desc").first(); + }, +}); +``` + +## Static configuration with a globals table + +A common pattern for component configuration is a single-document "globals" table: + +```ts +// schema.ts +export default defineSchema({ + globals: defineTable({ + maxRetries: v.number(), + webhookUrl: v.optional(v.string()), + }), + // ... other tables +}); +``` + +```ts +// lib.ts +export const configure = mutation({ + args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db.query("globals").first(); + if (existing) { + await ctx.db.patch(existing._id, args); + } else { + await ctx.db.insert("globals", args); + } + return null; + }, +}); +``` + +## Class-based client wrappers + +For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components. + +```ts +// src/client/index.ts +import type { GenericMutationCtx, GenericDataModel } from "convex/server"; +import type { ComponentApi } from "../component/_generated/component.js"; + +type MutationCtx = Pick, "runMutation">; + +export class Notifications { + constructor( + private component: ComponentApi, + private options?: { defaultChannel?: string }, + ) {} + + async send(ctx: MutationCtx, args: { userId: string; message: string }) { + return await ctx.runMutation(this.component.lib.send, { + ...args, + channel: this.options?.defaultChannel ?? "default", + }); + } +} +``` + +```ts +// App usage +import { Notifications } from "@convex-dev/notifications"; +import { components } from "./_generated/api"; + +const notifications = new Notifications(components.notifications, { + defaultChannel: "alerts", +}); + +export const send = mutation({ + args: { message: v.string() }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + await notifications.send(ctx, { userId, message: args.message }); + }, +}); +``` diff --git a/.agents/skills/convex-create-component/references/hybrid-components.md b/.agents/skills/convex-create-component/references/hybrid-components.md new file mode 100644 index 0000000..d2bb351 --- /dev/null +++ b/.agents/skills/convex-create-component/references/hybrid-components.md @@ -0,0 +1,37 @@ +# Hybrid Convex Components + +Read this file only when the user explicitly wants a hybrid setup. + +## What This Means + +A hybrid component combines a local Convex component with shared library code. + +This can help when: + +- the user wants a local install but also shared package logic +- the component needs extension points or override hooks +- some logic should live in normal TypeScript code outside the component boundary + +## Default Advice + +Treat hybrid as an advanced option, not the default. + +Before choosing it, ask: + +- Why is a plain local component not enough? +- Why is a packaged component not enough? +- What exactly needs to stay overridable or shared? + +If the answer is vague, fall back to local or packaged. + +## Risks + +- More moving parts +- Harder upgrades and backwards compatibility +- Easier to blur the component boundary + +## Checklist + +- [ ] User explicitly needs hybrid behavior +- [ ] Local-only and packaged-only options were considered first +- [ ] The extension points are clearly defined before coding diff --git a/.agents/skills/convex-create-component/references/local-components.md b/.agents/skills/convex-create-component/references/local-components.md new file mode 100644 index 0000000..7fbfe21 --- /dev/null +++ b/.agents/skills/convex-create-component/references/local-components.md @@ -0,0 +1,38 @@ +# Local Convex Components + +Read this file when the component should live inside the current app and does not need to be published as an npm package. + +## When to Choose This + +- The user wants the simplest path +- The component only needs to work in this repo +- The goal is extracting app logic into a cleaner boundary + +## Default Layout + +Use this structure unless the repo already has a clear alternative pattern: + +```text +convex/ + convex.config.ts + components/ + / + convex.config.ts + schema.ts + .ts +``` + +## Workflow Notes + +- Define the component with `defineComponent("")` +- Install it from the app with `defineApp()` and `app.use(...)` +- Keep auth, env access, public API wrappers, and HTTP route mounting in the app +- Let the component own isolated tables and reusable backend workflows +- Add app wrappers if clients need to call into the component + +## Checklist + +- [ ] Component is inside `convex/components//` +- [ ] App installs it with `app.use(...)` +- [ ] Component owns only its own tables +- [ ] App wrappers handle client-facing calls when needed diff --git a/.agents/skills/convex-create-component/references/packaged-components.md b/.agents/skills/convex-create-component/references/packaged-components.md new file mode 100644 index 0000000..5668e7e --- /dev/null +++ b/.agents/skills/convex-create-component/references/packaged-components.md @@ -0,0 +1,51 @@ +# Packaged Convex Components + +Read this file when the user wants a reusable npm package or a component shared across multiple apps. + +## When to Choose This + +- The user wants to publish the component +- The user wants a stable reusable package boundary +- The component will be shared across multiple apps or teams + +## Default Approach + +- Prefer starting from `npx create-convex@latest --component` when possible +- Keep the official authoring docs as the source of truth for package layout and exports +- Validate the bundled package through an example app, not just the source files + +## Build Flow + +When building a packaged component, make sure the bundled output exists before the example app tries to consume it. + +Recommended order: + +1. `npx convex codegen --component-dir ./path/to/component` +2. Run the package build command +3. Run `npx convex dev --typecheck-components` in the example app + +Do not assume normal app codegen is enough for packaged component workflows. + +## Package Exports + +If publishing to npm, make sure the package exposes the entry points apps need: + +- package root for client helpers, types, or classes +- `./convex.config.js` for installing the component +- `./_generated/component.js` for the app-facing `ComponentApi` type +- `./test` for testing helpers when applicable + +## Testing + +- Use `convex-test` for component logic +- Register the component schema and modules with the test instance +- Test app-side wrapper code from an example app that installs the package +- Export a small helper from `./test` if consumers need easy test registration + +## Checklist + +- [ ] Packaging is actually required +- [ ] Build order avoids bundle and codegen races +- [ ] Package exports include install and typing entry points +- [ ] Example app exercises the packaged component +- [ ] Core behavior is covered by tests diff --git a/.agents/skills/convex-migration-helper/SKILL.md b/.agents/skills/convex-migration-helper/SKILL.md new file mode 100644 index 0000000..db36c62 --- /dev/null +++ b/.agents/skills/convex-migration-helper/SKILL.md @@ -0,0 +1,149 @@ +--- +name: convex-migration-helper +description: Plans and executes safe Convex schema and data migrations using the widen-migrate-narrow workflow and the @convex-dev/migrations component. Use this skill when a deployment fails schema validation, existing documents need backfilling, fields need adding or removing or changing type, tables need splitting or merging, or a zero-downtime migration strategy is needed. Also use when the user mentions breaking schema changes, multi-deploy rollouts, or data transformations on existing Convex tables. +--- + +# Convex Migration Helper + +Safely migrate Convex schemas and data when making breaking changes. + +## When to Use + +- Adding new required fields to existing tables +- Changing field types or structure +- Splitting or merging tables +- Renaming or deleting fields +- Migrating from nested to relational data + +## When Not to Use + +- Greenfield schema with no existing data in production or dev +- Adding optional fields that do not need backfilling +- Adding new tables with no existing data to migrate +- Adding or removing indexes with no correctness concern +- Questions about Convex schema design without a migration need + +## Key Concepts + +### Schema Validation Drives the Workflow + +Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration: + +- You cannot add a required field if existing documents don't have it +- You cannot change a field's type if existing documents have the old type +- You cannot remove a field from the schema if existing documents still have it + +This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**. + +### Online Migrations + +Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats. + +### Prefer New Fields Over Changing Types + +When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back. + +### Don't Delete Data + +Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed. + +## Safe Changes (No Migration Needed) + +### Adding Optional Field + +```typescript +// Before +users: defineTable({ + name: v.string(), +}); + +// After - safe, new field is optional +users: defineTable({ + name: v.string(), + bio: v.optional(v.string()), +}); +``` + +### Adding New Table + +```typescript +posts: defineTable({ + userId: v.id("users"), + title: v.string(), +}).index("by_user", ["userId"]); +``` + +### Adding Index + +```typescript +users: defineTable({ + name: v.string(), + email: v.string(), +}).index("by_email", ["email"]); +``` + +## Breaking Changes: The Deployment Workflow + +Every breaking migration follows the same multi-deploy pattern: + +**Deploy 1 - Widen the schema:** + +1. Update schema to allow both old and new formats (e.g., add optional new field) +2. Update code to handle both formats when reading +3. Update code to write the new format for new documents +4. Deploy + +**Between deploys - Migrate data:** + +5. Run migration to backfill existing documents +6. Verify all documents are migrated + +**Deploy 2 - Narrow the schema:** + +7. Update schema to require the new format only +8. Remove code that handles the old format +9. Deploy + +## Using the Migrations Component + +For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring. + +See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options. + +## Common Migration Patterns + +See `references/migration-patterns.md` for complete patterns with code examples covering: + +- Adding a required field +- Deleting a field +- Changing a field type +- Splitting nested data into a separate table +- Cleaning up orphaned documents +- Zero-downtime strategies (dual write, dual read) +- Small table shortcut (single internalMutation without the component) +- Verifying a migration is complete + +## Common Pitfalls + +1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first. +2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small. +3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes." +4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents. +5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it. +6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove. + +## Migration Checklist + +- [ ] Identify the breaking change and plan the multi-deploy workflow +- [ ] Update schema to allow both old and new formats +- [ ] Update code to handle both formats when reading +- [ ] Update code to write the new format for new documents +- [ ] Deploy widened schema and updated code +- [ ] Define migration using the `@convex-dev/migrations` component +- [ ] Test with `dryRun: true` +- [ ] Run migration and monitor status +- [ ] Verify all documents are migrated +- [ ] Update schema to require new format only +- [ ] Clean up code that handled old format +- [ ] Deploy final schema and code +- [ ] Remove migration code once confirmed stable diff --git a/.agents/skills/convex-migration-helper/agents/openai.yaml b/.agents/skills/convex-migration-helper/agents/openai.yaml new file mode 100644 index 0000000..c2a7fcc --- /dev/null +++ b/.agents/skills/convex-migration-helper/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Migration Helper" + short_description: "Plan and run safe Convex schema and data migrations." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#8B5CF6" + default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/convex-migration-helper/assets/icon.svg b/.agents/skills/convex-migration-helper/assets/icon.svg new file mode 100644 index 0000000..fba7241 --- /dev/null +++ b/.agents/skills/convex-migration-helper/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agents/skills/convex-migration-helper/references/migration-patterns.md b/.agents/skills/convex-migration-helper/references/migration-patterns.md new file mode 100644 index 0000000..53b4946 --- /dev/null +++ b/.agents/skills/convex-migration-helper/references/migration-patterns.md @@ -0,0 +1,231 @@ +# Migration Patterns Reference + +Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations. + +## Adding a Required Field + +```typescript +// Deploy 1: Schema allows both states +users: defineTable({ + name: v.string(), + role: v.optional(v.union(v.literal("user"), v.literal("admin"))), +}); + +// Migration: backfill the field +export const addDefaultRole = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.role === undefined) { + await ctx.db.patch(user._id, { role: "user" }); + } + }, +}); + +// Deploy 2: After migration completes, make it required +users: defineTable({ + name: v.string(), + role: v.union(v.literal("user"), v.literal("admin")), +}); +``` + +## Deleting a Field + +Mark the field optional first, migrate data to remove it, then remove from schema: + +```typescript +// Deploy 1: Make optional +// isPro: v.boolean() --> isPro: v.optional(v.boolean()) + +// Migration +export const removeIsPro = migrations.define({ + table: "teams", + migrateOne: async (ctx, team) => { + if (team.isPro !== undefined) { + await ctx.db.patch(team._id, { isPro: undefined }); + } + }, +}); + +// Deploy 2: Remove isPro from schema entirely +``` + +## Changing a Field Type + +Prefer creating a new field. You can combine adding and deleting in one migration: + +```typescript +// Deploy 1: Add new field, keep old field optional +// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...) + +// Migration: convert old field to new field +export const convertToEnum = migrations.define({ + table: "teams", + migrateOne: async (ctx, team) => { + if (team.plan === undefined) { + await ctx.db.patch(team._id, { + plan: team.isPro ? "pro" : "basic", + isPro: undefined, + }); + } + }, +}); + +// Deploy 2: Remove isPro from schema, make plan required +``` + +## Splitting Nested Data Into a Separate Table + +```typescript +export const extractPreferences = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.preferences === undefined) return; + + const existing = await ctx.db + .query("userPreferences") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .first(); + + if (!existing) { + await ctx.db.insert("userPreferences", { + userId: user._id, + ...user.preferences, + }); + } + + await ctx.db.patch(user._id, { preferences: undefined }); + }, +}); +``` + +Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window. + +## Cleaning Up Orphaned Documents + +```typescript +export const deleteOrphanedEmbeddings = migrations.define({ + table: "embeddings", + migrateOne: async (ctx, doc) => { + const chunk = await ctx.db + .query("chunks") + .withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id)) + .first(); + + if (!chunk) { + await ctx.db.delete(doc._id); + } + }, +}); +``` + +## Zero-Downtime Strategies + +During the migration window, your app must handle both old and new data formats. There are two main strategies. + +### Dual Write (Preferred) + +Write to both old and new structures. Read from the old structure until migration is complete. + +1. Deploy code that writes both formats, reads old format +2. Run migration on existing data +3. Deploy code that reads new format, still writes both +4. Deploy code that only reads and writes new format + +This is preferred because you can safely roll back at any point, the old format is always up to date. + +```typescript +// Bad: only writing to new structure before migration is done +export const createTeam = mutation({ + args: { name: v.string(), isPro: v.boolean() }, + handler: async (ctx, args) => { + await ctx.db.insert("teams", { + name: args.name, + plan: args.isPro ? "pro" : "basic", + }); + }, +}); + +// Good: writing to both structures during migration +export const createTeam = mutation({ + args: { name: v.string(), isPro: v.boolean() }, + handler: async (ctx, args) => { + const plan = args.isPro ? "pro" : "basic"; + await ctx.db.insert("teams", { + name: args.name, + isPro: args.isPro, + plan, + }); + }, +}); +``` + +### Dual Read + +Read both formats. Write only the new format. + +1. Deploy code that reads both formats (preferring new), writes only new format +2. Run migration on existing data +3. Deploy code that reads and writes only new format + +This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format. + +```typescript +// Good: reading both formats, preferring new +function getTeamPlan(team: Doc<"teams">): "basic" | "pro" { + if (team.plan !== undefined) return team.plan; + return team.isPro ? "pro" : "basic"; +} +``` + +## Small Table Shortcut + +For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component: + +```typescript +import { internalMutation } from "./_generated/server"; + +export const backfillSmallTable = internalMutation({ + handler: async (ctx) => { + const docs = await ctx.db.query("smallConfig").collect(); + for (const doc of docs) { + if (doc.newField === undefined) { + await ctx.db.patch(doc._id, { newField: "default" }); + } + } + }, +}); +``` + +```bash +npx convex run migrations:backfillSmallTable +``` + +Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component. + +## Verifying a Migration + +Query to check remaining unmigrated documents: + +```typescript +import { query } from "./_generated/server"; + +export const verifyMigration = query({ + handler: async (ctx) => { + const remaining = await ctx.db + .query("users") + .filter((q) => q.eq(q.field("role"), undefined)) + .take(10); + + return { + complete: remaining.length === 0, + sampleRemaining: remaining.map((u) => u._id), + }; + }, +}); +``` + +Or use the component's built-in status monitoring: + +```bash +npx convex run --component migrations lib:getStatus --watch +``` diff --git a/.agents/skills/convex-migration-helper/references/migrations-component.md b/.agents/skills/convex-migration-helper/references/migrations-component.md new file mode 100644 index 0000000..95ec292 --- /dev/null +++ b/.agents/skills/convex-migration-helper/references/migrations-component.md @@ -0,0 +1,169 @@ +# Migrations Component Reference + +Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations. + +## Installation + +```bash +npm install @convex-dev/migrations +``` + +## Setup + +```typescript +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import migrations from "@convex-dev/migrations/convex.config.js"; + +const app = defineApp(); +app.use(migrations); +export default app; +``` + +```typescript +// convex/migrations.ts +import { Migrations } from "@convex-dev/migrations"; +import { components } from "./_generated/api.js"; +import { DataModel } from "./_generated/dataModel.js"; + +export const migrations = new Migrations(components.migrations); +export const run = migrations.runner(); +``` + +The `DataModel` type parameter is optional but provides type safety for migration definitions. + +## Define a Migration + +The `migrateOne` function processes a single document. The component handles batching and pagination automatically. + +```typescript +// convex/migrations.ts +export const addDefaultRole = migrations.define({ + table: "users", + migrateOne: async (ctx, user) => { + if (user.role === undefined) { + await ctx.db.patch(user._id, { role: "user" }); + } + }, +}); +``` + +Shorthand: if you return an object, it is applied as a patch automatically. + +```typescript +export const clearDeprecatedField = migrations.define({ + table: "users", + migrateOne: () => ({ legacyField: undefined }), +}); +``` + +## Run a Migration + +From the CLI: + +```bash +# Define a one-off runner in convex/migrations.ts: +# export const runIt = migrations.runner(internal.migrations.addDefaultRole); +npx convex run migrations:runIt + +# Or use the general-purpose runner +npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}' +``` + +Programmatically from another Convex function: + +```typescript +await migrations.runOne(ctx, internal.migrations.addDefaultRole); +``` + +## Run Multiple Migrations in Order + +```typescript +export const runAll = migrations.runner([ + internal.migrations.addDefaultRole, + internal.migrations.clearDeprecatedField, + internal.migrations.normalizeEmails, +]); +``` + +```bash +npx convex run migrations:runAll +``` + +If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically. + +## Dry Run + +Test a migration before committing changes: + +```bash +npx convex run migrations:runIt '{"dryRun": true}' +``` + +This runs one batch and then rolls back, so you can see what it would do without changing any data. + +## Check Migration Status + +```bash +npx convex run --component migrations lib:getStatus --watch +``` + +## Cancel a Running Migration + +```bash +npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}' +``` + +Or programmatically: + +```typescript +await migrations.cancel(ctx, internal.migrations.addDefaultRole); +``` + +## Run Migrations on Deploy + +Chain migration execution after deploying: + +```bash +npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod +``` + +## Configuration Options + +### Custom Batch Size + +If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts: + +```typescript +export const migrateHeavyTable = migrations.define({ + table: "largeDocuments", + batchSize: 10, + migrateOne: async (ctx, doc) => { + // migration logic + }, +}); +``` + +### Migrate a Subset Using an Index + +Process only matching documents instead of the full table: + +```typescript +export const fixEmptyNames = migrations.define({ + table: "users", + customRange: (query) => query.withIndex("by_name", (q) => q.eq("name", "")), + migrateOne: () => ({ name: "" }), +}); +``` + +### Parallelize Within a Batch + +By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering: + +```typescript +export const clearField = migrations.define({ + table: "myTable", + parallelize: true, + migrateOne: () => ({ optionalField: undefined }), +}); +``` diff --git a/.agents/skills/convex-performance-audit/SKILL.md b/.agents/skills/convex-performance-audit/SKILL.md new file mode 100644 index 0000000..382951c --- /dev/null +++ b/.agents/skills/convex-performance-audit/SKILL.md @@ -0,0 +1,143 @@ +--- +name: convex-performance-audit +description: Audits and optimizes Convex application performance across hot-path reads, write contention, subscription cost, and function limits. Use this skill when a Convex feature is slow or expensive, npx convex insights shows high bytes or documents read, OCC conflict errors or mutation retries appear, subscriptions or UI updates are costly, functions hit execution or transaction limits, or the user mentions performance, latency, read amplification, or invalidation problems in a Convex app. +--- + +# Convex Performance Audit + +Diagnose and fix performance problems in Convex applications, one problem class at a time. + +## When to Use + +- A Convex page or feature feels slow or expensive +- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts +- Low-freshness read paths are using reactivity where point-in-time reads would do +- OCC conflict errors or excessive mutation retries +- High subscription count or slow UI updates +- Functions approaching execution or transaction limits +- The same performance pattern needs fixing across sibling functions + +## When Not to Use + +- Initial Convex setup, auth setup, or component extraction +- Pure schema migrations with no performance goal +- One-off micro-optimizations without a user-visible or deployment-visible problem + +## Guardrails + +- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak +- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path +- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale + +## First Step: Gather Signals + +Start with the strongest signal available: + +1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals. +2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed. + - If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up. +3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth. +4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted. + +## Signal Routing + +After gathering signals, identify the problem class and read the matching reference file. + +| Signal | Reference | +| -------------------------------------------------------------- | ----------------------------------------- | +| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` | +| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` | +| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` | +| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` | +| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` | + +Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain. + +## Escalate Larger Fixes + +If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing. + +Examples: + +- introducing digest or summary tables across multiple flows +- splitting documents to isolate frequently-updated fields +- reworking pagination or fetch strategy across several screens +- switching to a new index or denormalized field that needs migration-safe rollout + +When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow. + +## Workflow + +### 1. Scope the problem + +Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom. + +Write down: + +- entrypoint functions +- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation` +- tables read +- tables written +- whether the path is high-read, high-write, or both + +### 2. Trace the full read and write set + +For each function in the path: + +1. Trace every `ctx.db.get()` and `ctx.db.query()` +2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()` +3. Note foreign-key lookups, JS-side filtering, and full-document reads +4. Identify all sibling functions touching the same tables +5. Identify reactive stats, aggregates, or widgets rendered on the same page + +In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems. + +### 3. Apply fixes from the relevant reference + +Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order. + +Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables. + +### 4. Fix sibling functions together + +When one function touching a table has a performance bug, audit sibling functions for the same pattern. + +After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables. + +Examples: + +- If one list query switches from full docs to a digest table, inspect the other list queries for that table +- If one mutation isolates a frequently-updated field or splits a hot document, inspect the other writers to the same table +- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk + +Do not leave one path fixed and another path on the old pattern unless there is a clear product reason. + +### 5. Verify before finishing + +Confirm all of these: + +1. Results are the same as before, no dropped records +2. Eliminated reads or writes are no longer in the path where expected +3. Fallback behavior works when denormalized or indexed fields are missing +4. Frequently-updated fields are isolated from widely-read documents where needed +5. Every relevant sibling reader and writer was inspected, not just the original function + +## Reference Files + +- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables +- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting +- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads +- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size + +Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit. + +## Checklist + +- [ ] Gathered signals from insights, dashboard, or code audit +- [ ] Identified the problem class and read the matching reference +- [ ] Scoped one concrete user flow or function path +- [ ] Traced every read and write in that path +- [ ] Identified sibling functions touching the same tables +- [ ] Applied fixes from the reference, following the recommended fix order +- [ ] Fixed sibling functions consistently +- [ ] Verified behavior and confirmed no regressions diff --git a/.agents/skills/convex-performance-audit/agents/openai.yaml b/.agents/skills/convex-performance-audit/agents/openai.yaml new file mode 100644 index 0000000..9a21f38 --- /dev/null +++ b/.agents/skills/convex-performance-audit/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Performance Audit" + short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#EF4444" + default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/convex-performance-audit/assets/icon.svg b/.agents/skills/convex-performance-audit/assets/icon.svg new file mode 100644 index 0000000..7ab9e09 --- /dev/null +++ b/.agents/skills/convex-performance-audit/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agents/skills/convex-performance-audit/references/function-budget.md b/.agents/skills/convex-performance-audit/references/function-budget.md new file mode 100644 index 0000000..d4d4aa5 --- /dev/null +++ b/.agents/skills/convex-performance-audit/references/function-budget.md @@ -0,0 +1,232 @@ +# Function Budget + +Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client. + +## Core Principle + +Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention. + +## Limits to Know + +These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers. + +| Resource | Limit | +| --------------------------------- | ----------------------------------------------------- | +| Query/mutation execution time | 1 second (user code only, excludes DB operations) | +| Action execution time | 10 minutes | +| Data read per transaction | 16 MiB | +| Data written per transaction | 16 MiB | +| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) | +| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) | +| Documents written per transaction | 16,000 | +| Individual document size | 1 MiB | +| Function return value size | 16 MiB | + +## Symptoms + +- "Function execution took too long" errors +- "Transaction too large" or read/write set size errors +- Slow queries that read many documents +- Client receiving large payloads that slow down page load +- `npx convex insights --details` showing high bytes read + +## Common Causes + +### Unbounded collection + +A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents. + +### Large document reads on hot paths + +Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view. + +### Mutation doing too much work + +A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction. + +### Returning too much data to the client + +A query returning full documents when the client only needs a few fields. + +## Fix Order + +### 1. Bound your reads + +Never `.collect()` without a limit on a table that can grow unbounded. + +```ts +// Bad: unbounded read, breaks as the table grows +const messages = await ctx.db.query("messages").collect(); +``` + +```ts +// Good: paginate or limit +const messages = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => q.eq("channelId", channelId)) + .order("desc") + .take(50); +``` + +### 2. Read smaller shapes + +If the list page only needs title, author, and date, do not read full documents with rich content fields. + +Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern. + +### 3. Break large mutations into batches + +If a mutation needs to update hundreds of documents, split it into a self-scheduling chain. + +```ts +// Bad: one mutation updating every row +export const backfillAll = internalMutation({ + handler: async (ctx) => { + const docs = await ctx.db.query("items").collect(); + for (const doc of docs) { + await ctx.db.patch(doc._id, { newField: computeValue(doc) }); + } + }, +}); +``` + +```ts +// Good: cursor-based batch processing +export const backfillBatch = internalMutation({ + args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) }, + handler: async (ctx, args) => { + const batchSize = args.batchSize ?? 100; + const result = await ctx.db + .query("items") + .paginate({ cursor: args.cursor ?? null, numItems: batchSize }); + + for (const doc of result.page) { + if (doc.newField === undefined) { + await ctx.db.patch(doc._id, { newField: computeValue(doc) }); + } + } + + if (!result.isDone) { + await ctx.scheduler.runAfter(0, internal.items.backfillBatch, { + cursor: result.continueCursor, + batchSize, + }); + } + }, +}); +``` + +### 4. Move heavy work to actions + +Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead. + +Actions run outside the transaction and can call mutations to write results back. + +```ts +// Bad: heavy computation inside a mutation +export const processUpload = mutation({ + handler: async (ctx, args) => { + const result = expensiveComputation(args.data); + await ctx.db.insert("results", result); + }, +}); +``` + +```ts +// Good: action for heavy work, mutation for the write +export const processUpload = action({ + handler: async (ctx, args) => { + const result = expensiveComputation(args.data); + await ctx.runMutation(internal.results.store, { result }); + }, +}); +``` + +### 5. Trim return values + +Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning. + +```ts +// Bad: returns full documents including large content fields +export const list = query({ + handler: async (ctx) => { + return await ctx.db.query("articles").take(20); + }, +}); +``` + +```ts +// Good: project to only the fields the client needs +export const list = query({ + handler: async (ctx) => { + const articles = await ctx.db.query("articles").take(20); + return articles.map((a) => ({ + _id: a._id, + title: a.title, + author: a.author, + createdAt: a._creationTime, + })); + }, +}); +``` + +### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions + +Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost. + +```ts +// Bad: unnecessary overhead from ctx.runQuery inside a mutation +export const createProject = mutation({ + handler: async (ctx, args) => { + const user = await ctx.runQuery(api.users.getCurrentUser); + await ctx.db.insert("projects", { ...args, ownerId: user._id }); + }, +}); +``` + +```ts +// Good: plain helper function, no extra overhead +export const createProject = mutation({ + handler: async (ctx, args) => { + const user = await getCurrentUser(ctx); + await ctx.db.insert("projects", { ...args, ownerId: user._id }); + }, +}); +``` + +Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else. + +### 7. Avoid unnecessary `runAction` calls + +`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime). + +```ts +// Bad: runAction overhead for no reason +export const processItems = action({ + handler: async (ctx, args) => { + for (const item of args.items) { + await ctx.runAction(internal.items.processOne, { item }); + } + }, +}); +``` + +```ts +// Good: plain function call +export const processItems = action({ + handler: async (ctx, args) => { + for (const item of args.items) { + await processOneItem(ctx, { item }); + } + }, +}); +``` + +## Verification + +1. No function execution or transaction size errors +2. `npx convex insights --details` shows reduced bytes read +3. Large mutations are batched and self-scheduling +4. Client payloads are reasonably sized for the UI they serve +5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible +6. Sibling functions with similar patterns were checked diff --git a/.agents/skills/convex-performance-audit/references/hot-path-rules.md b/.agents/skills/convex-performance-audit/references/hot-path-rules.md new file mode 100644 index 0000000..e003e05 --- /dev/null +++ b/.agents/skills/convex-performance-audit/references/hot-path-rules.md @@ -0,0 +1,369 @@ +# Hot Path Rules + +Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes. + +## Contents + +- Core Principle +- Consistency Rule +- 1. Push Filters To Storage (indexes, migration rule, redundant indexes) +- 2. Minimize Data Sources (denormalization, fallback rule) +- 3. Minimize Row Size (digest tables) +- 4. Skip No-Op Writes +- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write) +- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills) +- Verification + +## Core Principle + +Every byte read or written multiplies with concurrency. + +Think: + +`cost x calls_per_second x 86400` + +In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync. + +## Consistency Rule + +If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern. + +Do this especially for: + +- multiple list queries over the same table +- multiple writers to the same table +- public browse and search queries over the same records +- helper functions reused by more than one endpoint + +## 1. Push Filters To Storage + +Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned. + +Prefer: + +- `withIndex(...)` +- `.withSearchIndex(...)` for text search +- narrower tables +- summary tables + +before accepting a scan-plus-filter pattern. + +```ts +// Bad: scans then filters in JavaScript +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + const tasks = await ctx.db.query("tasks").collect(); + return tasks.filter((task) => task.status === "open"); + }, +}); +``` + +```ts +// Also bad: Convex .filter() does not push to storage either +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("tasks") + .filter((q) => q.eq(q.field("status"), "open")) + .collect(); + }, +}); +``` + +```ts +// Good: use an index so storage does the filtering +export const listOpen = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query("tasks") + .withIndex("by_status", (q) => q.eq("status", "open")) + .collect(); + }, +}); +``` + +### Migration rule for indexes + +New indexes on partially backfilled fields can create correctness bugs during rollout. + +Important Convex detail: + +`undefined !== false` + +If an older document is missing a field entirely, it will not match a compound index entry that expects `false`. + +Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify. + +If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`. + +```ts +// Bad: optional booleans can miss older rows where the field is undefined +const projects = await ctx.db + .query("projects") + .withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false)) + .order("desc") + .take(20); +``` + +```ts +// Good: switch hot-path reads only after the rollout is migration-safe +// See the migration helper skill for dual-read / backfill / cutover patterns. +``` + +### Check for redundant indexes + +Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete. + +```ts +// Bad: two indexes where one would do +defineTable({ team: v.id("teams"), user: v.id("users") }) + .index("by_team", ["team"]) + .index("by_team_and_user", ["team", "user"]); +``` + +```ts +// Good: single compound index serves both query patterns +defineTable({ team: v.id("teams"), user: v.id("users") }).index( + "by_team_and_user", + ["team", "user"], +); +``` + +Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first. + +## 2. Minimize Data Sources + +Trace every read. + +If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path. + +### When to denormalize + +Denormalize when all of these are true: + +- the path is hot +- the joined document is much larger than the field you need +- many readers are paying that join cost repeatedly + +Useful mental model: + +`join_cost = rows_per_page x foreign_doc_size x pages_per_second` + +Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not. + +### Fallback rule + +Denormalized data is an optimization. Live data is the correctness path. + +Rules: + +- If the denormalized field is missing or null, fall back to the live read +- Do not show placeholders instead of falling back +- In lookup maps, only include fully populated entries + +```ts +// Bad: missing denormalized data becomes a placeholder and blocks correctness +const ownerName = project.ownerName ?? "Unknown owner"; +``` + +```ts +// Good: denormalized data is an optimization, not the only source of truth +const ownerName = + project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null; +``` + +Bad lookup map pattern: + +```ts +const ownersById = { + [project.ownerId]: { ownerName: null }, +}; +``` + +That blocks fallback because the map says "I have data" when it does not. + +Good lookup map pattern: + +```ts +const ownersById = + project.ownerName !== undefined && project.ownerName !== null + ? { [project.ownerId]: { ownerName: project.ownerName } } + : {}; +``` + +### No denormalized copy yet + +Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table. + +If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan. + +Rollout order: + +1. Update schema +2. Update write path +3. Backfill +4. Switch read path + +## 3. Minimize Row Size + +Hot list pages should read the smallest document shape that still answers the UI. + +Prefer summary or digest tables over full source tables when: + +- the list page only needs a subset of fields +- source documents are large +- the query is high volume + +An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page. + +Digest tables are a tradeoff, not a default: + +- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost +- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit + +```ts +// Bad: list page reads source docs, then joins owner data per row +const projects = await ctx.db + .query("projects") + .withIndex("by_public", (q) => q.eq("isPublic", true)) + .collect(); +``` + +```ts +// Good: list page reads the smaller digest shape first +const projects = await ctx.db + .query("projectDigests") + .withIndex("by_public_and_updated", (q) => q.eq("isPublic", true)) + .order("desc") + .take(20); +``` + +## 4. Isolate Frequently-Updated Fields + +Convex already no-ops unchanged writes. The invalidation problem here is real writes hitting documents that many queries subscribe to. + +Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status off widely-read documents when most readers do not need them. + +Apply this across sibling writers too. Splitting one write path does not help much if three other mutations still update the same widely-read document. + +```ts +// Bad: every presence heartbeat invalidates subscribers to the whole profile +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, + lastSeen: Date.now(), +}); +``` + +```ts +// Good: keep profile reads stable, move heartbeat updates to a separate document +await ctx.db.patch(user._id, { + name: args.name, + avatarUrl: args.avatarUrl, +}); + +await ctx.db.patch(presence._id, { + lastSeen: Date.now(), +}); +``` + +## 5. Match Consistency To Read Patterns + +Choose read strategy based on traffic shape. + +### High-read, low-write + +Examples: + +- public browse pages +- search results +- landing pages +- directory listings + +Prefer: + +- point-in-time reads where appropriate +- explicit refresh +- local state for pagination +- caching where appropriate + +Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns. + +### High-read, high-write + +Examples: + +- collaborative editors +- live dashboards +- presence-heavy views + +Reactive queries may be worth the ongoing cost. + +## Convex-Specific Notes + +### Reactive queries + +Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query. + +On the client: + +- `useQuery` creates a live subscription +- `usePaginatedQuery` creates a live subscription per page + +For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically. + +### Point-in-time reads + +Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful. + +Use them for: + +- aggregate snapshots +- reports +- low-churn listings +- pages where explicit refresh is fine + +### Triggers and fan-out + +Triggers fire on every write, including writes that did not materially change the document. + +When a write exists only to keep derived state in sync: + +- diff before patching +- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate + +### Aggregates + +Reactive global counts invalidate frequently on busy tables. + +Prefer: + +- one-shot aggregate fetches +- periodic recomputation +- precomputed summary rows + +for global stats that do not need live updates every second. + +### Backfills + +For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component. + +Deploy code that can handle both states before running the backfill. + +During the gap: + +- writes should populate the new shape +- reads should fall back safely + +## Verification + +Before closing the audit, confirm: + +1. Same results as before, no dropped records +2. The removed table or lookup is no longer in the hot-path read set +3. Tests or validation cover fallback behavior +4. Migration safety is preserved while fields or indexes are unbackfilled +5. Sibling functions were fixed consistently diff --git a/.agents/skills/convex-performance-audit/references/occ-conflicts.md b/.agents/skills/convex-performance-audit/references/occ-conflicts.md new file mode 100644 index 0000000..1da4380 --- /dev/null +++ b/.agents/skills/convex-performance-audit/references/occ-conflicts.md @@ -0,0 +1,114 @@ +# OCC Conflict Resolution + +Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables. + +## Core Principle + +Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency. + +## Symptoms + +- OCC conflict errors in deployment logs or health page +- Mutations retrying multiple times before succeeding +- User-visible latency spikes on write-heavy pages +- `npx convex insights --details` showing high conflict rates + +## Common Causes + +### Hot documents + +Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record. + +### Broad read sets causing false conflicts + +A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified. + +### Fan-out from triggers or cascading writes + +A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others. + +Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function. + +### Write-then-read chains + +A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up. + +## Fix Order + +### 1. Reduce read set size + +Narrower reads mean fewer false conflicts. + +```ts +// Bad: broad scan creates a wide conflict surface +const allTasks = await ctx.db.query("tasks").collect(); +const mine = allTasks.filter((t) => t.ownerId === userId); +``` + +```ts +// Good: indexed query touches only relevant documents +const mine = await ctx.db + .query("tasks") + .withIndex("by_owner", (q) => q.eq("ownerId", userId)) + .collect(); +``` + +### 2. Split hot documents + +When many writers target the same document, split the contention point. + +```ts +// Bad: every vote increments the same counter document +const counter = await ctx.db.get(pollCounterId); +await ctx.db.patch(pollCounterId, { count: counter!.count + 1 }); +``` + +```ts +// Good: shard the counter across multiple documents, aggregate on read +const shardIndex = Math.floor(Math.random() * SHARD_COUNT); +const shardId = shardIds[shardIndex]; +const shard = await ctx.db.get(shardId); +await ctx.db.patch(shardId, { count: shard!.count + 1 }); +``` + +Aggregate the shards in a query or scheduled job when you need the total. + +### 3. Move non-critical work to scheduled functions + +If a mutation does primary work plus secondary bookkeeping (analytics, non-critical notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set. + +```ts +// Bad: canonical write and derived work happen in the same transaction +await ctx.db.patch(userId, { name: args.name }); +await ctx.db.insert("userUpdateAnalytics", { + userId, + kind: "name_changed", + name: args.name, +}); +``` + +```ts +// Good: keep the primary write small, defer the analytics work +await ctx.db.patch(userId, { name: args.name }); +await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, { + userId, + name: args.name, +}); +``` + +### 4. Combine competing writes + +If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows. + +Do not introduce artificial locks or queues unless the above steps have been tried first. + +## Related: Invalidation Scope + +Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern. + +## Verification + +1. OCC conflict rate has dropped in insights or dashboard +2. Mutation latency is lower and more consistent +3. No data correctness regressions from splitting or scheduling changes +4. Sibling writers to the same hot documents were fixed consistently diff --git a/.agents/skills/convex-performance-audit/references/subscription-cost.md b/.agents/skills/convex-performance-audit/references/subscription-cost.md new file mode 100644 index 0000000..ae7d1ad --- /dev/null +++ b/.agents/skills/convex-performance-audit/references/subscription-cost.md @@ -0,0 +1,252 @@ +# Subscription Cost + +Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes. + +## Core Principle + +Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with: + +`subscriptions x invalidation_frequency x query_cost` + +Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle. + +## Symptoms + +- Dashboard shows high active subscription count +- UI feels sluggish or laggy despite fast individual queries +- React profiling shows frequent re-renders from Convex state +- Pages with many components each running their own `useQuery` +- Paginated lists where every loaded page stays subscribed + +## Common Causes + +### Reactive queries on low-freshness flows + +Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth. + +### Overly broad queries + +A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation. + +### Too many subscriptions per page + +A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor. + +### Paginated queries keeping all pages live + +`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive. + +### Frequently-updated fields on widely-read documents + +A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason. + +## Fix Order + +### 1. Use point-in-time reads when live updates are not valuable + +Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data. + +Consider a point-in-time read instead when all of these are true: + +- the flow is high-read +- the underlying data changes less often than users need to see +- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable + +Possible implementations depend on environment: + +- a server-rendered fetch +- a framework helper like `fetchQuery` +- a point-in-time client read such as `ConvexHttpClient.query()` + +```ts +// Reactive by default when fresh live data matters +function TeamPresence() { + const presence = useQuery(api.teams.livePresence, { teamId }); + return ; +} +``` + +```ts +// Point-in-time read when explicit refresh is acceptable +import { ConvexHttpClient } from "convex/browser"; + +const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL); + +function SnapshotView() { + const [items, setItems] = useState([]); + + useEffect(() => { + client.query(api.items.snapshot).then(setItems); + }, []); + + return ; +} +``` + +Good candidates for point-in-time reads: + +- aggregate snapshots +- reports +- low-churn listings +- flows where explicit refresh is already acceptable + +Keep reactive for: + +- collaborative editing +- live dashboards +- presence-heavy views +- any surface where users expect fresh changes to appear automatically + +### 2. Batch related data into fewer queries + +Instead of N components each fetching their own related data, fetch it in a single query. + +```ts +// Bad: each card fetches its own author +function ProjectCard({ project }: { project: Project }) { + const author = useQuery(api.users.get, { id: project.authorId }); + return ; +} +``` + +```ts +// Good: parent query returns projects with author names included +function ProjectList() { + const projects = useQuery(api.projects.listWithAuthors); + return projects?.map((p) => ( + + )); +} +``` + +This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N. + +This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count. + +### 3. Use skip to avoid unnecessary subscriptions + +The `"skip"` value prevents a subscription from being created when the arguments are not ready. + +```ts +// Bad: subscribes with undefined args, wastes a subscription slot +const profile = useQuery(api.users.getProfile, { userId: selectedId! }); +``` + +```ts +// Good: skip when there is nothing to fetch +const profile = useQuery( + api.users.getProfile, + selectedId ? { userId: selectedId } : "skip", +); +``` + +### 4. Isolate frequently-updated fields into separate documents + +If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes. + +```ts +// Bad: lastSeen lives on the user doc, every heartbeat invalidates +// every query that reads this user +const users = defineTable({ + name: v.string(), + email: v.string(), + lastSeen: v.number(), +}); +``` + +```ts +// Good: lastSeen lives in a separate heartbeat doc +const users = defineTable({ + name: v.string(), + email: v.string(), + heartbeatId: v.id("heartbeats"), +}); + +const heartbeats = defineTable({ + lastSeen: v.number(), +}); +``` + +Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly. + +For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat. + +### 5. Use the aggregate component for counts and sums + +Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table. + +Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively. + +If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables. + +### 6. Narrow query read sets + +Queries that return less data and touch fewer documents invalidate less often. + +```ts +// Bad: returns all fields, invalidates on any field change +export const list = query({ + handler: async (ctx) => { + return await ctx.db.query("projects").collect(); + }, +}); +``` + +```ts +// Good: use a digest table with only the fields the list needs +export const listDigests = query({ + handler: async (ctx) => { + return await ctx.db.query("projectDigests").collect(); + }, +}); +``` + +Writes to fields not in the digest table do not invalidate the digest query. + +### 7. Remove `Date.now()` from queries + +Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed. + +```ts +// Bad: Date.now() defeats query caching and causes frequent re-evaluation +const releasedPosts = await ctx.db + .query("posts") + .withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now())) + .take(100); +``` + +```ts +// Good: use a boolean field updated by a scheduled function +const releasedPosts = await ctx.db + .query("posts") + .withIndex("by_is_released", (q) => q.eq("isReleased", true)) + .take(100); +``` + +If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry. + +### 8. Consider pagination strategy + +For long lists where users scroll through many pages: + +- If the data does not need live updates, use point-in-time fetching with manual "load more" +- If it does need live updates, accept the subscription cost but limit the number of loaded pages +- Consider whether older pages can be unloaded as the user scrolls forward + +### 9. Separate backend cost from UI churn + +If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether. + +Treat this as a UX problem first when: + +- the underlying query is already reasonably cheap +- the complaint is flicker, loading flashes, or re-render churn +- live updates are still desirable once fresh data arrives + +## Verification + +1. Subscription count in dashboard is lower for the affected pages +2. UI responsiveness has improved +3. React profiling shows fewer unnecessary re-renders +4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily +5. Sibling pages with similar patterns were updated consistently diff --git a/.agents/skills/convex-quickstart/SKILL.md b/.agents/skills/convex-quickstart/SKILL.md new file mode 100644 index 0000000..5bff17b --- /dev/null +++ b/.agents/skills/convex-quickstart/SKILL.md @@ -0,0 +1,347 @@ +--- +name: convex-quickstart +description: Initializes a new Convex project from scratch or adds Convex to an existing app. Use this skill when starting a new project with Convex, scaffolding with npm create convex@latest, adding Convex to an existing React, Next.js, Vue, Svelte, or other frontend, wiring up ConvexProvider, configuring environment variables for the deployment URL, or running npx convex dev for the first time, even if the user just says "set up Convex" or "add a backend." +--- + +# Convex Quickstart + +Set up a working Convex project as fast as possible. + +## When to Use + +- Starting a brand new project with Convex +- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app +- Scaffolding a Convex app for prototyping + +## When Not to Use + +- The project already has Convex installed and `convex/` exists - just start building +- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill + +## Workflow + +1. Determine the starting point: new project or existing app +2. If new project, pick a template and scaffold with `npm create convex@latest` +3. If existing app, install `convex` and wire up the provider +4. Run `npx convex dev` to connect a deployment and start the dev loop +5. Verify the setup works + +## Path 1: New Project (Recommended) + +Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together. + +### Pick a template + +| Template | Stack | +| -------------------------- | ----------------------------------------- | +| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui | +| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui | +| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui | +| `nextjs-clerk` | Next.js + Clerk auth | +| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui | +| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui | +| `bare` | Convex backend only, no frontend | + +If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes. + +You can also use any GitHub repo as a template: + +```bash +npm create convex@latest my-app -- -t owner/repo +npm create convex@latest my-app -- -t owner/repo#branch +``` + +### Scaffold the project + +Always pass the project name and template flag to avoid interactive prompts: + +```bash +npm create convex@latest my-app -- -t react-vite-shadcn +cd my-app +npm install +``` + +The scaffolding tool creates files but does not run `npm install`, so you must run it yourself. + +To scaffold in the current directory (if it is empty): + +```bash +npm create convex@latest . -- -t react-vite-shadcn +npm install +``` + +### Start the dev loop + +`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly. + +**Ask the user to run this themselves:** + +Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will: + +- Create a Convex project and dev deployment +- Write the deployment URL to `.env.local` +- Create the `convex/` directory with generated types +- Watch for changes and sync continuously + +The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`. + +**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction. + +### Start the frontend + +The user should also run the frontend dev server in a separate terminal: + +```bash +npm run dev +``` + +Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`. + +### What you get + +After scaffolding, the project structure looks like: + +``` +my-app/ + convex/ # Backend functions and schema + _generated/ # Auto-generated types (check this into git) + schema.ts # Database schema (if template includes one) + src/ # Frontend code (or app/ for Next.js) + package.json + .env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL +``` + +The template already has: + +- `ConvexProvider` wired into the app root +- Correct env var names for the framework +- Tailwind and shadcn/ui ready (for shadcn templates) +- Auth provider configured (for auth templates) + +Proceed to adding schema, functions, and UI. + +## Path 2: Add Convex to an Existing App + +Use this when the user already has a frontend project and wants to add Convex as the backend. + +### Install + +```bash +npm install convex +``` + +### Initialize and start dev loop + +Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly. + +### Wire up the provider + +The Convex client must wrap the app at the root. The setup varies by framework. + +Create the `ConvexReactClient` at module scope, not inside a component: + +```tsx +// Bad: re-creates the client on every render +function App() { + const convex = new ConvexReactClient( + import.meta.env.VITE_CONVEX_URL as string, + ); + return ...; +} + +// Good: created once at module scope +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); +function App() { + return ...; +} +``` + +#### React (Vite) + +```tsx +// src/main.tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import App from "./App"; + +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); + +createRoot(document.getElementById("root")!).render( + + + + + , +); +``` + +#### Next.js (App Router) + +```tsx +// app/ConvexClientProvider.tsx +"use client"; + +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ReactNode } from "react"; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + return {children}; +} +``` + +```tsx +// app/layout.tsx +import { ConvexClientProvider } from "./ConvexClientProvider"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} +``` + +#### Other frameworks + +For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide: + +- [Vue](https://docs.convex.dev/quickstart/vue) +- [Svelte](https://docs.convex.dev/quickstart/svelte) +- [React Native](https://docs.convex.dev/quickstart/react-native) +- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start) +- [Remix](https://docs.convex.dev/quickstart/remix) +- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs) + +### Environment variables + +The env var name depends on the framework: + +| Framework | Variable | +| ------------ | ------------------------ | +| Vite | `VITE_CONVEX_URL` | +| Next.js | `NEXT_PUBLIC_CONVEX_URL` | +| Remix | `CONVEX_URL` | +| React Native | `EXPO_PUBLIC_CONVEX_URL` | + +`npx convex dev` writes the correct variable to `.env.local` automatically. + +## Agent Mode (Cloud and Headless Agents) + +When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment. + +Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline: + +```bash +CONVEX_AGENT_MODE=anonymous npx convex dev +``` + +This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment. + +## Verify the Setup + +After setup, confirm everything is working: + +1. The user confirms `npx convex dev` is running without errors +2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts` +3. `.env.local` contains the deployment URL + +## Writing Your First Function + +Once the project is set up, create a schema and a query to verify the full loop works. + +`convex/schema.ts`: + +```ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + tasks: defineTable({ + text: v.string(), + completed: v.boolean(), + }), +}); +``` + +`convex/tasks.ts`: + +```ts +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("tasks").collect(); + }, +}); + +export const create = mutation({ + args: { text: v.string() }, + handler: async (ctx, args) => { + await ctx.db.insert("tasks", { text: args.text, completed: false }); + }, +}); +``` + +Use in a React component (adjust the import path based on your file location relative to `convex/`): + +```tsx +import { useQuery, useMutation } from "convex/react"; +import { api } from "../convex/_generated/api"; + +function Tasks() { + const tasks = useQuery(api.tasks.list); + const create = useMutation(api.tasks.create); + + return ( +
+ + {tasks?.map((t) => ( +
{t.text}
+ ))} +
+ ); +} +``` + +## Development vs Production + +Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save. + +When ready to ship, deploy to production: + +```bash +npx convex deploy +``` + +This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development. + +## Next Steps + +- Add authentication: use the `convex-setup-auth` skill +- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas) +- Build components: use the `convex-create-component` skill +- Plan a migration: use the `convex-migration-helper` skill +- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage) +- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling) + +## Checklist + +- [ ] Determined starting point: new project or existing app +- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template +- [ ] If existing app: installed `convex` and wired up the provider +- [ ] User has `npx convex dev` running and connected to a deployment +- [ ] `convex/_generated/` directory exists with types +- [ ] `.env.local` has the deployment URL +- [ ] Verified a basic query/mutation round-trip works diff --git a/.agents/skills/convex-quickstart/agents/openai.yaml b/.agents/skills/convex-quickstart/agents/openai.yaml new file mode 100644 index 0000000..a51a6d0 --- /dev/null +++ b/.agents/skills/convex-quickstart/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Quickstart" + short_description: "Start a new Convex app or add Convex to an existing frontend." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#F97316" + default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/convex-quickstart/assets/icon.svg b/.agents/skills/convex-quickstart/assets/icon.svg new file mode 100644 index 0000000..d83a73f --- /dev/null +++ b/.agents/skills/convex-quickstart/assets/icon.svg @@ -0,0 +1,4 @@ + diff --git a/.agents/skills/convex-setup-auth/SKILL.md b/.agents/skills/convex-setup-auth/SKILL.md new file mode 100644 index 0000000..0d1d9dd --- /dev/null +++ b/.agents/skills/convex-setup-auth/SKILL.md @@ -0,0 +1,150 @@ +--- +name: convex-setup-auth +description: Sets up Convex authentication with user management, identity mapping, and access control. Use this skill when adding login or signup to a Convex app, configuring Convex Auth, Clerk, WorkOS AuthKit, Auth0, or custom JWT providers, wiring auth.config.ts, protecting queries and mutations with ctx.auth.getUserIdentity(), creating a users table with identity mapping, or setting up role-based access control, even if the user just says "add auth" or "make it require login." +--- + +# Convex Authentication Setup + +Implement secure authentication in Convex with user management and access control. + +## When to Use + +- Setting up authentication for the first time +- Implementing user management (users table, identity mapping) +- Creating authentication helper functions +- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT) + +## When Not to Use + +- Auth for a non-Convex backend +- Pure OAuth/OIDC documentation without a Convex implementation +- Debugging unrelated bugs that happen to surface near auth code +- The auth provider is already fully configured and the user only needs a one-line fix + +## First Step: Choose the Auth Provider + +Convex supports multiple authentication approaches. Do not assume a provider. + +Before writing setup code: + +1. Ask the user which auth solution they want, unless the repository already makes it obvious +2. If the repo already uses a provider, continue with that provider unless the user wants to switch +3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding + +Common options: + +- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex +- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features +- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically +- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0 +- Custom JWT provider - use when integrating an existing auth system not covered above + +Look for signals in the repo before asking: + +- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages +- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components +- Environment variables that clearly point at a provider + +## After Choosing a Provider + +Read the provider's official guide and the matching local reference file: + +- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md` +- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md` +- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md` +- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md` + +The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks. + +Use those sources for: + +- package installation +- client provider wiring +- environment variables +- `convex/auth.config.ts` setup +- login and logout UI patterns +- framework-specific setup for React, Vite, or Next.js + +For shared auth behavior, use the official Convex docs as the source of truth: + +- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()` +- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage +- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance +- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth + +Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns. +For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table. +For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally. +After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration. + +## Core Pattern: Protecting Backend Functions + +The most common auth task is checking identity in Convex functions. + +```ts +// Bad: trusting a client-provided userId +export const getMyProfile = query({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.userId); + }, +}); +``` + +```ts +// Good: verifying identity server-side +export const getMyProfile = query({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Not authenticated"); + + return await ctx.db + .query("users") + .withIndex("by_tokenIdentifier", (q) => + q.eq("tokenIdentifier", identity.tokenIdentifier), + ) + .unique(); + }, +}); +``` + +## Workflow + +1. Determine the provider, either by asking the user or inferring from the repo +2. Ask whether the user wants local-only setup or production-ready setup now +3. Read the matching provider reference file +4. Follow the official provider docs for current setup details +5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns +6. Only add app-level user storage if the docs and app requirements call for it +7. Add authorization checks for ownership, roles, or team access only where the app needs them +8. Verify login state, protected queries, environment variables, and production configuration if requested + +If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it. +For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done. +If the environment has browser automation tools, you can use them. +If it does not, give the user a short manual validation checklist instead. + +## Reference Files + +### Provider References + +- `references/convex-auth.md` +- `references/clerk.md` +- `references/workos-authkit.md` +- `references/auth0.md` + +## Checklist + +- [ ] Chosen the correct auth provider before writing setup code +- [ ] Read the relevant provider reference file +- [ ] Asked whether the user wants local-only setup or production-ready setup +- [ ] Used the official provider docs for provider-specific wiring +- [ ] Used the official Convex docs for shared auth behavior and authorization patterns +- [ ] Only added app-level user storage if the app actually needs it +- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth +- [ ] Added authentication checks in protected backend functions +- [ ] Added authorization checks where the app actually needs them +- [ ] Clear error messages ("Not authenticated", "Unauthorized") +- [ ] Client auth provider configured for the chosen provider +- [ ] If requested, production auth setup is covered too diff --git a/.agents/skills/convex-setup-auth/agents/openai.yaml b/.agents/skills/convex-setup-auth/agents/openai.yaml new file mode 100644 index 0000000..d1c90a1 --- /dev/null +++ b/.agents/skills/convex-setup-auth/agents/openai.yaml @@ -0,0 +1,10 @@ +interface: + display_name: "Convex Setup Auth" + short_description: "Set up Convex auth, user identity mapping, and access control." + icon_small: "./assets/icon.svg" + icon_large: "./assets/icon.svg" + brand_color: "#2563EB" + default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/convex-setup-auth/assets/icon.svg b/.agents/skills/convex-setup-auth/assets/icon.svg new file mode 100644 index 0000000..4917dbb --- /dev/null +++ b/.agents/skills/convex-setup-auth/assets/icon.svg @@ -0,0 +1,3 @@ + diff --git a/.agents/skills/convex-setup-auth/references/auth0.md b/.agents/skills/convex-setup-auth/references/auth0.md new file mode 100644 index 0000000..9c729c5 --- /dev/null +++ b/.agents/skills/convex-setup-auth/references/auth0.md @@ -0,0 +1,116 @@ +# Auth0 + +Official docs: + +- https://docs.convex.dev/auth/auth0 +- https://auth0.github.io/auth0-cli/ +- https://auth0.github.io/auth0-cli/auth0_apps_create.html + +Use this when the app already uses Auth0 or the user wants Auth0 specifically. + +## Workflow + +1. Confirm the user wants Auth0 +2. Determine the app framework and whether Auth0 is already partly set up +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the official Convex and Auth0 guides before making changes +5. Ask whether they want the fastest setup path by installing the Auth0 CLI +6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI +7. If they do not want the CLI path, use the Auth0 dashboard path instead +8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up +9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID +10. Set environment variables for local and production environments +11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` +12. Gate Convex-backed UI with Convex auth state +13. Try to verify Convex reports the user as authenticated after Auth0 login +14. If the refresh-token path fails, stop improvising and send the user back to the official docs +15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered + +## What To Do + +- Read the official Convex and Auth0 guide before writing setup code +- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet +- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?" +- Make sure the app has already completed the relevant Auth0 quickstart for its frontend +- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0` +- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated + +## Key Setup Areas + +- install the Auth0 SDK for the app's framework +- configure `convex/auth.config.ts` with the Auth0 domain and client ID +- set environment variables for local and production environments +- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0` +- use Convex auth state when gating Convex-backed UI + +## Files and Env Vars To Expect + +- `convex/auth.config.ts` +- frontend app entry or provider wrapper +- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/` +- Auth0 environment variables commonly include: + - `AUTH0_DOMAIN` + - `AUTH0_CLIENT_ID` + - `VITE_AUTH0_DOMAIN` + - `VITE_AUTH0_CLIENT_ID` + +## Concrete Steps + +1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework +2. Ask whether the user wants the Auth0 CLI path +3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login` +4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app +5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard +6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard +7. Install the Auth0 SDK for the app's framework +8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID +9. Set frontend and backend environment variables +10. Wrap the app in `Auth0Provider` +11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0` +12. Run the normal Convex dev or deploy flow after backend config changes +13. Try the official provider config shown in the Convex docs +14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now +15. Only claim success if the user can sign in and Convex recognizes the authenticated session +16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too + +## Gotchas + +- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch +- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant +- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard +- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced +- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end +- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled +- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation +- Keep dev and prod tenants separate if the project uses different Auth0 environments +- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work. +- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it. +- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately. +- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered +- Verify production environment variables and redirect settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can complete the Auth0 login flow +- Verify Convex-authenticated UI renders only after Convex auth state is ready +- Verify protected Convex queries succeed after login +- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions +- Verify the Auth0 app settings match the real local callback and logout URLs during development +- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully +- If production-ready setup was requested, verify the production Auth0 configuration is also covered + +## Checklist + +- [ ] Confirm the user wants Auth0 +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Complete the relevant Auth0 frontend setup +- [ ] Configure `convex/auth.config.ts` +- [ ] Set environment variables +- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs +- [ ] If requested, configure the production deployment too diff --git a/.agents/skills/convex-setup-auth/references/clerk.md b/.agents/skills/convex-setup-auth/references/clerk.md new file mode 100644 index 0000000..7dbde19 --- /dev/null +++ b/.agents/skills/convex-setup-auth/references/clerk.md @@ -0,0 +1,113 @@ +# Clerk + +Official docs: + +- https://docs.convex.dev/auth/clerk +- https://clerk.com/docs/guides/development/integrations/databases/convex + +Use this when the app already uses Clerk or the user wants Clerk's hosted auth features. + +## Workflow + +1. Confirm the user wants Clerk +2. Make sure the user has a Clerk account and a Clerk application +3. Determine the app framework: + - React + - Next.js + - TanStack Start +4. Ask whether the user wants local-only setup or production-ready setup now +5. Gather the Clerk keys and the Clerk Frontend API URL +6. Follow the correct framework section in the official docs +7. Complete the backend and client wiring +8. Verify Convex reports the user as authenticated after login +9. If the user wants production-ready setup, make sure the production Clerk config is also covered + +## What To Do + +- Read the official Convex and Clerk guide before writing setup code +- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application +- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active +- Match the guide to the app's framework, usually React, Next.js, or TanStack Start +- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth` + +## Key Setup Areas + +- install the Clerk SDK for the framework in use +- configure `convex/auth.config.ts` with the Clerk issuer domain +- set the required Clerk environment variables +- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk` +- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading` + +## Files and Env Vars To Expect + +- `convex/auth.config.ts` +- React or Vite client entry such as `src/main.tsx` +- Next.js client wrapper for Convex if using App Router +- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up` +- Clerk app creation page: `https://dashboard.clerk.com/apps/new` +- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex` +- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys` +- Clerk environment variables: + - `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs + - `CLERK_FRONTEND_API_URL` in the Clerk docs + - `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps + - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps + - `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required + +`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs. + +## Concrete Steps + +1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up` +2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new` +3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed +4. Open `https://dashboard.clerk.com/apps/setup/convex` +5. Activate the Convex integration in Clerk if it is not already active +6. Copy the Clerk Frontend API URL shown there +7. Install the Clerk package for the app's framework +8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens +9. Set the publishable key in the frontend environment +10. Set the issuer domain or Frontend API URL so Convex can validate the JWT +11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk` +12. Wrap the app in `ClerkProvider` +13. Use Convex auth helpers for authenticated rendering +14. Run the normal Convex dev or deploy flow after updating backend auth config +15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too + +## Gotchas + +- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render +- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper +- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config +- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests. +- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it. +- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately. +- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key. +- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex` +- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects. + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included +- Verify production redirect URLs and any production Clerk domain values before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can sign in with Clerk +- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in +- Verify `useConvexAuth()` reaches the authenticated state after Clerk login +- Verify protected Convex queries run successfully inside authenticated UI +- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions +- If production-ready setup was requested, verify the production Clerk configuration is also covered + +## Checklist + +- [ ] Confirm the user wants Clerk +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Follow the correct framework section in the official guide +- [ ] Set Clerk environment variables +- [ ] Configure `convex/auth.config.ts` +- [ ] Verify Convex authenticated state after login +- [ ] If requested, configure the production deployment too diff --git a/.agents/skills/convex-setup-auth/references/convex-auth.md b/.agents/skills/convex-setup-auth/references/convex-auth.md new file mode 100644 index 0000000..d4824d2 --- /dev/null +++ b/.agents/skills/convex-setup-auth/references/convex-auth.md @@ -0,0 +1,143 @@ +# Convex Auth + +Official docs: https://docs.convex.dev/auth/convex-auth +Setup guide: https://labs.convex.dev/auth/setup + +Use this when the user wants auth handled directly in Convex rather than through a third-party provider. + +## Workflow + +1. Confirm the user wants Convex Auth specifically +2. Determine which sign-in methods the app needs: + - magic links or OTPs + - OAuth providers + - passwords and password reset +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the Convex Auth setup guide before writing code +5. Make sure the project has a configured Convex deployment: + - run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set + - if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing +6. Install the auth packages: + - `npm install @convex-dev/auth @auth/core@0.37.0` +7. Run the initialization command: + - `npx @convex-dev/auth` +8. Confirm the initializer created: + - `convex/auth.config.ts` + - `convex/auth.ts` + - `convex/http.ts` +9. Add the required `authTables` to `convex/schema.ts` +10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider` +11. Configure at least one auth method in `convex/auth.ts` +12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code +13. Verify the client can sign in successfully +14. Verify Convex receives authenticated identity in backend functions +15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well +16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex + +## What This Reference Is For + +- choosing Convex Auth as the default provider for a new Convex app +- understanding whether the app wants magic links, OTPs, OAuth, or passwords +- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior + +## What To Do + +- Read the Convex Auth setup guide before writing setup code +- Follow the setup flow from the docs rather than recreating it from memory +- If the app is new, consider starting from the official starter flow instead of hand-wiring everything +- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra + +## Concrete Steps + +1. Install `@convex-dev/auth` and `@auth/core@0.37.0` +2. Run `npx convex dev` if the project does not already have a configured deployment +3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment +4. Run `npx @convex-dev/auth` +5. Confirm the generated auth setup is present before continuing: + - `convex/auth.config.ts` + - `convex/auth.ts` + - `convex/http.ts` +6. Add `authTables` to `convex/schema.ts` +7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry +8. Configure the selected auth methods in `convex/auth.ts` +9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed +10. Verify login locally +11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment + +## Expected Files and Decisions + +- `convex/schema.ts` +- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file +- generated Convex Auth setup produced by `npx @convex-dev/auth` +- an existing configured Convex deployment, or the ability to create one with `npx convex dev` +- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods + +- Decide whether the user is creating a new app or adding auth to an existing app +- For a new app, prefer the official starter flow instead of rebuilding setup by hand +- Decide which auth methods the app needs: + - magic links or OTPs + - OAuth providers + - passwords +- Decide whether the user wants local-only setup or production-ready setup now +- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough + +## Gotchas + +- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior. +- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project. +- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`. +- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing. +- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method. +- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured. +- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough. +- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand. +- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too. +- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory. + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment +- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Human Handoff + +If `npx convex dev` or deployment setup requires human input: + +- stop and explain exactly what the user needs to do +- say why that step is required +- resume the auth setup immediately after the user confirms it is done + +## Validation + +- Verify the user can complete a sign-in flow +- Offer to validate sign up, sign out, and sign back in with the configured auth method +- If browser automation is available in the environment, you can do this directly +- If browser automation is not available, give the user a short manual validation checklist instead +- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions +- Verify protected UI only renders after Convex-authenticated state is ready +- Verify environment variables and redirect settings match the current app environment +- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in +- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed +- If production-ready setup was requested, verify the production deployment is also configured correctly + +## Checklist + +- [ ] Confirm the user wants Convex Auth specifically +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Ensure a Convex deployment is configured before running auth initialization +- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0` +- [ ] Run `npx convex dev` first if needed +- [ ] Run `npx @convex-dev/auth` +- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created +- [ ] Follow the setup guide for package install and wiring +- [ ] Add `authTables` to `convex/schema.ts` +- [ ] Replace `ConvexProvider` with `ConvexAuthProvider` +- [ ] Configure at least one auth method in `convex/auth.ts` +- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes +- [ ] Confirm which sign-in methods the app needs +- [ ] Verify the client can sign in and the backend receives authenticated identity +- [ ] Offer end-to-end validation of sign up, sign out, and sign back in +- [ ] If requested, configure the production deployment too +- [ ] Only add extra `users` table sync if the app needs app-level user records diff --git a/.agents/skills/convex-setup-auth/references/workos-authkit.md b/.agents/skills/convex-setup-auth/references/workos-authkit.md new file mode 100644 index 0000000..038cb9f --- /dev/null +++ b/.agents/skills/convex-setup-auth/references/workos-authkit.md @@ -0,0 +1,114 @@ +# WorkOS AuthKit + +Official docs: + +- https://docs.convex.dev/auth/authkit/ +- https://docs.convex.dev/auth/authkit/add-to-app +- https://docs.convex.dev/auth/authkit/auto-provision + +Use this when the app already uses WorkOS or the user wants AuthKit specifically. + +## Workflow + +1. Confirm the user wants WorkOS AuthKit +2. Determine whether they want: + - a Convex-managed WorkOS team + - an existing WorkOS team +3. Ask whether the user wants local-only setup or production-ready setup now +4. Read the official Convex and WorkOS AuthKit guide +5. Create or update `convex.json` for the app's framework and real local port +6. Follow the correct branch of the setup flow based on that choice +7. Configure the required WorkOS environment variables +8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs +9. Wire the client provider and callback flow +10. Verify authenticated requests reach Convex +11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too +12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex + +## What To Do + +- Read the official Convex and WorkOS AuthKit guide before writing setup code +- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team +- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra +- Follow the current setup flow from the docs instead of relying on older examples + +## Key Setup Areas + +- package installation for the app's framework +- `convex.json` with the `authKit` section for dev, and preview or prod if needed +- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration +- `convex/auth.config.ts` wiring for WorkOS-issued JWTs +- client provider setup and token flow into Convex +- login callback and redirect configuration + +## Files and Env Vars To Expect + +- `convex.json` +- `convex/auth.config.ts` +- frontend auth provider wiring +- callback or redirect route setup where the framework requires it +- WorkOS environment variables commonly include: + - `WORKOS_CLIENT_ID` + - `WORKOS_API_KEY` + - `WORKOS_COOKIE_PASSWORD` + - `VITE_WORKOS_CLIENT_ID` + - `VITE_WORKOS_REDIRECT_URI` + - `NEXT_PUBLIC_WORKOS_REDIRECT_URI` + +For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps. + +## Concrete Steps + +1. Choose Convex-managed or existing WorkOS team +2. Create or update `convex.json` with the `authKit` section for the framework in use +3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port +4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow +5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set` +6. Create or update `convex/auth.config.ts` for WorkOS JWT validation +7. Run the normal Convex dev or deploy flow so backend config is synced +8. Wire the WorkOS client provider in the app +9. Configure callback and redirect handling +10. Verify the user can sign in and return to the app +11. Verify Convex sees the authenticated user after login +12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too + +## Gotchas + +- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious +- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys +- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex +- Do not mix dev and prod WorkOS credentials or redirect URIs +- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it +- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts. +- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation. +- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again. +- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config. +- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded." + +## Production + +- Ask whether the user wants dev-only setup or production-ready setup +- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered +- Verify the production redirect and callback settings before calling the task complete +- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly. + +## Validation + +- Verify the user can complete the login flow and return to the app +- Verify the callback URL matches the real frontend port in local dev +- Verify Convex receives authenticated requests after login +- Verify `convex.json` matches the framework and chosen WorkOS setup path +- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path +- Verify environment variables differ correctly between local and production where needed +- If production-ready setup was requested, verify the production WorkOS configuration is also covered + +## Checklist + +- [ ] Confirm the user wants WorkOS AuthKit +- [ ] Ask whether the user wants local-only setup or production-ready setup +- [ ] Choose Convex-managed or existing WorkOS team +- [ ] Create or update `convex.json` +- [ ] Configure WorkOS environment variables +- [ ] Configure `convex/auth.config.ts` +- [ ] Verify authenticated requests reach Convex after login +- [ ] If requested, configure the production deployment too 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..738062a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - Use **PNPM** commands in this repo (workspace uses `pnpm-workspace.yaml`). - Always run new/updated tests after creating or changing them. - Prefer focused verification first (targeted package/spec), then broader checks when needed. +- At the end of each proposal when ready for a PR, run `pnpm ci:check` to ensure all checks pass. ## Quick Repo Orientation @@ -13,6 +14,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 +56,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 +273,387 @@ 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. + + +This project uses [Convex](https://convex.dev) as its backend. + +When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data. + +Convex agent skills for common tasks can be installed by running `npx convex ai-files install`. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ba21163 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ + +This project uses [Convex](https://convex.dev) as its backend. + +When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data. + +Convex agent skills for common tasks can be installed by running `npx convex ai-files install`. + 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..29dbe63 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,16 +16,88 @@ 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. +- [ ] make web admin chat input field multi line, with scrollbar when needed (currently single line max) +- [ ] make clicking anywhere on the settings headers expand that section, not just the show/hide button +- [ ] add full evals, traces, etc. to the AI agent + + 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 +165,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 +213,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/landing/package.json b/apps/landing/package.json index 70b0e7f..fb91937 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -17,7 +17,7 @@ "framer-motion": "^12.34.3", "geist": "^1.7.0", "lucide-react": "^0.469.0", - "next": "^15.5.10", + "next": "^15.5.15", "react": "^19.2.3", "react-dom": "^19.2.3", "tailwind-merge": "^2.1.0" 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..b68d066 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.35.1", "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/global-teardown.ts b/apps/web/e2e/global-teardown.ts index 2cb6bf9..84bf2f7 100644 --- a/apps/web/e2e/global-teardown.ts +++ b/apps/web/e2e/global-teardown.ts @@ -46,7 +46,7 @@ async function globalTeardown() { args: { secret: adminSecret, name: "testing/helpers:cleanupE2ETestData", - mutationArgs: {}, + mutationArgsJson: JSON.stringify({}), }, format: "json", }), 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/test-data.ts b/apps/web/e2e/helpers/test-data.ts index 78835e9..cf9df43 100644 --- a/apps/web/e2e/helpers/test-data.ts +++ b/apps/web/e2e/helpers/test-data.ts @@ -37,7 +37,11 @@ async function callInternalMutation(path: string, args: Record 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,12 +294,29 @@ 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. */ export async function submitNPSRating(page: Page, rating: number): Promise { const frame = getWidgetContainer(page); + await dismissTour(page); + // Click the rating button (0-10) await frame .locator( @@ -300,6 +329,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 +353,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 +404,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..ca1d5b6 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -41,23 +41,11 @@ const SECURITY_HEADERS = [ ]; const nextConfig = { - transpilePackages: ["@opencom/ui"], + transpilePackages: ["@opencom/ui", "@opencom/web-shared"], experimental: { // Reduce memory usage during webpack compilation webpackMemoryOptimizations: true, }, - webpack: (config, { dev }) => { - if (dev) { - // Use filesystem cache to reduce in-memory pressure during dev - config.cache = { - type: "filesystem", - buildDependencies: { - config: [__filename], - }, - }; - } - return config; - }, async headers() { return [ { diff --git a/apps/web/package.json b/apps/web/package.json index db5d0bb..0074cc0 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,15 +14,15 @@ "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.35.1", "dompurify": "^3.3.1", "fflate": "^0.8.2", "lucide-react": "^0.469.0", "markdown-it": "^14.1.1", - "next": "^15.5.10", + "next": "^15.5.15", "react": "^19.2.3", "react-dom": "^19.2.3" }, 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. +

+
+
+