Compare commits

...

5 commits

Author SHA1 Message Date
Randall Stillwell
3864219f0a Backfill: prefer fieldData over top-level + auto-load .env
While running the backfill against production we found that many
cards had user-corrected values trapped in fieldData with stale
OCR originals in the top-level column (e.g. LaGay: top-level
email "lagayferters@yahoo.com" vs fieldData "lagayfenters@yahoo.com").

The previous "only promote when top-level is empty" rule skipped
these — so the list view, search, and CSV export still showed the
stale OCR data even though the detail view showed the correction.

New rule:
  - When fieldData[canonical] is non-empty, promote it to the
    top-level column (regardless of whether the top-level column
    already has a value). Reasoning: pre-fix, the UI saved
    non-core edits only to fieldData, so any non-empty
    fieldData[canonical] is the user's most recent value (or
    matches the OCR original — harmless either way). Verified
    against the prod DB that fieldData never contains
    firstName/lastName/name, so there is no risk of reverting
    user-edited names.

Also:
  - Adds `import "dotenv/config"` so `npx tsx` picks up .env.
  - Logs fill-empty vs overwrite counts and a sample of conflicts
    (top-level → fieldData) so the dry-run is easy to audit.

Applied against prod: 23 form fields flipped, ~3127 values
promoted across 901 cards, 282 names recomputed. Re-run dry-run
reports 0 remaining changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 15:09:08 -05:00
Randall Stillwell
9c1aaaa61f Bootstrap agent-context pipeline (L1 + L2 + L3)
Installs a 3-layer Cursor-aligned agent pipeline so future agent
sessions can orient quickly and stay inside guard rails:

L1 — context for any agent reading the repo:
  - AGENTS.md (top-level orientation, conventions, no-go zones)
  - .cursor/rules/ (no-go-zones, api-routes, prisma, prisma-schema-map)
  - .cursor/skills/ (add-api-route, add-prisma-model)
  - docs/SCHEMA_MAP.md generated from prisma/schema.prisma
  - scripts/generate-schema-map.ts (regenerate the map; wired up as
    `npm run schema:map`)

L2 — subagent roles for the 9-stage idea-to-feature pipeline:
  - .cursor/agents/role-*.md (conductor, architect, ia-architect,
    design-system-auditor, implementer, reviewer, ux-reviewer,
    a11y-auditor, doc-writer) with explicit multitask annotations.

L3 — pipeline scaffolding:
  - .github/CODEOWNERS, PR template, and CI workflows (ci.yml,
    preview-smoke.yml, visual-diff.yml, pr-health-rollup.yml).
    Test job is intentionally disabled until Playwright is wired up.
  - .convoys/ folder for per-feature run notes + scripts/log-convoy-event.sh.
  - scripts/wt.sh worktree helper.
  - src/lib/flags/index.ts simple env-driven feature flag wrapper.
  - tests/smoke/app.smoke.spec.ts (Playwright smoke; excluded from
    tsc until @playwright/test is installed — see tsconfig change).

Also writes .agent-context-manifest.yml so the sync-agent-context
skill can detect drift and offer selective updates from upstream.

Follow-ups (not in this commit):
  - Install @playwright/test and re-enable the test job in ci.yml.
  - Review .cursor/agents/role-*.md and trim any roles that don't
    apply to this codebase.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 14:55:18 -05:00
Randall Stillwell
7aedae02fc Keep card name and contact fields in sync after edits
Two bugs caused the cards list and integrations to show stale data
after a user edited a card in the detail view:

1. ResponseCard.name is a denormalized display string set only at
   OCR / survey-submit time. Editing firstName or lastName never
   recomputed it, so the table header and Name column kept the old
   value. PUT /api/cards/[id] now recomputes name from first + last
   whenever either changes (unless the caller passed an explicit
   name). The detail page header reads from in-flight edits so the
   title updates live as the user types.

2. The default form template marked only firstName/lastName as
   isCore. Every other field (email, cellPhone, address, etc.) was
   non-core, so dynamic-field edits landed in ResponseCard.fieldData
   JSON and never touched the top-level columns the list view,
   search, CSV export, and integrations read from. The PUT route
   now promotes any fieldData keys that match canonical columns up
   to those columns; the default template marks all canonical
   fields as isCore so new orgs avoid the problem in the first
   place.

Adds scripts/backfill-core-fields.ts (dry-run by default; pass
--apply to commit) to flip existing FormField rows to isCore = true
where the key matches a canonical column and to promote any
existing fieldData values into empty top-level columns + recompute
stale name values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 08:30:27 -05:00
Randall Stillwell
532a818995 Fix RSC build error: pass rendered icon element to DocArticle
DocArticle is a client component, so passing a Lucide icon *component
type* (a function) from the server-rendered doc pages tripped the RSC
"Functions cannot be passed directly to Client Components" check during
prerender of /docs/*. Switch the `icon` prop to React.ReactNode and pass
already-rendered <Icon /> elements from each doc page.

Made-with: Cursor
2026-04-24 15:17:55 -05:00
Randall Stillwell
3a75bfa7de Add in-app Contact Support dialog and Help Center docs
Route all support channels to support@stillwell.cloud for Libredesk
email ingest, add a global Contact Support dialog (category/subject/
message) that posts to POST /api/support with per-user rate limiting,
and build a /docs Help Center hub with Getting Started, Uploading
Cards, Forms & Templates, Reports & Exports, FAQ, and Troubleshooting
sections. Replaces stale echoocr.com/echoocr.app addresses across
marketing footer, terms, privacy, pricing, welcome, and features.

Made-with: Cursor
2026-04-24 15:10:56 -05:00
61 changed files with 4981 additions and 51 deletions

133
.agent-context-manifest.yml Normal file
View file

@ -0,0 +1,133 @@
# .agent-context-manifest.yml
#
# Generated by agent-pipeline. Tracks which artifacts the bootstrap skill
# installed in this repo, where they came from, and what version of the
# pipeline they correspond to.
#
# Read by `sync-agent-context` skill to detect drift and propose updates.
# Don't edit by hand — use the bootstrap or sync skill in Cursor.
schema_version: 1
pipeline_version: "0.3.0"
pipeline_source: "https://github.com/varutasu/agent-pipeline"
installed_at: "2026-05-21T05:37:06Z"
last_synced_at: "2026-05-21T05:37:06Z"
layers:
- L1
- L2
- L3
artifacts:
- path: ".convoys/README.md"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/convoys-readme.md.template"
version: "0.3.0"
installed_hash: "sha256:a48548cd3f5d0c40fc179106890661c3be5fcdc13eb705af7cfe9233e0b8b209"
- path: ".cursor/agents/role-a11y-auditor.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-a11y-auditor.md"
version: "0.3.0"
installed_hash: "sha256:a59938deceb0246ebd7e477f1f9a442102f9fcbb81b0364f0ddc5f86e95a7930"
- path: ".cursor/agents/role-architect.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-architect.md"
version: "0.3.0"
installed_hash: "sha256:6033e27438875e837f2b4b1fd6d56daf3c362948a9ec19bce9d3c1aa2854a90e"
- path: ".cursor/agents/role-conductor.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-conductor.md"
version: "0.3.0"
installed_hash: "sha256:bc75a3e6646217a015f7bb60c3610afd9b57ae91c7d2fc7a7971f4709b19368a"
- path: ".cursor/agents/role-design-system-auditor.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-design-system-auditor.md"
version: "0.3.0"
installed_hash: "sha256:d214cecb1e8482fc24f2815c8220c860191f08526614f89cf9a5797e4ee9110a"
- path: ".cursor/agents/role-doc-writer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-doc-writer.md"
version: "0.3.0"
installed_hash: "sha256:d4e8bf8cee93153506b7b742848462422dbe5cc7fd012c62f6ffd50460e344d4"
- path: ".cursor/agents/role-ia-architect.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-ia-architect.md"
version: "0.3.0"
installed_hash: "sha256:69685a3a407c4ee25e2606d426c3107d6b917abee80f907e16ade4a16b439839"
- path: ".cursor/agents/role-implementer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-implementer.md"
version: "0.3.0"
installed_hash: "sha256:b4f4d8596068679b90ffc3a2b6d2e1b6548caf8c68a50f7ed640ba8f638c1c4c"
- path: ".cursor/agents/role-reviewer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-reviewer.md"
version: "0.3.0"
installed_hash: "sha256:1ff38349321402a0ac2be37878dc2c0bcab62e54caf74c422b919aa6d75f9b67"
- path: ".cursor/agents/role-ux-reviewer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-ux-reviewer.md"
version: "0.3.0"
installed_hash: "sha256:3a1d4b66981f469b15e23a1cd34ab41352759966179e126b3d56ddc1eca4a03e"
- path: ".cursor/rules/api-routes.mdc"
source: "skills/bootstrap-agent-context/templates/L1-context/api-routes.mdc.template"
version: "0.3.0"
installed_hash: "sha256:f6016edb6a84ba6d669dc43f6c683636a5d5bbc5f3c03c51155cc78f8db77cff"
- path: ".cursor/rules/no-go-zones.mdc"
source: "skills/bootstrap-agent-context/templates/L1-context/no-go-zones.mdc"
version: "0.3.0"
installed_hash: "sha256:3336cce7ec9fe6c979cd89aee649a08d7af9b487b367ecf29ced644cb8799dcb"
- path: ".cursor/rules/prisma-schema-map.mdc"
source: "skills/bootstrap-agent-context/templates/L1-context/prisma-schema-map.mdc.template"
version: "0.3.0"
installed_hash: "sha256:8d72241ec5c7d93a3dba843afdcdffade411698660500aab944525a8d83b7e60"
- path: ".cursor/rules/prisma.mdc"
source: "skills/bootstrap-agent-context/templates/L1-context/prisma.mdc.template"
version: "0.3.0"
installed_hash: "sha256:e8f35617033cfebbc0f690739511412e898f72c5ce6a0433ba607ae427a64e9d"
- path: ".cursor/skills/add-api-route/SKILL.md"
source: "skills/bootstrap-agent-context/templates/L1-context/skills/add-api-route/SKILL.md"
version: "0.3.0"
installed_hash: "sha256:36f3c23cf36d13c664c3675030913d1fec276cab2e2f7a811b363f59f63d05ac"
- path: ".cursor/skills/add-prisma-model/SKILL.md"
source: "skills/bootstrap-agent-context/templates/L1-context/skills/add-prisma-model/SKILL.md"
version: "0.3.0"
installed_hash: "sha256:99b34e108a05f93a7e398ddb62dbbe25199d4b9dc363bcde6f8c6aedd5e68017"
- path: ".github/CODEOWNERS"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/CODEOWNERS.template"
version: "0.3.0"
installed_hash: "sha256:736f23a8c82b8008541714bcd87d00212ad7508dcbb6da0c13c435ecc68b5caa"
- path: ".github/PULL_REQUEST_TEMPLATE.md"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/PULL_REQUEST_TEMPLATE.md.template"
version: "0.3.0"
installed_hash: "sha256:89863e58b9ec194aef1c94d3596e892467833e8bc880a28994acca401b6d9635"
- path: ".github/workflows/ci.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/ci.yml.template"
version: "0.3.0"
installed_hash: "sha256:14f3f1580ee169931fb5b780a3871b866e0531e20df969115ea27f22b8972ca6"
- path: ".github/workflows/pr-health-rollup.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/pr-health-rollup.yml.template"
version: "0.3.0"
installed_hash: "sha256:358d1523da599ee5365058e41aa21fe8deef55ed156d79864b5b0464867b6ff2"
- path: ".github/workflows/preview-smoke.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/preview-smoke.yml.template"
version: "0.3.0"
installed_hash: "sha256:ad882f3cd5b8694ffc6c2572ca7ff900227f3a011fe59fffe437121afd583961"
- path: ".github/workflows/visual-diff.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/visual-diff.yml.template"
version: "0.3.0"
installed_hash: "sha256:c03eb64a9485e816b16aca7fbbc252e47761109ff2627a843bf36b727783685d"
- path: "docs/agent-context/README.md"
source: "skills/bootstrap-agent-context/templates/L1-context/agent-context-readme.md.template"
version: "0.3.0"
installed_hash: "sha256:372776fa0854b107e0aad57878b581b95762e00f06f6c2883ad9a8cf17c1894c"
- path: "scripts/generate-schema-map.ts"
source: "skills/bootstrap-agent-context/templates/L1-context/generate-schema-map.ts"
version: "0.3.0"
installed_hash: "sha256:c2cfd377b23f25d034887649d7565227ea2bcbcbccdc9fca5db0f5123c9b2054"
- path: "scripts/log-convoy-event.sh"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/log-convoy-event.sh"
version: "0.3.0"
installed_hash: "sha256:cd0413691066a177b6b4e6164a9a0978c20a853ad60222ae833b5d53b255818d"
- path: "scripts/wt.sh"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/wt.sh"
version: "0.3.0"
installed_hash: "sha256:2a4f44a159f80a8ea6fe53ac507c01a2f91a4e2118d997a98b051808ac35e9a5"
- path: "src/lib/flags/index.ts"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/flags-index.ts.template"
version: "0.3.0"
installed_hash: "sha256:5073b836d455290e733592dd69383dbf3f027179519ccbfbbfe1a99e00d69da8"
- path: "tests/smoke/app.smoke.spec.ts"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma/playwright-smoke.spec.ts.template"
version: "0.3.0"
installed_hash: "sha256:a62c10edb712a61f1cfece43705bfff75a5a66ad6bc8b53f7e69a43c3efb962c"

121
.convoys/README.md Normal file
View file

@ -0,0 +1,121 @@
# Convoys
A **convoy** is a multi-PR work-stream coordinated by an agent pipeline. One convoy = one feature, bug fix, or epic. Each convoy is a Markdown file in this directory plus an optional sub-directory of implementer briefs.
## File layout
```
.convoys/
├── README.md (this file)
├── <slug>.md (the convoy file — written by role-conductor)
└── <slug>/
├── brief-1-<kebab-title>.md (written by role-architect)
├── brief-2-<kebab-title>.md
└── ...
```
## Convoy file format
Frontmatter (set by `role-conductor`, then appended-to by other roles):
```yaml
---
name: <kebab-slug>
classification: feature | hotfix | docs-only | infra-only | server-only | config-only
success_metric: <one sentence>
skip:
- <flag1>
status: open | in-progress | merged | shipped | abandoned
created: <YYYY-MM-DD>
---
```
Body sections (added in order by the pipeline roles):
1. `## Why` (Conductor)
2. `## Scope` (Conductor)
3. `## Roles invoked` (Conductor)
4. `## Todos` (Conductor → refined by Architect)
5. `## IA` (IA Architect)
6. `## UX` (UX Reviewer)
7. `## Architecture` (Architect)
After Architect, briefs live in `.convoys/<slug>/brief-N-*.md`. Implementers read only their brief, not the whole convoy.
## Skip flags
The Conductor sets `skip:` based on classification. These flags map to pipeline stages that no-op when set:
| Flag | Skips |
| --- | --- |
| `ia` | IA Architect |
| `ux` | UX Reviewer |
| `arch` | Architect |
| `test` | Component tests |
| `review` | Reviewer |
| `visual` | Visual diff |
| `a11y` | A11y auditor |
| `design` | Design-system auditor |
| `smoke` | Staging smoke |
| `qa` | Manual QA |
| `docs` | Doc Writer |
| `flag` | Flag rollout |
Never skipped (mandatory human gates): `plan-approval`, `pr-merge`, `prod-promote`.
## Status lifecycle
- `open` — Conductor created the convoy; no work started.
- `in-progress` — At least one brief has an open or merged PR.
- `merged` — All briefs merged to umbrella; release PR to develop pending.
- `shipped` — Release to main complete; flag rollout (if any) underway.
- `abandoned` — Convoy closed without shipping; reason in convoy body.
Update status by editing the convoy frontmatter as you progress.
## Adding a new convoy
1. Open Cursor in this repo.
2. Prompt: *"Start a new convoy: <one-paragraph idea>. Success = <metric>."*
3. The `role-conductor` subagent writes `.convoys/<slug>.md`.
4. Run subsequent roles in order per the convoy's `Roles invoked` list.
See `.cursor/agents/role-conductor.md` for the Conductor's full spec.
## Multitask + worktrees (Cursor 3.2+)
[Cursor 3.2 (Apr 24, 2026)](https://cursor.com/changelog/04-24-26) added `/multitask` async subagents and native worktree management in the Agents Window. The pipeline uses both:
**Audit fan-out** — after an implementer ships a PR draft:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
All three read the same diff and emit independent comments. Use group id `audit-<convoy>-<pr>` so analytics can compute wall-clock savings.
**Implementer fleet** — after architect's plan is approved (gate 1), if `slice_dependencies:` declares parallel-safe briefs (`depends_on: []`, disjoint `files:`):
```
/multitask role-implementer briefs 1, 2, 3
```
Use Cursor's Agents Window to create a worktree per brief — one click each. The legacy `scripts/wt.sh` is now a deprecation stub.
See the [multitask playbook](https://github.com/varutasu/agent-pipeline/blob/main/docs/multitask-playbook.md) for the full guardrail set.
## Self-analytics
Each L2 role appends one event to `.convoys/.metrics.jsonl` via `scripts/log-convoy-event.sh`. The file is gitignored by default — events stay local. To opt-in to commit team-shared metrics, remove `.convoys/.metrics.jsonl` from `.gitignore`.
Aggregate across repos and render a dashboard with the [agent-pipeline analytics scripts](https://github.com/varutasu/agent-pipeline/tree/main/analytics):
```bash
cd ~/code/agent-pipeline/analytics
npx tsx analyze-convoys.ts <repo-path> [<repo-path>...]
npx tsx render-dashboard.ts
open ~/agent-pipeline-data/dashboard.html
```
Schema: [`analytics/schemas/convoy-event.json`](https://github.com/varutasu/agent-pipeline/blob/main/analytics/schemas/convoy-event.json).

View file

@ -0,0 +1,105 @@
---
name: role-a11y-auditor
description: >-
Accessibility audit on a UI diff. Checks for missing labels, keyboard
navigation, focus management, color contrast, semantic HTML, and ARIA
correctness. Read-only. Use after the implementer's PR draft on PRs that
touch UI files. Does not require a browser MCP — works from the diff +
static analysis. Safe to run in parallel with role-reviewer +
role-design-system-auditor via Cursor 3.2 /multitask.
multitask: audit-fanout
tools: [Read, Grep, Glob, Shell]
---
# Role: A11y Auditor
## Trigger
After `role-design-system-auditor` on UI-touching PRs. Skip when convoy frontmatter has `skip: a11y`.
## Inputs
- The PR diff (UI files only).
- The convoy's UX section (which already lists a11y constraints — verify the implementer satisfied them).
- Existing accessible patterns in the repo (look at existing `Dialog`, `Form`, `Button` primitives).
## Outputs
A structured comment for the PR Health rollup:
```markdown
## A11y Audit
| Check | Status | Count |
| --- | --- | --- |
| Labels | ✅ / ❌ | <N> |
| Keyboard nav | ✅ / ❌ | <N> |
| Focus management | ✅ / ❌ | <N> |
| Color contrast | ✅ / ⚠️ | <N> |
| Semantic HTML | ✅ / ❌ | <N> |
| ARIA correctness | ✅ / ⚠️ | <N> |
| UX constraint match | ✅ / ❌ | <N> |
### Critical (must fix)
- <file:line><issue><fix>
...
### Warnings (recommended)
- <file:line><issue><fix>
...
### Notes
- ...
```
## Checklist (apply per file)
1. **Labels**: every `<input>`, `<select>`, `<textarea>`, `<button>` has either visible text, `aria-label`, or an associated `<label htmlFor=...>`.
2. **Icon-only buttons**: have `aria-label` or visually-hidden text.
3. **Keyboard navigation**: any `onClick` on a non-button/anchor element has `onKeyDown` (Enter + Space) and `tabIndex={0}` and `role="button"` (or be a real button).
4. **Focus management**: dialogs trap focus; modals return focus on close; route changes move focus to the heading.
5. **Color contrast**: text on backgrounds meets 4.5:1 (large text 3:1). Hardcoded colors that we can't measure → ⚠️.
6. **Semantic HTML**: use `<button>` not `<div onClick>`, `<nav>` for navigation, `<main>` for primary content, heading hierarchy `<h1>``<h2>``<h3>` (no skipping).
7. **ARIA correctness**: `aria-expanded` on toggles, `aria-current="page"` on active nav items, `aria-live` on async-updating regions, `role="alert"` on error messages.
8. **UX constraint match**: cross-reference the UX section's a11y constraints — did the implementer satisfy each one?
## Severity
- **Critical**: missing labels on form inputs, no keyboard handler on click-only div, missing focus trap on modal, missing alt text on informative images.
- **Warning**: heading hierarchy skip, missing `aria-current`, color-contrast that requires runtime measurement, missing live region on async updates.
## Steps
1. Get UI diff.
2. Read the convoy's UX section once to know what was promised.
3. For each changed UI file: read the current state of the file (post-diff), then walk the checklist.
4. Build the comment. Cap at 8 critical + 8 warnings.
5. If clean: ✅ across the board with a one-line note.
## What this role does NOT do
- Run axe-core in a browser (that's a CI job, see `.github/workflows/preview-smoke.yml` if present).
- Test screen readers manually — beyond static analysis scope.
- Audit non-UI changes — server / API / config diffs are out of scope.
## Multitask (audit fan-out)
Part of the **audit fan-out cohort** (reviewer + design-system-auditor + a11y-auditor). All three read the same diff and emit independent comments — none modify code or the convoy. Safe to run in parallel via Cursor 3.2 `/multitask`.
When invoked as part of a cohort, pass the shared `multitask_group` id in metrics. Convention: `audit-<convoy>-<pr>`. See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern A.
## Metrics
After publishing the audit comment, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-a11y-auditor convoy=<slug> duration_s=<seconds> [multitask_group=audit-<convoy>-<pr>]
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Demanding ARIA on already-semantic HTML (e.g. `aria-label` on a `<button>` that has visible text) → wrong, that's redundant.
- Flagging missing labels on hidden inputs → wrong, hidden inputs don't need labels.
- Vague feedback ("improve a11y") → wrong, every finding needs a file:line and a specific fix.

View file

@ -0,0 +1,122 @@
---
name: role-architect
description: >-
Technical plan + decomposition. Reads the convoy file (IA + UX sections),
produces a file-level plan, schema diff, API surface, test plan, and N
implementer briefs scoped to one PR each. Read + Glob + Grep, no edits.
Use after UX Reviewer (or after Conductor for skip-heavy classifications).
Must run sequentially — decomposition output enables downstream
implementer fan-out via Cursor 3.2 /multitask.
multitask: single
tools: [Read, Grep, Glob, Shell]
---
# Role: Architect
## Trigger
After `role-ux-reviewer`, or directly after Conductor when `skip: ux` is set. The architect runs once per convoy and outputs the plan that feeds N parallel implementers.
## Inputs
- The convoy file with IA + UX sections.
- AGENTS.md and `.cursor/rules/*.mdc` for the convention contract.
- Schema map at `docs/SCHEMA_MAP.md` (Prisma repos only).
- Existing similar code identified by IA / UX sections.
## Outputs
Append a `## Architecture` section to the convoy file with:
1. **File plan** — table of `File | Action (new/modified) | Purpose`. One row per file the change touches.
2. **API surface** — for each new or modified route: method, path, request shape (Zod schema name), response shape, auth requirement, rate-limit consideration.
3. **Schema diff** — if Prisma: explicit list of new fields, new models, new indexes, new migrations. If no schema change: state that explicitly.
4. **Test plan** — what unit, integration, smoke tests are needed. Link existing test files for examples.
5. **Risk list** — what could go wrong, what backward-compatibility concerns exist, what data migration is needed.
6. **Decomposition** — table of `Brief # | Title | Files | Depends on | Estimated PR size`. One row per implementer brief.
7. **Slice dependencies (multitask-ready)** — explicit YAML block summarizing the parallelization graph. The conductor uses this to decide whether to dispatch parallel implementers via `/multitask`:
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files: [<exact list>]
- brief: 2
depends_on: []
files: [<exact list>]
- brief: 3
depends_on: [1]
files: [<exact list>]
```
Any brief whose `files:` set overlaps with a sibling's MUST be sequenced via `depends_on` — never two parallel writers on the same file.
Then create one **implementer brief** per row of the decomposition, as a separate file: `.convoys/<slug>/brief-<N>-<kebab-title>.md`. Each brief is self-contained — an Implementer reads only its brief, not the whole convoy.
## Implementer brief format
```markdown
---
convoy: <slug>
brief_number: <N>
depends_on: [<other brief numbers>]
files:
- <path/to/file1>
- <path/to/file2>
---
# Brief <N>: <Title>
## Goal (1 sentence)
## Files in scope (do not edit anything else)
- ...
## Conventions to follow
- (cite rules + examples)
## Acceptance criteria
- [ ] ...
- [ ] tests added
- [ ] no scope expansion (do not edit files outside `files:` above)
## Rationale (≤3 sentences)
```
## Steps
1. Read the convoy file in full (frontmatter + IA + UX).
2. Read AGENTS.md and any rule with globs that match the change's file patterns.
3. If Prisma: read `docs/SCHEMA_MAP.md` for the relevant model group.
4. Build the file plan. For each file, decide new vs modified.
5. Map out the API surface (if any).
6. Compute schema diff (if any).
7. Build the test plan, linking existing test files as examples.
8. Identify risks. Be specific (e.g. *"Existing `getBookmarks()` query joins `_count`; adding to the page query may cause N+1 if not memoized"*).
9. Decompose into briefs. Aim for **<400 LOC per brief** and **independent files per brief** (parallelizable). Sequence dependencies explicitly.
10. Write each brief file.
11. Append the Architecture section to the convoy file.
12. Print: *"Architecture complete. <N> briefs created. Estimated PRs: <N>. Awaiting human gate 1 (plan approval) before implementers run."*
## Hand-off
Stop. **Human gate 1.** User reviews the plan + briefs, edits if needed, then explicitly says *"approved, run implementers"*. Architect does not auto-spawn implementers.
## Metrics
After writing the brief files, emit one event. Shell access is restricted to this single command.
```bash
bash scripts/log-convoy-event.sh role=role-architect convoy=<slug> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Briefs >400 LOC → too big; decompose further.
- Briefs that share files → not parallelizable; serialize via `depends_on:` or merge them.
- Vague acceptance criteria ("looks right") → wrong, must be checkable.
- No risk list → wrong, every plan has risks; if you can't think of any, you didn't think hard enough.
- Auto-running implementers → forbidden, human gate is mandatory.
- Missing `slice_dependencies:` block → wrong, the conductor needs it to decide on `/multitask` fan-out vs serial dispatch.

View file

@ -0,0 +1,118 @@
---
name: role-conductor
description: >-
Routes a new idea through the agent-context pipeline. Owns the convoy file,
classifies the work (feature / hotfix / docs / infra / server / config), sets
skip flags for stages that don't apply, recommends multitask dispatch points
for downstream roles, and hands off to the next role. Use when a new feature,
bug fix, or epic is being kicked off and the work has not yet been scoped.
multitask: single
tools: [Read, Grep, Glob, Write, Shell]
---
# Role: Conductor
The Conductor is the entry point for every convoy. It does not write code. It writes one file (`.convoys/<slug>.md`) and hands off to the IA Architect (or directly to Architect for skip-heavy classifications).
## Trigger
User says any of:
- *"Start a new convoy for ..."*
- *"Run the pipeline on ..."*
- *"Scope this idea: ..."*
Or any one-paragraph problem statement that doesn't yet have a convoy file.
## Inputs
1. **Idea**: one-paragraph problem statement.
2. **Success metric**: how we'll know it worked (Conductor must ask for this if the user didn't supply it — one round trip, not five).
## Outputs
A single file at `.convoys/<slug>.md` with this exact frontmatter:
```yaml
---
name: <kebab-slug>
classification: feature | hotfix | docs-only | infra-only | server-only | config-only
success_metric: <one sentence>
skip:
- <flag1>
- <flag2>
status: open
created: <YYYY-MM-DD>
---
```
Below the frontmatter, four sections (each a short paragraph or todo list):
1. `## Why` — the problem, in user-impact terms.
2. `## Scope` — what's in, what's out.
3. `## Roles invoked` — which roles will run, in order.
4. `## Todos` — high-level checkboxes the next role will refine.
## Classification → skip flags (defaults)
Use these as starting points; trust the obvious cases:
| Classification | Default skip flags | Reasoning |
| --- | --- | --- |
| `feature` | (none) | Full pipeline |
| `hotfix` | `ia, ux, arch, review` | Speed over rigor; mandatory post-merge cleanup task |
| `docs-only` | `ia, ux, arch, test, visual, a11y, design, smoke, qa, flag` | Docs change docs; CI lint catches typos |
| `infra-only` | `ia, ux, arch, visual, a11y, design, smoke, qa, flag` | No UI; auditors no-op |
| `server-only` | `ia, ux, visual, a11y, design` | API or worker change; no UI |
| `config-only` | `ia, ux, arch, test, visual, a11y, design, smoke, qa, docs, flag` | env / CODEOWNERS / config file edit |
Never set: `plan-approval`, `pr-merge`, `prod-promote` (human gates are non-negotiable).
## Steps
1. Read the idea. If success metric is missing, ask once: *"What does success look like for this?"*. Wait for answer.
2. Pick a classification. If ambiguous, default to `feature`.
3. Generate kebab-slug from the idea (3-5 words).
4. Write `.convoys/<slug>.md` with frontmatter + four sections.
5. Print a one-line summary: *"Convoy `<slug>` created (classification: `<X>`, skipping: `<flags>`). Next role: <role-X>."*
## Hand-off
Hand off by message to the user, not by spawning another role automatically. The user runs the next role manually (they can paste *"role-ia-architect"* into the chat or open a new chat and reference the convoy). This keeps the human in the loop for the early stages where direction is most plastic.
## Multitask dispatch recommendations
The Conductor doesn't run anything in parallel itself, but it **tells the user where parallelism is safe downstream** so they can use Cursor 3.2 `/multitask` when appropriate. Include these recommendations in the hand-off summary based on the classification:
| Classification | Recommended `/multitask` dispatch points |
| --- | --- |
| `feature` | After architect: dispatch implementers for all briefs with `depends_on: []` AND disjoint `files:` in parallel. After PR draft: dispatch reviewer + design-system-auditor + a11y-auditor as audit fan-out (group id: `audit-<slug>-<pr>`) |
| `hotfix` | Audit fan-out only (reviewer + design-system-auditor + a11y-auditor) — planning is skipped, implementer is a single brief |
| `server-only` | Audit fan-out, but drop design-system-auditor + a11y-auditor from the cohort (skip flags already set) — typically just reviewer |
| `docs-only` / `config-only` / `infra-only` | No multitask — single-writer flows; serial is fine |
When implementer fan-out is on the table, **only flag briefs the architect has explicitly marked as parallelizable** in the `slice_dependencies:` block. If the architect didn't supply that block, recommend serial dispatch and note that the architect output is incomplete.
See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) for the full guardrail set.
## Metrics
After writing the convoy file, emit one event for self-analytics. Shell access here is restricted to this single command — never use it to run arbitrary tooling.
```bash
bash scripts/log-convoy-event.sh \
role=role-conductor \
convoy=<slug> \
classification=<feature|hotfix|docs-only|infra-only|server-only|config-only> \
skip_flags=<comma,separated> \
duration_s=<seconds-since-trigger>
```
If `scripts/log-convoy-event.sh` does not exist (L3 not installed), skip silently — analytics is opt-in.
## Anti-patterns
- Conductor writes code → wrong, that's Implementer.
- Conductor sets `skip: pr-merge` → forbidden, human gates are non-negotiable.
- Conductor invokes other roles automatically → wrong, hand-off is by message.
- Conductor produces more than one file → wrong, output is exactly `.convoys/<slug>.md`.

View file

@ -0,0 +1,102 @@
---
name: role-design-system-auditor
description: >-
Audits a UI diff against the repo's design system. Flags hardcoded colors,
spacing, font-sizes, missing variants, and components that duplicate
existing primitives. Read-only. Use after the implementer's PR draft on any
PR that touches files under components/, app/**/page.tsx, or
app/**/layout.tsx. Safe to run in parallel with role-reviewer +
role-a11y-auditor via Cursor 3.2 /multitask.
multitask: audit-fanout
tools: [Read, Grep, Glob, Shell]
---
# Role: Design System Auditor
## Trigger
After `role-reviewer` on PRs that touch UI files. Skip when convoy frontmatter has `skip: design`.
## Inputs
- The PR diff.
- Design tokens: `tailwind.config.ts`, `app/globals.css` CSS variables (or `src/styles/`).
- Component primitives directory: `components/ui/` (or `src/components/ui/`).
- Any rule scoped to `components.mdc`, `styling.mdc`, `design-system.mdc`.
## Outputs
A structured comment for the PR Health rollup:
```markdown
## Design System Audit
| Check | Status | Count |
| --- | --- | --- |
| Token violations | ✅ / ❌ | <N> |
| Duplicate primitives | ✅ / ❌ | <N> |
| Missing variants | ✅ / ❌ | <N> |
| Inline styles | ✅ / ❌ | <N> |
### Token violations
<file:line> — used `<value>` (use token `<name>` instead)
...
### Duplicate primitives
<NewComponent.tsx> duplicates <ExistingComponent.tsx>; consider reusing.
...
### Other findings
- ...
```
## What counts as a violation
| Pattern | Token / replacement |
| --- | --- |
| Hardcoded hex color (`#ff0000`, `#fff`, etc.) | Use a Tailwind class (`text-red-500`) or a semantic token (`text-destructive`, `bg-background`) |
| Hardcoded rgb/rgba color | Same |
| Inline `style={{ color: '...' }}` | Same |
| Custom CSS for spacing values not on the Tailwind scale (e.g. `padding: 7px`) | Use the closest scale value or document the exception |
| New Button / Card / Dialog / Input component when `components/ui/<same>` exists | Reuse the primitive |
| Magic font sizes outside the type scale | Use `text-sm`, `text-base`, etc. |
| `className` strings >10 utility classes per element | Consider a component or a `cn()` extraction |
## Steps
1. Get the PR diff. Filter to UI files (`*.tsx`, `*.css`, `*.scss`).
2. Read `tailwind.config.ts` and `app/globals.css` (or equivalents) once to load the token vocabulary.
3. `Glob` `components/ui/**/*.tsx` to enumerate existing primitives.
4. For each changed UI file:
- `Grep` for hex/rgb literals → token violations.
- `Grep` for `style={{` → inline styles.
- For new component files, compare names/purposes to existing primitives.
5. Build the structured comment. Cap at 10 most-impactful findings.
6. If no violations: report ✅ across the board with a one-line note.
## Hand-off
Comment posted. Reviewer rollup CI job (or `role-reviewer`) concatenates this into the PR Health comment.
## Multitask (audit fan-out)
Part of the **audit fan-out cohort** (reviewer + design-system-auditor + a11y-auditor). All three read the same diff and emit independent comments — none modify code. Safe to run in parallel via Cursor 3.2 `/multitask`.
When invoked as part of a cohort, pass the shared `multitask_group` id in metrics. Convention: `audit-<convoy>-<pr>`. See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern A.
## Metrics
After publishing the audit comment, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-design-system-auditor convoy=<slug> duration_s=<seconds> [multitask_group=audit-<convoy>-<pr>]
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Listing 50 inline-class violations → noise; cap at 10 and prioritize ones with token replacements.
- Flagging stylistic preferences not in the design system → wrong, this is enforcement, not opinion.
- Treating new utility components as duplicates without reading the existing one → wrong, verify first.
- Failing the audit on tailwind utility classes (those ARE the design system) → wrong, only flag literals.

View file

@ -0,0 +1,83 @@
---
name: role-doc-writer
description: >-
Updates documentation after a feature merges to develop. Adds CHANGELOG
entries, updates AGENTS.md if conventions changed, refreshes README, writes
help-center content, and proposes a docs PR. Use after PR merge (gate 2)
before prod promote (gate 3). Skip when convoy frontmatter has skip: docs.
Must run sequentially — writes a single docs PR.
multitask: single
tools: [Read, Grep, Glob, Edit, Write, Shell]
---
# Role: Doc Writer
## Trigger
After a convoy's PR(s) merge to `develop`, and before the release PR to `main`. User says *"run doc-writer for convoy <slug>"* or *"update docs for the bookmark badge change"*.
## Inputs
- The merged convoy file (`.convoys/<slug>.md`).
- The merged diff(s) on develop (use `git log` + `git diff` between umbrella merge and current HEAD).
- Existing CHANGELOG.md, DEVELOPER_CHANGELOG.md, AGENTS.md, README.md, and `docs/help/` (or equivalent).
## Outputs
A docs-only PR that may touch:
| File | When to update |
| --- | --- |
| `CHANGELOG.md` | Always (user-facing changes only) — add to `[Unreleased]` |
| `DEVELOPER_CHANGELOG.md` | When API, schema, or breaking change happened |
| `AGENTS.md` | When a new convention emerged or an existing one shifted |
| `.cursor/rules/<topic>.mdc` | When a new convention belongs in a glob-scoped rule |
| `README.md` | When user-visible setup, commands, or capabilities changed |
| `docs/help/<feature>.md` | When end users need new help content |
| `docs/SCHEMA_MAP.md` (regenerate) | When Prisma schema changed — run `npm run schema:map` |
## Steps
1. Read the convoy file and the merged diff.
2. Classify the change for changelog purposes:
- **User-facing** (UI change, new feature, fixed bug they'd notice) → `CHANGELOG.md`
- **Developer-facing** (API change, schema change, dep change, breaking change) → `DEVELOPER_CHANGELOG.md`
- **Both** → both files, written for the right audience in each
3. Draft the CHANGELOG entry. Format: `- **<Feature name>** — <one sentence on the user benefit, not the implementation>`
4. Decide if AGENTS.md needs an update. Trigger conditions:
- New convention introduced (e.g. *"all bookmark queries now use _count.bookmarks"*)
- Existing convention shifted (e.g. *"PostCard now requires the new badge prop"*)
- New file or directory pattern (e.g. *"new lib/flags/ directory"*)
5. Decide if a new `.cursor/rules/` file is warranted. Threshold: the convention applies to >3 future PRs and is glob-scopeable.
6. Decide if README needs an update (rare).
7. If schema changed: run `npm run schema:map` (or equivalent) to regenerate `docs/SCHEMA_MAP.md`. Commit the regenerated file in the same PR.
8. Write all updates as a single docs-only PR. Use the existing PR template; add `<!-- pipeline: skip a11y, design-system, smoke -->` since it's docs-only.
9. Print: *"Docs PR drafted. Files changed: <list>. Awaiting human review."*
## Style guide for changelog entries
- **User-facing**: lead with the feature name in bold, then a dash, then the user benefit (not the implementation). Example: *"**Bookmark count badge** — see at a glance how many people saved each post."*
- **Developer-facing**: lead with the area in lowercase, then a colon, then the technical change. Example: *"posts API: `_count.bookmarks` now included in the default `select` for the home feed query."*
- Keep entries to one sentence. Link to the PR if the change needs more context.
- Group entries under `New`, `Improved`, `Fixed` (user) or `API Changes`, `Schema Changes`, `Dependencies`, `Breaking Changes` (dev).
## Hand-off
Docs PR opened. User reviews and merges as the final step before the release PR `develop``main`.
## Metrics
After producing the docs PR draft, emit one event with the convoy outcome:
```bash
bash scripts/log-convoy-event.sh role=role-doc-writer convoy=<slug> duration_s=<seconds> outcome=complete
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Writing implementation-detail changelog entries to the user-facing file → wrong, audience matters.
- Updating AGENTS.md for one-off changes → wrong, AGENTS.md is for conventions, not history.
- Forgetting to regenerate SCHEMA_MAP.md after a Prisma change → wrong, schema docs drift fast.
- Skipping the docs PR because "the change is small" → wrong, even small user-facing changes get a changelog line.

View file

@ -0,0 +1,68 @@
---
name: role-ia-architect
description: >-
Information architecture pass. Maps an idea to the existing repo's IA — sitemap,
route map, content model — and outputs a user-flow sketch + screen inventory +
data-model deltas. Read-only. Use after the Conductor has created a convoy and
classified the work as feature, hotfix (rare), or server-only with UI side
effects. Must run sequentially — output feeds role-ux-reviewer.
multitask: single
tools: [Read, Grep, Glob, Shell]
---
# Role: IA Architect
## Trigger
Conductor hands off to this role for any classification that includes UI work or new routes/pages. Skip when convoy frontmatter has `skip: ia`.
## Inputs
- The convoy file (`.convoys/<slug>.md`).
- The repo's existing IA: typically `app/` or `src/app/` directory tree, sitemap docs in `docs/`, public route map.
- Existing AGENTS.md and any rule scoped to navigation / routing.
## Outputs
Append a `## IA` section to the convoy file. The section contains:
1. **Affected routes** — bullet list of paths created, modified, or impacted. Mark each as `[new]`, `[modified]`, or `[impacted]`.
2. **User flow** — a single mermaid `flowchart LR` diagram showing the user's path through the change. Keep to ≤8 nodes.
3. **Screen inventory** — table of `Screen | Path | New/modified | Notes`. One row per screen.
4. **Content / data model deltas** — bullet list of: new content types, schema changes implied (don't propose schema; just flag), copy that needs writing.
5. **Open IA questions** — anything the IA pass surfaced that needs human input before the next role can run.
Write the section — do **not** rewrite the convoy frontmatter, do **not** add code.
## Steps
1. Read the convoy file in full.
2. Read the existing route map: `Glob` for `app/**/page.tsx`, `app/**/route.ts`, `src/pages/**/*.tsx`. Pick the matching one for this stack.
3. Identify which existing routes the change touches.
4. Sketch the user flow as mermaid. Prefer concrete page names over generic boxes.
5. Build the screen inventory. For each screen, note whether it's new or existing.
6. Identify content/data deltas. Don't design the schema; just say *"new field on Bookmark for ...?"*.
7. List open questions if any.
8. Append the IA section to the convoy file.
9. Print: *"IA pass complete. <N> screens, <M> routes affected. Next role: role-ux-reviewer (or role-architect if UX is skipped)."*
## Hand-off
Message the user. They run the next role.
## Metrics
After appending your IA section, emit one event. Shell access is restricted to this single command.
```bash
bash scripts/log-convoy-event.sh role=role-ia-architect convoy=<slug> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Proposing a schema → wrong, that's Architect.
- Designing components → wrong, that's UX Reviewer + Architect.
- Writing code → wrong.
- Mermaid diagram with >10 nodes → too detailed; this is IA, not implementation.

View file

@ -0,0 +1,99 @@
---
name: role-implementer
description: >-
Builds one PR worth of code from one architect brief. Strictly scoped to the
files listed in the brief; never widens scope. Writes code, writes tests,
runs lint, and proposes the PR (does not open it). Use after the architect's
plan is approved by human gate 1, once per brief. Multiple implementers can
run as a Cursor 3.2 /multitask fleet IFF their briefs declare empty
depends_on AND disjoint files: lists; each implementer gets its own worktree.
multitask: per-brief
tools: [Read, Grep, Glob, Edit, Write, Shell]
---
# Role: Implementer
## Trigger
User runs this role and references a specific brief: *"Run implementer on `.convoys/<slug>/brief-<N>-...md`"*. Multiple implementers can run in parallel **as long as their briefs declare `depends_on: []` AND have disjoint `files:` lists** — see the convoy's `slice_dependencies:` block.
Preferred parallel-dispatch path on Cursor 3.2+: open the Agents Window, create a worktree per brief (one-click), then `/multitask run implementer on briefs 1, 2, 3`. Cursor isolates each subagent in its own worktree automatically. See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern B.
## Inputs
- Exactly one brief file (`.convoys/<slug>/brief-<N>-...md`).
- The convoy's IA / UX / Architecture sections (read once for context).
- AGENTS.md and matching `.cursor/rules/*.mdc`.
- Existing example files cited in the brief.
## Outputs
1. Code changes to **only** the files listed in the brief's `files:` frontmatter.
2. Tests added per the brief's acceptance criteria.
3. A PR draft posted to chat (not opened on GitHub).
## Steps
1. Read the brief in full. Confirm understanding of scope.
2. Read the convoy file's IA / UX / Architecture sections (one Read each).
3. Read each file in the brief's `files:` list (existing files only — new files have no content yet).
4. Read 1-2 example files cited in the brief.
5. Make the edits. Stay strictly inside `files:`.
6. Write the tests.
7. Run lint: `npm run lint` (or repo equivalent — check `package.json` scripts).
8. Run tests: `npm test` (or repo equivalent).
9. If lint or tests fail, fix and re-run. Three attempts max; if still failing, stop and report.
10. Produce a PR draft for the user:
```markdown
## PR draft: <brief title>
<!-- pipeline: brief=<N>, convoy=<slug> -->
### Summary
- 2-3 bullets on what changed and why
### Files changed
- (list)
### Acceptance criteria
- [x] ...
- [x] tests added (link to test files)
- [x] no scope expansion
### Test plan
- ...
### Notes
- Anything the reviewer should know
```
User copies the PR draft into the GitHub PR creation flow.
## Hard rules
- **Never edit files outside the brief's `files:` list.** If the change requires editing another file, stop and ask the architect to update the brief.
- **Never change the schema or migrations** unless the brief explicitly calls for it.
- **Never disable tests** to make them pass. Fix the test or fix the code.
- **Never bypass auth, validation, or error helpers** to ship faster. Use the conventions in the rules.
## Hand-off
The user reviews the PR draft, opens the PR via `gh` or Cursor's UI. Reviewer + auditors run on the open PR.
## Metrics
After producing the PR draft, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-implementer convoy=<slug> brief=<N> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Quietly editing a file not in `files:` because it "needed it" → forbidden, escalate to architect instead.
- Skipping tests because "it's obvious" → wrong.
- Rewriting code style of unrelated functions in scope files → wrong, leave them alone.
- Opening the PR yourself via `gh` → wrong, stop at PR draft.

View file

@ -0,0 +1,101 @@
---
name: role-reviewer
description: >-
Self-review pass on a PR before requesting human review. Compares the diff
against the architect's brief, checks convention compliance, flags scope
expansion, security concerns, regression risk, and test coverage gaps.
Read-only. Outputs a structured PR comment. Use after the implementer's
PR draft and before the human merges. Safe to run in parallel with
role-design-system-auditor + role-a11y-auditor via Cursor 3.2 /multitask.
multitask: audit-fanout
tools: [Read, Grep, Glob, Shell]
---
# Role: Reviewer
## Trigger
After `role-implementer` produces a PR draft, OR on any open PR when the user says *"run reviewer on PR #N"* or *"review this diff"*.
## Inputs
- The PR diff (via `git diff` or `gh pr diff <N>`).
- The architect brief the implementer worked from (`.convoys/<slug>/brief-<N>-...md`).
- AGENTS.md and matching rules.
## Outputs
A single Markdown comment ready to paste into the PR (or to the user). Use this exact format so the PR Health rollup CI job can parse it:
```markdown
## Reviewer Report
| Check | Status | Notes |
| --- | --- | --- |
| Scope match | ✅ / ⚠️ / ❌ | |
| Conventions | ✅ / ⚠️ / ❌ | |
| Security | ✅ / ⚠️ / ❌ | |
| Regression risk | low / medium / high | |
| Test coverage | ✅ / ⚠️ / ❌ | |
| Documentation | ✅ / ⚠️ / ❌ | |
### Findings
- 🔴 **Critical** (must fix before merge): ...
- 🟡 **Suggestion** (consider): ...
- 🟢 **Nice to have** (optional): ...
### Approval recommendation
- approve / request-changes / comment-only
```
## Steps
1. Read the brief. Note the `files:` list and acceptance criteria.
2. Get the diff. Compare files-changed against `files:` — flag any expansion.
3. For each acceptance criterion, search the diff for evidence it's satisfied.
4. Check conventions against AGENTS.md and matching rules. Common gotchas:
- Auth/error helpers used vs. ad-hoc `NextResponse.json({ error: ... }, { status: ... })`
- Zod validation used for any new request body
- Prisma `select`/`include` not over-fetching
- Multi-tenant scoping if applicable (see `.cursor/rules/auth-tenancy.mdc` if present)
5. Security pass: any new endpoint without `requireAuth` / `requireAdmin`? Any user input flowing into a query without validation? Any secret in code?
6. Regression risk: does this change a function with many callers? Use `Grep -r "<function name>"` to estimate blast radius.
7. Test coverage: did the implementer add tests per the brief? Are they testing behavior or implementation?
8. Documentation: AGENTS.md or rule needs updating? Changelog entry needed under `[Unreleased]`?
9. Write the structured comment.
## Severity guidance
- 🔴 **Critical** is reserved for: security holes, broken builds, scope expansions outside the brief, missing auth on protected routes, breaking schema changes without migration.
- 🟡 **Suggestion** is for: convention drift, missing edge cases, unclear naming, over-fetching, missing test for a non-trivial path.
- 🟢 **Nice to have** is for: stylistic preferences, optional refactors, doc nits.
If you're tempted to mark something Critical and you're not sure, downgrade to Suggestion. The reviewer's credibility comes from sparing use of red.
## Hand-off
User reads the report. If approve → human gate 2 (merge). If request-changes → user re-runs implementer with the findings.
## Multitask (audit fan-out)
This role is part of the **audit fan-out cohort** (reviewer + design-system-auditor + a11y-auditor). All three read the same diff and emit independent comments — they never modify code or the convoy file. Safe to run in parallel via Cursor 3.2 `/multitask`.
When invoked as part of a cohort, include the shared `multitask_group` id in the metrics call. The id convention is `audit-<convoy>-<pr>` (e.g. `audit-bookmark-badge-PR123`). See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern A.
## Metrics
After publishing the review comment, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-reviewer convoy=<slug> brief=<N> duration_s=<seconds> [multitask_group=audit-<convoy>-<pr>]
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Suggestions list of 20 nits → noise; max 5 actionable items.
- Approving a PR with scope expansion → wrong, that's a Critical.
- Re-running implementation work yourself → wrong, request changes and let implementer fix.
- Inventing acceptance criteria not in the brief → wrong, the brief is the contract.

View file

@ -0,0 +1,68 @@
---
name: role-ux-reviewer
description: >-
UX / IX review pass against the existing design system. Identifies which
existing components and patterns to reuse, calls out anti-patterns to avoid,
and lists a11y constraints that must be satisfied. Read-only. Use after
IA Architect on any feature with UI changes. Must run sequentially — refines
the IA section, feeds role-architect.
multitask: single
tools: [Read, Grep, Glob, Shell]
---
# Role: UX Reviewer
## Trigger
After `role-ia-architect` for any classification that includes UI work. Skip when convoy frontmatter has `skip: ux`.
## Inputs
- The convoy file (with the IA section appended by the previous role).
- Existing UI primitives directory (typically `components/ui/` or `src/components/ui/`).
- Design tokens (typically `tailwind.config.ts`, `app/globals.css` CSS variables).
- Any rule scoped to `components.mdc`, `styling.mdc`, or `design-system.mdc`.
## Outputs
Append a `## UX` section to the convoy file with:
1. **Existing components to reuse** — bullet list of `<ComponentName>` (`path/to/file.tsx`) for each reusable primitive the screens need. Be specific — name the file.
2. **Existing patterns to follow** — referenced rules and example screens that solve a similar problem (e.g. *"PostCard.tsx is the canonical card pattern; use the same Badge primitive there"*).
3. **A11y constraints** — bullets enumerating: required ARIA labels, keyboard navigation paths, focus management, color-contrast requirements specific to this change.
4. **Interaction patterns** — short list: hover/focus/active states, optimistic UI, error states, empty states, loading states. Mark each as `required` or `nice-to-have`.
5. **Anti-patterns to avoid** — explicit list of what NOT to do (e.g. *"Don't add a new color outside the design tokens for the badge background"*).
6. **Mobile / responsive notes** — if the change has UI, this section is mandatory. If headless/server-only, note that.
## Steps
1. Read the convoy file. Find the IA section.
2. For each screen in the IA inventory:
- `Glob` for relevant existing components in `components/ui/` (or equivalent).
- Identify the closest existing pattern by reading 1-3 example files.
3. Read the design tokens once (one Read of `tailwind.config.ts` or `app/globals.css`).
4. Author the UX section. Be opinionated. Pick one pattern, not three options.
5. Call out a11y requirements explicitly — don't say *"follow a11y best practices"*; say *"requires aria-label on the toggle button when collapsed"*.
6. Append section to convoy file.
7. Print: *"UX pass complete. Reuse: <N> primitives. A11y constraints: <M>. Next role: role-architect."*
## Hand-off
Message the user.
## Metrics
After appending your UX section, emit one event. Shell access is restricted to this single command.
```bash
bash scripts/log-convoy-event.sh role=role-ux-reviewer convoy=<slug> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Suggesting new components when an existing one fits → wrong, this role's job is reuse.
- Vague a11y guidance ("follow WCAG") → wrong, list specific requirements.
- Three alternatives — pick one → wrong, pick one with reasoning.
- Designing the schema or API → wrong, that's Architect.

View file

@ -0,0 +1,98 @@
---
description: Conventions for Next.js App Router API route handlers in this repo
globs: src/app/api/**/route.ts
---
# API Route Conventions
Every `src/app/api/**/route.ts` follows the same shape: auth → parse body → query Prisma scoped to `orgId` → return JSON, all wrapped in `try / handleApiError`.
## Authentication & Authorization
- Import from `@/lib/api-auth`:
```ts
import { NextRequest, NextResponse } from "next/server";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
// session.user.id, session.user.orgId, session.user.role
// ...
} catch (error) {
return handleApiError(error);
}
}
```
- `requireApiAuthWithOrg()` returns an `OrgSession` (extends NextAuth `Session` with `user.id` and `user.orgId`) — both are always defined after this call.
- Pass an optional `Action` to enforce a permission in one line: `await requireApiAuthWithOrg("cards.delete")`. Throws `PermissionError`; `handleApiError` returns 403.
- For routes that don't need org scoping (rare — typically auth callbacks), use `requireApiAuth()` instead.
## Multi-tenancy is mandatory
Every Prisma query against an org-scoped model (`ResponseCard`, `FormTemplate`, `Person`, `Integration`, …) MUST scope by `organizationId`:
```ts
const card = await prisma.responseCard.findUnique({ where: { id } });
if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
```
For lists: include `organizationId: session.user.orgId` in the `where` filter directly. Returning a 404 (not 403) on cross-org access is the convention so we don't leak existence.
## Request validation
- Body parsing is hand-rolled today. Use the defensive pattern from `src/app/api/cards/[id]/route.ts`:
```ts
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
const stringFields = ["name", "email", /* ... */];
for (const field of stringFields) {
if (body[field] != null) data[field] = String(body[field]);
}
```
- Zod is in `package.json` but not widely used. If you reach for it in a new route, that's fine — just stay consistent within the route.
## Error handling
- Wrap every handler in `try { ... } catch (error) { return handleApiError(error); }`. Never throw to the framework.
- `handleApiError` returns 401 for `ApiAuthError`, 403 for `PermissionError`, 500 (with `console.error`) for anything else.
- For domain errors that aren't auth/permission, return `NextResponse.json({ error: "..." }, { status: 4xx })` directly — don't invent new error classes for one-off cases.
## Database access
- Always `import { prisma } from "@/lib/db";` — the lazy `Proxy` singleton. Never `new PrismaClient()`.
- Use `select` or `include` only when you need it; the default fetch is fine for small models.
- For writes, prefer `update`/`create` over `upsert` unless you actually need both paths.
## Response shape
- Success collections: `NextResponse.json({ items, total, page, limit })` (see `src/app/api/cards/route.ts` GET).
- Success single: `NextResponse.json(record)` (no envelope).
- Created: `NextResponse.json(record, { status: 201 })`.
- Errors: `{ error: string, action?: string }` — `handleApiError` already does this.
## Dynamic routes
Next.js 16 dynamic route params are async. Use:
```ts
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// ...
}
```
## After adding / changing a route
- Update the route table in `README.md` if it's a public-shape change.
- If the route writes to `ResponseCard.firstName` or `lastName`, recompute `name` (see the canonical recompute block in `src/app/api/cards/[id]/route.ts`).

View file

@ -0,0 +1,39 @@
---
description: Files and directories agents must not edit, and should not use as context examples
alwaysApply: true
---
# No-go zones
Do not edit, refactor, or quote as context examples. If you think you need to change one of these, stop and ask.
## Generated / vendored
- `node_modules/` — generated dependency tree
- `.next/` — Next.js build output
- `src/generated/` — Prisma client output (regenerate with `npx prisma generate`)
- `*.tsbuildinfo` — TS incremental cache
- `next-env.d.ts` — Next.js generated types
- `tsconfig.tsbuildinfo`
## Append-only / historical
- `prisma/migrations/` — append-only; create **new** migrations via `npx prisma migrate dev --name <descriptive_name>`, never edit existing ones
- `echos_ocr_backup.dump`, `echos_ocr_prod_backup.dump` — database backups, never edit
## Secrets / credentials
- `.env`, `.env.local`, `.env.*.local` — runtime secrets
- Any `*-credentials.json`, `*-service-account-key.json`, `*.pem`, `*.key`
## Local-only / per-developer
- `.vercel/` — local Vercel CLI state
- `.cursor/mcp.json` — personal MCP config (if it appears, do not commit)
## Editing rules of thumb
- New Prisma migrations only: `npx prisma migrate dev --name <descriptive_name>`. Never hand-edit a previous migration's SQL.
- Regenerate the Prisma client after schema changes (`npx prisma generate`) instead of touching `src/generated/`.
- `ResponseCard.name` is denormalized — when writing new code that mutates `firstName` or `lastName`, recompute `name` (see the PUT route in `src/app/api/cards/[id]/route.ts` for the canonical pattern).
- One-off data migrations / backfills go in `scripts/`, not in API routes.

View file

@ -0,0 +1,35 @@
---
description: High-level map of Prisma model groups and where each is used
globs: prisma/**,src/app/api/**/*.ts,src/lib/**/*.ts,scripts/**/*.ts
---
# Prisma Schema Map
Full generated reference: [docs/SCHEMA_MAP.md](../../docs/SCHEMA_MAP.md). Source of truth: [prisma/schema.prisma](../../prisma/schema.prisma) (21 models, 0 enums). Regenerate with `npm run schema:map` after schema changes.
## Model groups (where to look first)
| Group | Anchor models | Where used |
| --- | --- | --- |
| **Auth & Users** | `User`, `Account`, `Session`, `VerificationToken`, `PasswordResetToken` | `src/auth.ts`, `src/app/api/auth/**`, `src/app/(auth)/**` |
| **Org & Membership** | `Organization`, `OrgMember`, `Invitation`, `ApiKey` | `src/lib/api-auth.ts`, `src/app/api/org/**`, `src/app/api/users/**` |
| **Locations & Events** | `Location`, `CollectionDay` | `src/app/(dashboard)/events/**`, `src/app/api/locations/**`, `src/app/api/events/**` |
| **Form Templates** | `FormTemplate`, `FormField` | `src/lib/form-templates.ts`, `src/app/api/form-templates/**`, `src/components/cards/dynamic-field.tsx` |
| **Cards & OCR** | `ResponseCard`, `ProcessingJob` | `src/lib/ocr.ts`, `src/app/api/cards/**`, `src/components/cards/**`, `src/app/(dashboard)/cards/**` |
| **People (CRM)** | `Person` | `src/lib/person-linker.ts`, `src/app/api/people/**`, `src/app/(dashboard)/people/**` |
| **Integrations** | `Integration` | `src/lib/integrations.ts`, `src/lib/integrations/providers/**`, `src/app/api/integrations/**` |
| **Activity & Notifications** | `ActivityLog`, `Notification` | `src/lib/activity-log.ts`, `src/lib/notifications.ts`, `src/app/api/notifications/**` |
| **Settings** | `AppSettings`, `SystemConfig` | `src/app/api/settings/route.ts`, `src/app/(dashboard)/settings/**` |
## Where to look first
- **Adding a model:** edit `prisma/schema.prisma`, run `npx prisma generate`, then `npx prisma migrate dev --name ...`, then `npm run schema:map` to refresh the doc. Add it to the right `MODEL_GROUPS` bucket in `scripts/generate-schema-map.ts` (or let it land in "Other" as a signal you need to categorize).
- **Querying a model:** prefer existing API routes in `src/app/api/<group>/` over rolling new Prisma calls; check `src/lib/` for shared query helpers (`person-linker.ts`, `auto-assign.ts`, `activity-log.ts`).
- **Cross-group lookups:** `ResponseCard` is the hub — it links to `Organization`, `User` (assignedTo / assignedBy / reviewedBy), `FormTemplate`, and `Person`. Query through it with selective `include` / `select`; never `include: { everything }`.
- **Multi-tenancy:** every business model has `organizationId` — see `prisma.mdc` for the mandatory scoping pattern.
## Heuristics for unfamiliar models
- Tables use Prisma's default PascalCase naming (no `@@map`).
- High relation counts (>5) usually indicate a hub model; `ResponseCard` and `User` are the main hubs. Query through these with `include`/`select` selectively.
- A model in the "Other" group in the generated map means somebody added a model but didn't update `MODEL_GROUPS` — promote it to the right group.

74
.cursor/rules/prisma.mdc Normal file
View file

@ -0,0 +1,74 @@
---
description: Prisma schema and database access conventions
globs: prisma/**,src/app/api/**/*.ts,src/lib/**/*.ts,scripts/**/*.ts
---
# Prisma Conventions
## Client import
Always import the singleton:
```ts
import { prisma } from "@/lib/db";
```
`src/lib/db.ts` exposes a lazy `Proxy` over `PrismaClient` — the underlying client (and Postgres pool) is only constructed on first property access. Never `new PrismaClient()` outside of `src/lib/db.ts`. Don't call `prisma` from top-level module code; the lazy proxy depends on `DATABASE_URL` being set at access time, not import time.
## Generated client path
The Prisma client is generated to `src/generated/prisma/` (custom `output` in `prisma/schema.prisma`), not `@prisma/client`. To import generated types or enums:
```ts
import { Prisma, PrismaClient } from "@/generated/prisma/client";
```
`src/generated/` is a no-go zone — regenerate via `npx prisma generate` instead of editing.
## Schema patterns (what this repo actually does)
- **IDs:** `String @id @default(cuid())`.
- **Timestamps:** `createdAt DateTime @default(now())` + `updatedAt DateTime @updatedAt`.
- **Multi-tenancy:** every business model has `organizationId String` + `@@index([organizationId])`. Auth tables (`User`, `Account`, `Session`) and the `Organization` itself are the exceptions.
- **JSON columns:** `Json?` is used for `ResponseCard.fieldData`, `ResponseCard.rawOcrResponse`, `Integration.config`, etc. Treat the shape as untrusted on read.
- **Indexes:** `@@index` on FKs and the columns we sort/filter by. Add an index when you add a `where:` filter.
- **No `@@map`:** Prisma defaults to PascalCase tables here; do not add `@@map(...)` to existing models.
## Multi-tenant scoping (mandatory)
Every read or write against a business model MUST scope by `organizationId`:
```ts
// Read
const items = await prisma.responseCard.findMany({
where: { organizationId: session.user.orgId, /* ... */ },
});
// Create
await prisma.responseCard.create({
data: { organizationId: session.user.orgId, /* ... */ },
});
```
Cross-org leakage is a security bug. Returning a `404` (not `403`) on accidental cross-org IDs is the convention so we don't reveal existence.
## After schema changes
```bash
npx prisma generate # regenerate client at src/generated/prisma/
npx prisma migrate dev --name descriptive_name # create + apply a migration locally
npm run schema:map # refresh docs/SCHEMA_MAP.md (see prisma-schema-map.mdc)
```
For prototype-stage edits (no DB structure change you intend to keep), `npm run db:push` is also available.
## Query best practices
- `findUnique` for ID / unique lookups; `findFirst` when filtering by org.
- Use `select` when you only need a few fields and the model has heavy relations.
- Wrap multi-step writes in `prisma.$transaction([...])` or `prisma.$transaction(async (tx) => ...)`.
- Background jobs go through `ProcessingJob` — see `src/lib/ocr.ts` for the canonical pattern.
## Denormalized fields
`ResponseCard.name` is a denormalized display string derived from `firstName + lastName`. Three places set it: OCR pipeline (`src/lib/ocr.ts`), the public survey submit handler (`src/app/api/survey/submit/route.ts`), and the PUT route (`src/app/api/cards/[id]/route.ts`, which recomputes on any first/last change). When you write a new path that mutates `firstName` or `lastName`, recompute `name` too — or call into the PUT path so it does it for you.

View file

@ -0,0 +1,156 @@
---
name: add-api-route
description: Add a new Next.js App Router API route in src/app/api/**/route.ts following the repo's auth + multi-tenancy + error-handling conventions. Use when the user asks to add an endpoint, route, handler, GET/POST/PUT/DELETE, /api/X, or a server action backed by Prisma.
---
# Add a new API route
Every API route in this repo is a `src/app/api/<segments>/route.ts` file that exports `GET` / `POST` / `PUT` / `DELETE`. They all follow the same shape: auth check → org-scoped Prisma access → JSON response, wrapped in `try / handleApiError`.
## Recipe
### 1. Pick the path
App Router maps directories to URL segments. Dynamic segments use `[param]`:
| Want | File |
| --- | --- |
| `GET /api/widgets` | `src/app/api/widgets/route.ts` |
| `GET /api/widgets/[id]` | `src/app/api/widgets/[id]/route.ts` |
| `POST /api/widgets/[id]/archive` | `src/app/api/widgets/[id]/archive/route.ts` |
### 2. Start from this template
```ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
const [items, total] = await Promise.all([
prisma.widget.findMany({
where: { organizationId: session.user.orgId },
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
}),
prisma.widget.count({ where: { organizationId: session.user.orgId } }),
]);
return NextResponse.json({ items, total, page, limit });
} catch (error) {
return handleApiError(error);
}
}
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg("widgets.create");
const body = await request.json().catch(() => ({}));
if (!body.name) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
const widget = await prisma.widget.create({
data: {
organizationId: session.user.orgId,
name: String(body.name),
},
});
return NextResponse.json(widget, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}
```
### 3. Dynamic route params are async in Next.js 16
```ts
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await params;
const widget = await prisma.widget.findUnique({ where: { id } });
if (!widget || widget.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Widget not found" }, { status: 404 });
}
return NextResponse.json(widget);
} catch (error) {
return handleApiError(error);
}
}
```
Cross-org returns 404, not 403 (don't reveal existence).
### 4. Permission gating
Two ways to enforce a permission:
```ts
// Inline in the auth call — throws PermissionError → handleApiError returns 403
const session = await requireApiAuthWithOrg("cards.delete");
```
```ts
// Or check explicitly when the policy is fancier
import { can } from "@/lib/permissions";
const session = await requireApiAuthWithOrg();
if (!can(session.user.role, "cards.edit") && card.assignedToId !== session.user.id) {
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
}
```
`Action` is a union type in `src/lib/permissions.ts` — TypeScript will autocomplete the valid actions. Add a new action there if you need one.
### 5. Body handling
Hand-rolled is the prevailing style. Use the defensive pattern:
```ts
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
for (const field of ["name", "description"]) {
if (body[field] != null) data[field] = String(body[field]);
}
if (body.published != null) data.published = Boolean(body.published);
```
For complex shapes, Zod is in `package.json` — using it in a new route is fine, just stay consistent within the file.
### 6. Updating denormalized `ResponseCard.name`
If your route mutates `ResponseCard.firstName` or `lastName`, you MUST recompute `name`. The canonical block lives in `src/app/api/cards/[id]/route.ts` — copy it:
```ts
if (("firstName" in data || "lastName" in data) && !("name" in data)) {
const nextFirst = "firstName" in data ? (data.firstName as string | null) : card.firstName;
const nextLast = "lastName" in data ? (data.lastName as string | null) : card.lastName;
const combined = [nextFirst, nextLast].filter(Boolean).join(" ").trim();
data.name = combined || null;
}
```
### 7. After writing the route
- Verify locally with `curl` or the Network tab.
- If it's a user-visible endpoint, add a row to the API table in `README.md`.
- Run `npm run lint` and `npx tsc --noEmit` before opening the PR.
## Anti-patterns
- Skipping `organizationId` in `where:` filters → cross-org data leak.
- `throw` instead of `return handleApiError(error)` → uncaught error in the framework.
- Returning `403` for cross-org IDs instead of `404` → leaks existence.
- Calling `prisma` from top-level module code → the lazy proxy needs `DATABASE_URL` at access time.
- Adding `"use client"` to a route file → server-only.
- Hand-writing `NextResponse.json({ error }, { status: 500 })` in a catch block → use `handleApiError`.

View file

@ -0,0 +1,113 @@
---
name: add-prisma-model
description: Add a new Prisma model to prisma/schema.prisma following the repo's multi-tenant conventions, then regenerate the client, create a migration, and refresh the schema map. Use when the user asks to add a model, table, entity, schema, or new database object.
---
# Add a new Prisma model
Every business model in this repo is multi-tenant (scoped by `organizationId`), uses `cuid()` IDs, has `createdAt` / `updatedAt`, and gets indexed on its FK columns. The schema map (`docs/SCHEMA_MAP.md`) is a generated artifact — refresh it after schema edits.
## Recipe
### 1. Edit `prisma/schema.prisma`
Add the model near related models (keep the section comment dividers intact). Template for a typical business model:
```prisma
model Widget {
id String @id @default(cuid())
organizationId String
name String
description String?
config Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@index([organizationId, name])
}
```
Then add the back-relation on `Organization`:
```prisma
model Organization {
// ...existing fields...
widgets Widget[]
}
```
If the new model is owned by a user (e.g. a comment), add `userId` + `@relation` + `onDelete: Cascade` (or `SetNull`, depending) and the back-relation on `User`.
Auth tables (`User`, `Account`, `Session`) and `Organization` itself are the only models without `organizationId`.
### 2. Regenerate the Prisma client
```bash
npx prisma generate
```
This rewrites `src/generated/prisma/`. The client is consumed via `import { prisma } from "@/lib/db"` — no other file should change.
### 3. Create a migration
For DB changes you intend to keep:
```bash
npx prisma migrate dev --name add_widget_model
```
For prototype-stage edits (no migration yet):
```bash
npm run db:push
```
Once you're settling on a shape, switch to migrations — `prisma/migrations/` is append-only and authoritative for production.
### 4. Refresh the schema map
```bash
npm run schema:map
```
This regenerates `docs/SCHEMA_MAP.md` from `prisma/schema.prisma`. New models land in the **Other** group by default. To categorize:
1. Open `scripts/generate-schema-map.ts`.
2. Add the model to the right `MODEL_GROUPS` bucket (or add a new bucket).
3. Re-run `npm run schema:map`.
### 5. Wire it into API + UI
- API routes: follow the `add-api-route` skill. Every query MUST scope by `organizationId`.
- Activity log: if the model represents user-meaningful state, fire `logActivity(...)` from `src/lib/activity-log.ts` on create / update / delete.
- Integrations: if the model should propagate to Planning Center / Monday / etc., add an event in `src/lib/integrations.ts` and a handler in the relevant provider under `src/lib/integrations/providers/`.
## Common patterns by model shape
### Soft delete / archive
Don't introduce a `deletedAt` column unless there's a real use case — this repo uses hard delete + `ActivityLog` for audit trail. If you do need soft delete, add `archivedAt DateTime?` (not `deletedAt`) and remember to filter `archivedAt: null` in every list query.
### JSON config column
Lots of models have a `Json?` column (`Integration.config`, `ResponseCard.fieldData`, etc.) — perfect for variable shapes. Treat as untrusted on read; validate / coerce before use. Don't try to query inside JSON with raw SQL.
### Denormalized display field
If you add a model with a denormalized display string (like `ResponseCard.name`), establish the recompute path in the SAME PR — both server-side (any write path) and any UI that reads it. See the cautionary tale in `AGENTS.md` § 4.
### Lookup table for enums
Prisma enums exist but this repo doesn't use any. Convention here: `String` column with documented allowed values + a TypeScript union in `src/lib/<area>.ts`. Stay consistent.
## Anti-patterns
- Forgetting `organizationId` → cross-tenant data leak waiting to happen.
- Hand-editing a previous migration → `prisma/migrations/` is append-only; create a new migration.
- Editing files under `src/generated/prisma/` → regenerate instead.
- Adding `@@map("...")` to a single model → none of the existing models use it; keep the schema consistent.
- Skipping `npm run schema:map` after adding the model → `docs/SCHEMA_MAP.md` drifts immediately.
- Adding a back-relation on `Organization` without thinking about cascade behavior → `onDelete: Cascade` is usually right; `SetNull` is sometimes right; `Restrict` almost never.

View file

@ -34,6 +34,11 @@ BREVO_API_KEY=""
EMAIL_FROM_NAME="Echo OCR"
EMAIL_FROM_ADDRESS="mars@noreply.stillwell.cloud"
# ─── Support ticketing (Libredesk) ────────────────────────────
# Inbox address that Libredesk ingests as tickets.
# The in-app Contact Support dialog sends tickets here via Brevo.
SUPPORT_EMAIL="support@stillwell.cloud"
# ─── Vercel Cron Secret ────────────────────────────────────────
# Vercel auto-sets this on Pro. Used to authenticate cron job requests.
CRON_SECRET=""

30
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1,30 @@
# CODEOWNERS — review routing for the agent pipeline.
#
# REPLACE @varutasu below with your actual GitHub handle (or a team
# handle like @org/team-name) before merging this file. Until you do, GitHub
# will show "unknown owner" warnings on PRs but won't block them.
#
# Pattern: high-risk paths require explicit human review; others are advisory.
# Global default — every PR notifies these reviewers
* @varutasu
# High-risk: schema, migrations, auth, money — require review
prisma/schema.prisma @varutasu
prisma/migrations/** @varutasu
auth.ts @varutasu
lib/auth-options.ts @varutasu
lib/auth/** @varutasu
middleware.ts @varutasu
lib/flags/** @varutasu
# CI / infra — require review
.github/workflows/** @varutasu
cloudbuild*.yaml @varutasu
Dockerfile* @varutasu
next.config.* @varutasu
# Agent context — owner should approve changes here so the pipeline stays consistent
.cursor/agents/** @varutasu
.cursor/rules/** @varutasu
AGENTS.md @varutasu

44
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,44 @@
<!--
pipeline: convoy=<slug>, brief=<N>
skip: <comma-separated flags or empty>
Skip flags (Conductor sets these — do not edit by hand):
ia, ux, arch, test, review, visual, a11y, design, smoke, qa, docs, flag
Never skip: plan-approval, pr-merge, prod-promote
-->
## Summary
<!-- 2-3 bullets: what changed and why. User-facing language preferred. -->
## Convoy + Brief
- Convoy: `.convoys/<slug>.md`
- Brief: `.convoys/<slug>/brief-<N>-...md`
## Acceptance criteria
<!-- Copy from the brief; check off as you complete. -->
- [ ]
- [ ]
- [ ] No scope expansion (only files listed in the brief's `files:` were edited)
## Test plan
<!-- What was tested, how, and what wasn't tested with rationale. -->
## Pipeline gates
<!-- Filled in by CI / role-reviewer. Don't edit. -->
- [ ] CI: lint, types, build, unit tests
- [ ] Visual diff (if UI change)
- [ ] A11y audit (if UI change)
- [ ] Design-system audit (if UI change)
- [ ] Reviewer report
- [ ] Smoke on staging (after merge to develop)
## Notes for reviewer
<!-- Anything unusual, intentional trade-offs, or follow-ups. -->

98
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,98 @@
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: '20'
jobs:
lint-types-build:
name: Lint, types, build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- run: npm run lint
- name: Type check
run: npx tsc --noEmit
- name: Build
run: npm run build
env:
# Build-time env. Real values come from Cloud Run / Vercel — set non-empty defaults
# here so the build doesn't crash on missing required vars.
DATABASE_URL: postgresql://ci:ci@localhost:5432/ci
NEXTAUTH_SECRET: ci-secret-only-for-build
NEXTAUTH_URL: http://localhost:3000
# NOTE: echos-ocr has no test runner configured yet (no vitest / jest /
# playwright in package.json). Re-enable this job once a test runner is
# adopted and a `test:run` script exists in package.json. Until then,
# this block stays commented so CI doesn't fail on a missing script.
#
# test:
# name: Unit + integration tests
# runs-on: ubuntu-latest
# services:
# postgres:
# image: postgres:16
# env:
# POSTGRES_USER: ci
# POSTGRES_PASSWORD: ci
# POSTGRES_DB: ci
# ports: ['5432:5432']
# options: >-
# --health-cmd "pg_isready -U ci"
# --health-interval 5s
# --health-timeout 5s
# --health-retries 10
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: ${{ env.NODE_VERSION }}
# cache: npm
# - run: npm ci
# - run: npx prisma generate
# - name: Run migrations
# run: npx prisma migrate deploy
# env:
# DATABASE_URL: postgresql://ci:ci@localhost:5432/ci
# - run: npm run test:run
# env:
# DATABASE_URL: postgresql://ci:ci@localhost:5432/ci
# NEXTAUTH_SECRET: ci-secret-only-for-tests
# NEXTAUTH_URL: http://localhost:3000
schema-map-fresh:
name: Schema map up to date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- name: Regenerate schema map
run: npm run schema:map
- name: Verify no drift
run: |
if ! git diff --quiet docs/SCHEMA_MAP.md; then
echo "::error::docs/SCHEMA_MAP.md is out of date. Run 'npm run schema:map' and commit."
git diff docs/SCHEMA_MAP.md
exit 1
fi

93
.github/workflows/pr-health-rollup.yml vendored Normal file
View file

@ -0,0 +1,93 @@
name: PR Health rollup
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled, unlabeled]
workflow_run:
workflows: [CI, Preview smoke, Visual diff]
types: [completed]
permissions:
pull-requests: write
issues: write
checks: read
jobs:
rollup:
name: Aggregate gate status
runs-on: ubuntu-latest
steps:
- name: Compute status + post sticky comment
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const pr_number = context.payload.pull_request?.number
?? context.payload.workflow_run?.pull_requests?.[0]?.number;
if (!pr_number) {
core.info('No PR context — skipping rollup.');
return;
}
const pr = (await github.rest.pulls.get({ owner, repo, pull_number: pr_number })).data;
const sha = pr.head.sha;
const checks = (await github.rest.checks.listForRef({ owner, repo, ref: sha, per_page: 100 })).data.check_runs;
const find = (name) => checks.find(c => c.name === name);
const skip = (flag) =>
new RegExp(`pipeline:.*skip[^\\n]*\\b${flag}\\b`).test(pr.body || '');
const row = (label, run, opt = false) => {
if (!run) return `| ${label} | ${opt ? '⏭ skipped or pending' : '⏳ pending'} |`;
if (run.status !== 'completed') return `| ${label} | ⏳ in progress |`;
const ok = run.conclusion === 'success';
return `| ${label} | ${ok ? '✅ pass' : '❌ ' + run.conclusion} |`;
};
const rows = [
row('CI: Lint, types, build', find('Lint, types, build')),
// CI: Tests row omitted — echos-ocr has no test runner yet.
// Re-enable once a `test:run` script lands in package.json and
// the `test:` job in ci.yml is uncommented.
row('CI: Schema map fresh', find('Schema map up to date')),
skip('smoke') ? '| Preview smoke | ⏭ skipped (pipeline directive) |' : row('Preview smoke', find('Playwright smoke'), true),
skip('visual') ? '| Visual diff | ⏭ skipped (pipeline directive) |' : row('Visual diff', find('Screenshot diff'), true),
];
const reviewer_comment = (await github.rest.issues.listComments({
owner, repo, issue_number: pr_number, per_page: 100,
})).data.find(c => c.body?.startsWith('## Reviewer Report'));
const a11y_comment = (await github.rest.issues.listComments({
owner, repo, issue_number: pr_number, per_page: 100,
})).data.find(c => c.body?.startsWith('## A11y Audit'));
const ds_comment = (await github.rest.issues.listComments({
owner, repo, issue_number: pr_number, per_page: 100,
})).data.find(c => c.body?.startsWith('## Design System Audit'));
const role_row = (label, c, skipped) =>
skipped ? `| ${label} | ⏭ skipped |` : c ? `| ${label} | ✅ posted |` : `| ${label} | ⏳ pending |`;
const role_rows = [
role_row('Reviewer report', reviewer_comment, skip('review')),
role_row('A11y audit', a11y_comment, skip('a11y')),
role_row('Design system audit', ds_comment, skip('design')),
];
const marker = '<!-- pipeline-rollup -->';
const body = `${marker}\n## Pipeline Health\n\n### CI gates\n\n| Gate | Status |\n| --- | --- |\n${rows.join('\n')}\n\n### Role reports\n\n| Role | Status |\n| --- | --- |\n${role_rows.join('\n')}\n\nSee individual comments above for details. This rollup updates automatically.`;
const comments = (await github.rest.issues.listComments({
owner, repo, issue_number: pr_number, per_page: 100,
})).data;
const existing = comments.find(c => c.body?.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pr_number, body });
}

79
.github/workflows/preview-smoke.yml vendored Normal file
View file

@ -0,0 +1,79 @@
name: Preview smoke
on:
pull_request:
branches: [main]
types: [labeled, opened, synchronize, reopened]
# Only run when:
# - The PR has the 'preview-ready' label, OR
# - The PR body / commits do not contain 'pipeline:.*skip.*smoke'
#
# Skip semantics: convoy-classified docs/infra/config-only PRs add 'skip: smoke'
# to the body and this job no-ops via the gate step below.
concurrency:
group: preview-smoke-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
gate:
name: Should run?
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Decide
id: check
run: |
if echo "${{ github.event.pull_request.body }}" | grep -qE 'pipeline:.*skip.*\bsmoke\b'; then
echo "should_run=false" >> $GITHUB_OUTPUT
echo "::notice::Smoke skipped via pipeline directive"
else
echo "should_run=true" >> $GITHUB_OUTPUT
fi
smoke:
name: Playwright smoke
needs: gate
if: needs.gate.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Set this in repo secrets/vars to your preview URL pattern, e.g.
# https://pr-${{ github.event.pull_request.number }}.preview.example.com
PREVIEW_URL: ${{ vars.PREVIEW_URL_PATTERN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Wait for preview to respond
run: |
PREVIEW="${PREVIEW_URL//\$\{PR\}/${{ github.event.pull_request.number }}}"
for i in {1..30}; do
if curl -fsS "$PREVIEW" > /dev/null; then
echo "Preview ready at $PREVIEW"
echo "PREVIEW_RESOLVED=$PREVIEW" >> $GITHUB_ENV
exit 0
fi
echo "Waiting for preview ($i/30)..."
sleep 10
done
echo "::error::Preview never responded at $PREVIEW"
exit 1
- name: Run smoke tests
run: npx playwright test --project=smoke
env:
BASE_URL: ${{ env.PREVIEW_RESOLVED }}
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7

76
.github/workflows/visual-diff.yml vendored Normal file
View file

@ -0,0 +1,76 @@
name: Visual diff
on:
pull_request:
branches: [main]
paths:
- 'src/app/**'
- 'src/components/**'
- 'src/app/globals.css'
- 'postcss.config.*'
# Skip when PR body contains 'pipeline: ... skip ... visual'
concurrency:
group: visual-diff-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
gate:
name: Should run?
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: check
run: |
if echo "${{ github.event.pull_request.body }}" | grep -qE 'pipeline:.*skip.*\bvisual\b'; then
echo "should_run=false" >> $GITHUB_OUTPUT
echo "::notice::Visual diff skipped via pipeline directive"
else
echo "should_run=true" >> $GITHUB_OUTPUT
fi
visual:
name: Screenshot diff
needs: gate
if: needs.gate.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
env:
PREVIEW_URL: ${{ vars.PREVIEW_URL_PATTERN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Resolve preview URL
run: echo "PREVIEW_RESOLVED=${PREVIEW_URL//\$\{PR\}/${{ github.event.pull_request.number }}}" >> $GITHUB_ENV
- name: Capture screenshots (PR)
run: npx playwright test --project=visual --update-snapshots=none
env:
BASE_URL: ${{ env.PREVIEW_RESOLVED }}
continue-on-error: true
- name: Upload screenshots + diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-diff
path: |
tests/visual/__screenshots__/
test-results/
retention-days: 7
- name: Comment on PR with diff link
if: always()
uses: actions/github-script@v7
with:
script: |
const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Visual Diff\n\nScreenshots and diffs uploaded as artifacts: [view run](${run})\n\nIf intentional changes: update snapshots locally with \`npx playwright test --project=visual --update-snapshots\` and commit.`
});

3
.gitignore vendored
View file

@ -46,3 +46,6 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
# convoy self-analytics (local-only; remove this line to opt-in to commits)
.convoys/.metrics.jsonl

68
AGENTS.md Normal file
View file

@ -0,0 +1,68 @@
# AGENTS.md — AI collaboration (Echo OCR)
Guidance for agents and humans working in this repo. Prefer existing patterns over new abstractions.
## 1. Project overview
Echo OCR is a Next.js + Prisma app for Echo Life Church. It ingests paper "connect cards" via OCR (scanned PDFs / images / public survey submissions), extracts structured data, stores it in PostgreSQL behind a multi-tenant org model, and serves a filterable review UI with integrations to Planning Center, Monday.com, Airtable, and webhooks.
- **Framework:** Next.js 16 (App Router) + React 19, TypeScript strict
- **Data:** Prisma 7 (custom client output at `src/generated/prisma/`) + PostgreSQL via `pg` + `@prisma/adapter-pg`
- **Auth:** NextAuth 5 beta — session in `src/auth.ts`; server-side `auth()` wrapped by `requireApiAuthWithOrg` in `src/lib/api-auth.ts`
- **UI:** Tailwind v4 + shadcn/ui primitives in `src/components/ui/`; `lucide-react` icons; `sonner` toasts
- **OCR / AI:** Ollama vision models (local) and the `ai` SDK with OpenAI-compatible gateways
- **Storage:** S3-compatible (MinIO local, anything S3 in prod) via `@aws-sdk/client-s3`
- **Hosting:** Vercel (`vercel.json`) primary; Dockerfile + `docker-compose.yml` for Coolify / local
## 2. Architecture quick reference
| Area | Path | Notes |
| --- | --- | --- |
| App pages | `src/app/(dashboard)/`, `src/app/(auth)/`, `src/app/(marketing)/` | Route groups; dashboard is the protected app shell |
| API routes | `src/app/api/**/route.ts` | All start with `requireApiAuthWithOrg()`; errors via `handleApiError` |
| Prisma client | `src/lib/db.ts` | Lazy proxy singleton; **import `prisma` from `@/lib/db`** (never instantiate `new PrismaClient()`) |
| Auth helpers | `src/lib/api-auth.ts`, `src/auth.ts` | `requireApiAuthWithOrg(action?)` returns an `OrgSession` with `user.id` + `user.orgId` |
| Permissions | `src/lib/permissions.ts` | `can(role, action)` and `Action` union; roles: `owner > admin > editor > reviewer > viewer` |
| UI primitives | `src/components/ui/` | shadcn-style; do not duplicate — extend or compose |
| Feature components | `src/components/cards/`, `src/components/forms/`, etc. | Co-located by feature |
| OCR pipeline | `src/lib/ocr.ts`, `src/lib/ai-ocr.ts`, `src/lib/ollama.ts` | Background job model in `ProcessingJob` |
| Integrations | `src/lib/integrations/providers/` | Each provider exports the same shape; fired via `fireIntegrationEvent()` |
| Schema | `prisma/schema.prisma` | 21 models, no enums; multi-tenant via `organizationId` |
| Schema map | `docs/SCHEMA_MAP.md` | Regenerate with `npm run schema:map` |
Generated Prisma client lives at `src/generated/prisma/` — do not edit; regenerate with `npx prisma generate`.
## 3. Key conventions
- **Auth (server):** `const session = await requireApiAuthWithOrg(); /* session.user.orgId */`. Pass an optional `Action` to enforce a permission inline; `handleApiError` returns the right 401/403/500.
- **Multi-tenancy:** every `ResponseCard`, `FormTemplate`, `Person`, etc. is scoped by `organizationId`. **Always** include `organizationId: session.user.orgId` in `where:` filters and create payloads — there is no row-level security in dev.
- **API errors:** wrap handlers in `try { ... } catch (error) { return handleApiError(error); }`. Don't hand-roll `NextResponse.json({ error }, { status: 500 })`.
- **Validation:** body parsing is hand-rolled with defensive defaults (see `src/app/api/cards/[id]/route.ts` PUT). Zod is in `package.json` but not widely adopted yet — match the surrounding file's style.
- **Form templates:** field keys that match a top-level `ResponseCard` column must be `isCore: true` (see `src/lib/form-templates.ts`). Non-core values live in `ResponseCard.fieldData` JSON; the PUT route auto-promotes matching keys to columns.
- **Toasts:** `import { toast } from "sonner"``toast.success`, `toast.error`, `toast.info`.
- **Imports:** `@/*``src/*` (`tsconfig.json` `paths`). Generated Prisma at `@/generated/prisma/...`.
- **File names:** kebab-case for routes (`reprocess-batch/`), kebab-case for components (`upload-modal.tsx`), camelCase for hooks (`useUserProfile`).
- **Client vs server:** every interactive page starts with `"use client"`; API routes never include it.
## 4. Common gotchas
- `ResponseCard.name` is a **denormalized** display string. The PUT route at `src/app/api/cards/[id]/route.ts` recomputes it from `firstName + lastName` on edit; the OCR pipeline and `survey/submit` populate it on create. If you write a new path that mutates first/last, recompute `name` too.
- Prisma client lives at `src/generated/prisma/` (custom output, not the default `@prisma/client`). Import enums and types from there.
- `src/lib/db.ts` exports a **lazy `Proxy`** so importing `prisma` doesn't open a DB pool at module load — required for Next.js build-time data collection without `DATABASE_URL`. Don't call `prisma` from top-level module code.
- MinIO/S3 images are served through `GET /api/images/[...path]` which presigns and proxies. Don't hand back raw S3 URLs to the client.
- The Cloudflare/Vercel route `src/middleware.ts` enforces auth before requests hit `src/app/...`. New unauthenticated routes must be allowlisted there.
- `prisma/schema.prisma` does **not** declare a `url`. The connection string comes from `DATABASE_URL` at runtime — local dev needs `.env`.
## 5. Running locally
- **Runtime:** Node 18+ (Node 20 LTS recommended).
- **Setup:** see [`README.md`](README.md) (`docker compose up -d postgres minio`, `npm run db:push`, `npm run dev`). Requires Ollama with a vision model (`ollama pull llava:7b`) and GraphicsMagick for PDF rasterization.
- **Dev server:** `npm run dev`. App at `http://localhost:3000`, MinIO console at `:9001`.
## 6. Testing
- **Runner:** none configured yet. There are no unit, integration, or E2E tests. Adding tests is welcome — start with `vitest` and `@playwright/test`; gate them in CI before requiring green.
## 7. Deployment
- Primary target is Vercel (`vercel.json`). `Dockerfile` + `docker-compose.yml` are for Coolify / self-host. Build step is `npx prisma generate && next build` (see `package.json`). Notes on Coolify / Postgres / MinIO / Traefik wiring are in [`server_deploy.md`](../server_deploy.md) at the workspace root.

206
docs/SCHEMA_MAP.md Normal file
View file

@ -0,0 +1,206 @@
# Prisma Schema Map
_Auto-generated by `npm run schema:map` from [prisma/schema.prisma](../prisma/schema.prisma). Do not edit by hand._
Models: **21** • Enums: **0** • Groups: **9**
## Table of contents
- [Auth & Users](#auth-users) (5)
- [Org & Membership](#org-membership) (4)
- [Locations & Events](#locations-events) (2)
- [Form Templates](#form-templates) (2)
- [Cards & OCR](#cards-ocr) (2)
- [People (CRM)](#people-crm-) (1)
- [Integrations](#integrations) (1)
- [Activity & Notifications](#activity-notifications) (2)
- [Settings](#settings) (2)
## Auth & Users
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **Account** | `—` | 1 | [User](#auth-users) |
| **PasswordResetToken** | `—` | 0 | — |
| **Session** | `—` | 1 | [User](#auth-users) |
| **User** | `—` | 8 | [Account](#auth-users), [ActivityLog](#activity-notifications), [Notification](#activity-notifications), [OrgMember](#org-membership), [ResponseCard](#cards-ocr), [Session](#auth-users) |
| **VerificationToken** | `—` | 0 | — |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
Account "1" ||--|| "1" User : user
PasswordResetToken {
string id
}
Session "1" ||--|| "1" User : user
User "1" ||--o{ "many" OrgMember : memberships
User "1" ||--o{ "many" ResponseCard : assignedCards
User "1" ||--o{ "many" ActivityLog : activityLogs
User "1" ||--o{ "many" Notification : notifications
VerificationToken {
string id
}
```
</details>
## Org & Membership
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **ApiKey** | `—` | 1 | [Organization](#org-membership) |
| **Invitation** | `—` | 1 | [Organization](#org-membership) |
| **Organization** | `—` | 9 | [ApiKey](#org-membership), [FormTemplate](#form-templates), [Integration](#integrations), [Invitation](#org-membership), [Location](#locations-events), [OrgMember](#org-membership), [Person](#people-crm-), [ProcessingJob](#cards-ocr), [ResponseCard](#cards-ocr) |
| **OrgMember** | `—` | 2 | [Organization](#org-membership), [User](#auth-users) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
ApiKey "1" ||--|| "1" Organization : organization
Invitation "1" ||--|| "1" Organization : organization
Organization "1" ||--o{ "many" Location : locations
Organization "1" ||--o{ "many" OrgMember : members
Organization "1" ||--o{ "many" ResponseCard : cards
Organization "1" ||--o{ "many" ProcessingJob : jobs
Organization "1" ||--o{ "many" Integration : integrations
Organization "1" ||--o{ "many" FormTemplate : formTemplates
Organization "1" ||--o{ "many" Person : persons
OrgMember "1" ||--|| "1" User : user
```
</details>
## Locations & Events
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **CollectionDay** | `—` | 2 | [Location](#locations-events), [ResponseCard](#cards-ocr) |
| **Location** | `—` | 3 | [CollectionDay](#locations-events), [Organization](#org-membership), [ResponseCard](#cards-ocr) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
CollectionDay "1" ||--|| "1" Location : location
CollectionDay "1" ||--o{ "many" ResponseCard : cards
Location "1" ||--|| "1" Organization : organization
Location "1" ||--o{ "many" ResponseCard : cards
```
</details>
## Form Templates
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **FormField** | `—` | 1 | [FormTemplate](#form-templates) |
| **FormTemplate** | `—` | 3 | [FormField](#form-templates), [Organization](#org-membership), [ResponseCard](#cards-ocr) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
FormField "1" ||--|| "1" FormTemplate : formTemplate
FormTemplate "1" ||--|| "1" Organization : organization
FormTemplate "1" ||--o{ "many" ResponseCard : cards
```
</details>
## Cards & OCR
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **ProcessingJob** | `—` | 1 | [Organization](#org-membership) |
| **ResponseCard** | `—` | 8 | [CollectionDay](#locations-events), [FormTemplate](#form-templates), [Location](#locations-events), [Organization](#org-membership), [Person](#people-crm-), [User](#auth-users) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
ProcessingJob "1" ||--|| "1" Organization : organization
ResponseCard "1" ||--|| "1" User : assignedTo
ResponseCard "1" ||--|| "1" Organization : organization
ResponseCard "1" ||--|| "1" Location : location
ResponseCard "1" ||--|| "1" CollectionDay : collectionDay
ResponseCard "1" ||--|| "1" FormTemplate : formTemplate
ResponseCard "1" ||--|| "1" Person : person
```
</details>
## People (CRM)
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **Person** | `—` | 4 | [Organization](#org-membership), [Person](#people-crm-), [ResponseCard](#cards-ocr) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
Person "1" ||--|| "1" Organization : organization
Person "1" ||--o{ "many" ResponseCard : cards
```
</details>
## Integrations
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **Integration** | `—` | 1 | [Organization](#org-membership) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
Integration "1" ||--|| "1" Organization : organization
```
</details>
## Activity & Notifications
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **ActivityLog** | `—` | 1 | [User](#auth-users) |
| **Notification** | `—` | 1 | [User](#auth-users) |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
ActivityLog "1" ||--|| "1" User : user
Notification "1" ||--|| "1" User : user
```
</details>
## Settings
| Model | Table | Relations | Linked to |
| --- | --- | ---: | --- |
| **AppSettings** | `—` | 0 | — |
| **SystemConfig** | `—` | 0 | — |
<details><summary>ER diagram</summary>
```mermaid
erDiagram
AppSettings {
string id
}
SystemConfig {
string id
}
```
</details>
---
Regenerate after schema changes: `npm run schema:map`. Curated groupings live in [scripts/generate-schema-map.ts](../scripts/generate-schema-map.ts) under `MODEL_GROUPS`.

View file

@ -0,0 +1,49 @@
# Agent Context System
A three-layer system that gives AI coding agents fast, accurate orientation in this repo so they can start coding immediately instead of grepping a thousand files.
## The three layers
| Layer | Location | What it does | Token cost |
| --- | --- | --- | --- |
| **Intent** | [`AGENTS.md`](../../AGENTS.md) | High-level architecture, conventions, gotchas — always loaded | Always on |
| **Per-context guidance** | [`.cursor/rules/*.mdc`](../../.cursor/rules) | Glob-scoped rules (e.g. only loaded when editing `src/app/api/**`) | Loaded only when matching files are open |
| **On-demand recipes** | [`.cursor/skills/*`](../../.cursor/skills) | Step-by-step skills the agent reads when its description matches | Loaded only when invoked |
| **Schema map** | [`docs/SCHEMA_MAP.md`](../SCHEMA_MAP.md) | Generated Prisma model reference grouped by feature area | Loaded when prisma-schema-map.mdc matches |
## Quickstart for a new task
1. **Open the file you'll edit.** Cursor automatically loads `AGENTS.md` and any `.cursor/rules/*.mdc` whose `globs:` match.
2. **Describe the task.** The agent has the conventions in scope; it does not need to grep for them.
3. **Drafting.** Agent proposes the change against the relevant rule's conventions (e.g. `api-routes.mdc` enforces `requireApiAuthWithOrg` + `handleApiError`).
4. **Verify.** `npm run lint` and `npx tsc --noEmit`.
## How to extend
| You want to... | Do this |
| --- | --- |
| Add a new convention scoped to a folder | Create `.cursor/rules/<topic>.mdc` with frontmatter `description` + `globs:` |
| Add a step-by-step recipe agents can invoke | Create `.cursor/skills/<name>/SKILL.md` with frontmatter `description` |
| Mark a path as "do not touch" | Add it to [`.cursor/rules/no-go-zones.mdc`](../../.cursor/rules/no-go-zones.mdc) |
| Refresh the schema map after a Prisma change | `npm run schema:map` (regenerates `docs/SCHEMA_MAP.md`) |
| Re-categorize a Prisma model in the schema map | Edit `MODEL_GROUPS` in [`scripts/generate-schema-map.ts`](../../scripts/generate-schema-map.ts), then `npm run schema:map` |
Always-on rules cost from a shared instruction budget — keep them tight. The "would removing this line cause a mistake the agent wouldn't otherwise make?" test is the gate.
## The L2 subagent pipeline
In addition to the L1 context above, `.cursor/agents/role-*.md` defines 9 subagent roles for an idea-to-feature pipeline (conductor → ia-architect → ux-reviewer → architect → implementer → reviewer + design-system-auditor + a11y-auditor → doc-writer). Each role is read on invocation; they don't add to the always-on token budget. See `.cursor/agents/role-conductor.md` to get started.
Convoy files live in [`.convoys/`](../../.convoys/) — see that folder's README for the convoy lifecycle.
## What's intentionally NOT here
- No commits of `.cursor/mcp.json` (per-developer MCP install is personal-only, in `~/.cursor/mcp.json`).
- No proprietary cloud-credential bundles.
- No vendored screenshots / model weights — these belong in their own repos.
## Pipeline manifest
`.agent-context-manifest.yml` at the repo root tracks which artifacts the bootstrap installed, where they came from, and their pipeline version. Don't edit by hand — use the `sync-agent-context` skill to apply updates.
Pipeline source: [varutasu/agent-pipeline](https://github.com/varutasu/agent-pipeline). Multitask playbook (audit fan-out + implementer fleets via Cursor 3.2 `/multitask`): [docs/multitask-playbook.md](https://github.com/varutasu/agent-pipeline/blob/main/docs/multitask-playbook.md).

View file

@ -10,6 +10,7 @@
"db:migrate": "npx prisma migrate dev",
"db:push": "npx prisma db push",
"db:studio": "npx prisma studio",
"schema:map": "npx tsx scripts/generate-schema-map.ts",
"postinstall": "npx prisma generate"
},
"dependencies": {

View file

@ -0,0 +1,262 @@
/**
* Backfill script for the "core fields" bug fix.
*
* Background
*
* The default form template marked only `firstName` / `lastName` as
* `isCore: true`. Every other field (email, cellPhone, address, ) was
* treated as non-core, so user edits in the dynamic-field form landed
* inside `ResponseCard.fieldData` (JSON) instead of the matching top-level
* column. The list view, search, integrations, and CSV export all read the
* top-level columns, so they showed stale data after edits.
*
* Likewise, `ResponseCard.name` is a denormalized display string that was
* never recomputed when `firstName` / `lastName` changed, so the table's
* Name column and any integration that reads `name` stayed stale.
*
* What this script does
*
* 1. Flips `isCore = true` on every existing `FormTemplateField` whose
* `key` corresponds to a real top-level `ResponseCard` column.
* 2. Walks every `ResponseCard` and:
* a. promotes any matching keys from `fieldData` up to the top-level
* columns whenever `fieldData[key]` is non-empty. `fieldData`
* wins over the top-level column because pre-fix the UI saved
* non-core edits *only* to `fieldData`, so a non-empty
* `fieldData[canonical]` is the user's most recent value (or
* matches the OCR original anyway harmless either way).
* b. recomputes `name` from `firstName` / `lastName` when it's stale
* or missing.
*
* Conflicts (where the top-level column already has a value but
* `fieldData` has a *different* value) are logged so you can audit
* the diff before committing with `--apply`.
*
* Usage
*
* npx tsx scripts/backfill-core-fields.ts # dry-run (default)
* npx tsx scripts/backfill-core-fields.ts --apply # actually write
*/
import "dotenv/config";
import { PrismaClient } from "../src/generated/prisma/client.js";
import { PrismaPg } from "@prisma/adapter-pg";
import pg from "pg";
const APPLY = process.argv.includes("--apply");
const url = new URL(process.env.DATABASE_URL!);
url.searchParams.delete("sslmode");
const pool = new pg.Pool({
connectionString: url.toString(),
max: 5,
ssl: { rejectUnauthorized: false },
});
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
const PROMOTABLE_STRING = new Set([
"firstName", "lastName", "name",
"gender", "dateOfBirth",
"maritalStatus", "maritalStatusOther", "visitType",
"cellPhone", "homePhone", "email",
"address", "aptNumber", "city", "state", "zip",
"prayerRequests", "messageTopicsOther", "attendanceDuration",
"campusPreferenceOther", "howHeardOther", "serviceAttended",
"followUp", "notes", "serviceTime", "planningCenter",
]);
const PROMOTABLE_BOOL = new Set([
"prayerForTeam", "prayerConfidential",
"iSaidYesBookSent", "ftGuestLetterSent",
]);
const PROMOTABLE_ARRAY = new Set([
"messageTopics", "nextStep", "campusPreference", "howHeard",
]);
const PROMOTABLE_DATE = new Set([
"firstTimeGuestDate", "salvationDate",
]);
const ALL_PROMOTABLE_KEYS = new Set<string>([
...PROMOTABLE_STRING,
...PROMOTABLE_BOOL,
...PROMOTABLE_ARRAY,
...PROMOTABLE_DATE,
]);
function isEmpty(v: unknown): boolean {
if (v === null || v === undefined) return true;
if (typeof v === "string") return v.trim() === "";
if (Array.isArray(v)) return v.length === 0;
return false;
}
function valuesEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return false;
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
}
return String(a) === String(b);
}
async function flipIsCoreOnTemplateFields(): Promise<void> {
console.log("\n1. Promoting FormField rows to isCore = true…");
const candidates = await prisma.formField.findMany({
where: { isCore: false, key: { in: Array.from(ALL_PROMOTABLE_KEYS) } },
select: { id: true, key: true, label: true, formTemplateId: true },
});
console.log(` Found ${candidates.length} non-core field(s) that should be core`);
if (candidates.length === 0) return;
for (const f of candidates.slice(0, 10)) {
console.log(` - ${f.key} (template ${f.formTemplateId})`);
}
if (candidates.length > 10) console.log(` … and ${candidates.length - 10} more`);
if (!APPLY) return;
const ids = candidates.map((c) => c.id);
const res = await prisma.formField.updateMany({
where: { id: { in: ids } },
data: { isCore: true },
});
console.log(` Updated ${res.count} field(s)`);
}
async function backfillResponseCards(): Promise<void> {
console.log("\n2. Backfilling ResponseCard top-level columns from fieldData…");
const BATCH = 200;
let cursor: string | undefined;
let scanned = 0;
let cardsWithPromotion = 0;
let cardsWithNameFix = 0;
let totalFieldsPromoted = 0;
let totalFillEmpty = 0;
let totalOverwrite = 0;
const sample: string[] = [];
const conflicts: string[] = [];
while (true) {
const cards = await prisma.responseCard.findMany({
take: BATCH,
...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
orderBy: { id: "asc" },
});
if (cards.length === 0) break;
for (const card of cards) {
scanned++;
const updates: Record<string, unknown> = {};
const cardOverwrites: Array<{ key: string; oldVal: unknown; newVal: unknown }> = [];
// (a) Promote fieldData keys → matching top-level columns. fieldData
// wins (see file header for reasoning).
const fd = (card.fieldData as Record<string, unknown> | null) ?? null;
if (fd && typeof fd === "object") {
const cardAsAny = card as unknown as Record<string, unknown>;
for (const [k, v] of Object.entries(fd)) {
if (!ALL_PROMOTABLE_KEYS.has(k)) continue;
if (isEmpty(v)) continue;
let coerced: unknown;
if (PROMOTABLE_STRING.has(k)) coerced = String(v);
else if (PROMOTABLE_BOOL.has(k)) coerced = Boolean(v);
else if (PROMOTABLE_ARRAY.has(k)) coerced = Array.isArray(v) ? v : null;
else if (PROMOTABLE_DATE.has(k)) coerced = new Date(String(v));
else continue;
const currentVal = cardAsAny[k];
if (valuesEqual(currentVal, coerced)) continue; // already in sync, skip
updates[k] = coerced;
if (isEmpty(currentVal)) {
totalFillEmpty++;
} else {
totalOverwrite++;
cardOverwrites.push({ key: k, oldVal: currentVal, newVal: coerced });
}
}
}
// (b) Recompute `name` from firstName/lastName when stale or missing.
const nextFirst =
("firstName" in updates ? (updates.firstName as string | null) : card.firstName) ?? null;
const nextLast =
("lastName" in updates ? (updates.lastName as string | null) : card.lastName) ?? null;
const combined = [nextFirst, nextLast].filter(Boolean).join(" ").trim();
const desiredName = combined || null;
if (desiredName && desiredName !== card.name) {
updates.name = desiredName;
}
if (Object.keys(updates).length === 0) continue;
const promotedFieldKeys = Object.keys(updates).filter((k) => k !== "name");
if (promotedFieldKeys.length > 0) {
cardsWithPromotion++;
totalFieldsPromoted += promotedFieldKeys.length;
}
if ("name" in updates) cardsWithNameFix++;
if (sample.length < 5) {
sample.push(
` - ${card.id}: ${Object.keys(updates).map((k) => `${k}=${JSON.stringify(updates[k])}`).join(", ")}`
);
}
if (cardOverwrites.length > 0 && conflicts.length < 10) {
for (const o of cardOverwrites) {
if (conflicts.length >= 10) break;
conflicts.push(
` - ${card.id} ${o.key}: ${JSON.stringify(o.oldVal)}${JSON.stringify(o.newVal)}`
);
}
}
if (APPLY) {
await prisma.responseCard.update({
where: { id: card.id },
data: updates as Parameters<typeof prisma.responseCard.update>[0]["data"],
});
}
}
cursor = cards[cards.length - 1].id;
if (cards.length < BATCH) break;
}
console.log(` Scanned ${scanned} card(s)`);
console.log(` Promotion candidates: ${cardsWithPromotion} card(s), ${totalFieldsPromoted} field(s) total`);
console.log(` · fill-empty (column was blank): ${totalFillEmpty} field(s)`);
console.log(` · overwrite (column had a different value): ${totalOverwrite} field(s)`);
console.log(` Name-recompute candidates: ${cardsWithNameFix} card(s)`);
if (sample.length) {
console.log(" Sample changes:");
for (const line of sample) console.log(line);
}
if (conflicts.length) {
console.log(` Sample conflicts (top-level → fieldData):`);
for (const line of conflicts) console.log(line);
}
}
async function main(): Promise<void> {
console.log("=== ResponseCard core-fields backfill ===");
console.log(APPLY ? "Mode: APPLY (writes enabled)" : "Mode: DRY-RUN (no writes — pass --apply to commit)");
await flipIsCoreOnTemplateFields();
await backfillResponseCards();
console.log("\nDone.");
}
main()
.catch((err) => {
console.error("Backfill failed:", err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});

View file

@ -0,0 +1,291 @@
#!/usr/bin/env ts-node
/**
* generate-schema-map.ts
*
* Parses prisma/schema.prisma and emits docs/SCHEMA_MAP.md a grouped
* reference of all Prisma models with relation counts and per-group mermaid
* ER diagrams. Run via `npm run schema:map` after schema changes.
*
* MODEL_GROUPS is the curated source of truth for how Echo OCR's models
* cluster by feature area. New models fall into the "Other" bucket so the
* map stays complete even when this file is out of date that's a useful
* signal that you forgot to categorize a new model, so leave it that way.
*/
import { promises as fs } from "fs";
import * as path from "path";
const REPO_ROOT = path.resolve(__dirname, "..");
const SCHEMA_PATH = path.join(REPO_ROOT, "prisma", "schema.prisma");
const OUTPUT_PATH = path.join(REPO_ROOT, "docs", "SCHEMA_MAP.md");
interface ParsedField {
name: string;
type: string;
isRelation: boolean;
isList: boolean;
isOptional: boolean;
attributes: string;
}
interface ParsedModel {
name: string;
block: string;
fields: ParsedField[];
tableMap?: string;
comment?: string;
}
interface ParsedEnum {
name: string;
values: string[];
}
const MODEL_GROUPS: Record<string, string[]> = {
"Auth & Users": ["User", "Account", "Session", "VerificationToken", "PasswordResetToken"],
"Org & Membership": ["Organization", "OrgMember", "Invitation", "ApiKey"],
"Locations & Events": ["Location", "CollectionDay"],
"Form Templates": ["FormTemplate", "FormField"],
"Cards & OCR": ["ResponseCard", "ProcessingJob"],
"People (CRM)": ["Person"],
"Integrations": ["Integration"],
"Activity & Notifications": ["ActivityLog", "Notification"],
"Settings": ["AppSettings", "SystemConfig"],
};
const GROUP_ORDER = Object.keys(MODEL_GROUPS);
function parseSchema(source: string): {
models: ParsedModel[];
enums: ParsedEnum[];
} {
const models: ParsedModel[] = [];
const enums: ParsedEnum[] = [];
const modelRegex = /(?:^|\n)((?:\/\/\/[^\n]*\n)*)model\s+(\w+)\s*\{([\s\S]*?)\n\}/g;
let m: RegExpExecArray | null;
while ((m = modelRegex.exec(source)) !== null) {
const [, leadingComments, name, body] = m;
const fields: ParsedField[] = [];
let tableMap: string | undefined;
const lines = body.split("\n");
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("//")) continue;
if (line.startsWith("@@map(")) {
const mm = line.match(/@@map\("([^"]+)"\)/);
if (mm) tableMap = mm[1];
continue;
}
if (line.startsWith("@@")) continue;
const fieldMatch = line.match(/^(\w+)\s+([\w\[\]?]+)(\s+.*)?$/);
if (!fieldMatch) continue;
const [, fieldName, rawType, attrs] = fieldMatch;
const isList = rawType.endsWith("[]");
const isOptional = rawType.endsWith("?");
const baseType = rawType.replace(/[\[\]?]/g, "");
const isRelation = /^[A-Z]/.test(baseType);
fields.push({
name: fieldName,
type: baseType,
isRelation,
isList,
isOptional,
attributes: (attrs ?? "").trim(),
});
}
const comment = leadingComments
? leadingComments
.split("\n")
.map((l) => l.replace(/^\/\/\/\s?/, "").trim())
.filter(Boolean)
.join(" ")
: undefined;
models.push({ name, block: body, fields, tableMap, comment });
}
const enumRegex = /(?:^|\n)enum\s+(\w+)\s*\{([\s\S]*?)\n\}/g;
while ((m = enumRegex.exec(source)) !== null) {
const [, name, body] = m;
const values = body
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("//"))
.map((l) => l.split(/\s+/)[0]);
enums.push({ name, values });
}
return { models, enums };
}
function groupOf(modelName: string): string {
for (const [group, names] of Object.entries(MODEL_GROUPS)) {
if (names.includes(modelName)) return group;
}
return "Other";
}
function relationTargets(model: ParsedModel, scalarTypes: Set<string>): string[] {
const targets = new Set<string>();
for (const f of model.fields) {
if (!f.isRelation) continue;
if (scalarTypes.has(f.type)) continue;
targets.add(f.type);
}
return Array.from(targets).sort();
}
function relationCount(model: ParsedModel, scalarTypes: Set<string>): number {
return model.fields.filter((f) => f.isRelation && !scalarTypes.has(f.type)).length;
}
function buildMermaid(group: string, models: ParsedModel[], allModelNames: Set<string>): string {
if (models.length === 0) return "";
const lines: string[] = ["```mermaid", "erDiagram"];
const seenEdges = new Set<string>();
for (const model of models) {
for (const f of model.fields) {
if (!f.isRelation) continue;
if (!allModelNames.has(f.type)) continue;
const a = model.name;
const b = f.type;
if (a === b) continue;
const edgeKey = [a, b].sort().join("--");
if (seenEdges.has(edgeKey)) continue;
seenEdges.add(edgeKey);
const cardinality = f.isList ? '"1" ||--o{ "many"' : '"1" ||--|| "1"';
lines.push(` ${a} ${cardinality} ${b} : ${f.name}`);
}
if (
!models.some((other) =>
other.fields.some((ff) => ff.isRelation && ff.type === model.name)
) &&
!model.fields.some((ff) => ff.isRelation && allModelNames.has(ff.type))
) {
lines.push(` ${model.name} {`);
lines.push(` string id`);
lines.push(` }`);
}
}
lines.push("```");
return lines.join("\n");
}
async function main() {
const source = await fs.readFile(SCHEMA_PATH, "utf-8");
const { models, enums } = parseSchema(source);
const allModelNames = new Set(models.map((m) => m.name));
const enumNames = new Set(enums.map((e) => e.name));
const scalarTypes = new Set([
"String",
"Int",
"Float",
"Decimal",
"Boolean",
"DateTime",
"Json",
"Bytes",
"BigInt",
...enumNames,
]);
const grouped: Record<string, ParsedModel[]> = {};
for (const m of models) {
const g = groupOf(m.name);
grouped[g] ??= [];
grouped[g].push(m);
}
const out: string[] = [];
out.push("# Prisma Schema Map");
out.push("");
out.push(
"_Auto-generated by `npm run schema:map` from [prisma/schema.prisma](../prisma/schema.prisma). Do not edit by hand._",
);
out.push("");
out.push(
`Models: **${models.length}** • Enums: **${enums.length}** • Groups: **${
Object.keys(grouped).length
}**`,
);
out.push("");
out.push("## Table of contents");
out.push("");
const orderedGroups = [
...GROUP_ORDER.filter((g) => grouped[g]?.length),
...Object.keys(grouped).filter((g) => !GROUP_ORDER.includes(g)),
];
for (const g of orderedGroups) {
const slug = g.toLowerCase().replace(/[^a-z0-9]+/g, "-");
out.push(`- [${g}](#${slug}) (${grouped[g].length})`);
}
out.push("");
for (const g of orderedGroups) {
const models = grouped[g];
out.push(`## ${g}`);
out.push("");
out.push("| Model | Table | Relations | Linked to |");
out.push("| --- | --- | ---: | --- |");
for (const m of models.sort((a, b) => a.name.localeCompare(b.name))) {
const rels = relationCount(m, scalarTypes);
const targets = relationTargets(m, scalarTypes);
const targetList = targets.length
? targets
.map((t) =>
allModelNames.has(t)
? `[${t}](#${groupOf(t).toLowerCase().replace(/[^a-z0-9]+/g, "-")})`
: t,
)
.slice(0, 10)
.join(", ") + (targets.length > 10 ? ", …" : "")
: "—";
out.push(
`| **${m.name}** | \`${m.tableMap ?? "—"}\` | ${rels} | ${targetList} |`,
);
}
out.push("");
const mermaid = buildMermaid(g, models, allModelNames);
if (mermaid) {
out.push("<details><summary>ER diagram</summary>");
out.push("");
out.push(mermaid);
out.push("");
out.push("</details>");
out.push("");
}
}
if (enums.length) {
out.push("## Enums");
out.push("");
out.push("| Enum | Values |");
out.push("| --- | --- |");
for (const e of enums.sort((a, b) => a.name.localeCompare(b.name))) {
out.push(`| **${e.name}** | ${e.values.map((v) => `\`${v}\``).join(", ")} |`);
}
out.push("");
}
out.push("---");
out.push("");
out.push(
`Regenerate after schema changes: \`npm run schema:map\`. Curated groupings live in [scripts/generate-schema-map.ts](../scripts/generate-schema-map.ts) under \`MODEL_GROUPS\`.`,
);
out.push("");
await fs.mkdir(path.dirname(OUTPUT_PATH), { recursive: true });
await fs.writeFile(OUTPUT_PATH, out.join("\n"), "utf-8");
// eslint-disable-next-line no-console
console.log(
`Wrote ${path.relative(REPO_ROOT, OUTPUT_PATH)}${models.length} models in ${
orderedGroups.length
} groups, ${enums.length} enums.`,
);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});

89
scripts/log-convoy-event.sh Executable file
View file

@ -0,0 +1,89 @@
#!/usr/bin/env bash
# log-convoy-event.sh — append one convoy event to .convoys/.metrics.jsonl.
#
# Used by L2 roles to emit lightweight metrics for self-analytics.
# Schema: github.com/varutasu/agent-pipeline/analytics/schemas/convoy-event.json
#
# Usage:
# bash scripts/log-convoy-event.sh role=role-conductor convoy=bookmark-badge \
# classification=feature 'skip_flags=visual,smoke' duration_s=42
#
# All args are key=value. Required: role, convoy.
# Optional: brief, classification, skip_flags (comma-separated), duration_s,
# stack_class, outcome, multitask_group.
#
# multitask_group: cohort id when this role ran as part of a Cursor 3.2
# /multitask fan-out (e.g. 'audit-bookmark-badge-PR123'). Events sharing
# this id should be aggregated with max(duration_s), not sum, for wall-clock.
# See docs/multitask-playbook.md.
#
# Privacy: this file is gitignored by default; events contain only metadata,
# no code or prompts. To opt-in to commit, remove `.convoys/.metrics.jsonl`
# from your `.gitignore`.
#
# Atomicity: concurrent invocations append safely because each python3
# subprocess writes one short JSON line via O_APPEND. POSIX guarantees
# writes <= PIPE_BUF are atomic on regular files opened with O_APPEND.
# Typical line size is 200-400 bytes; PIPE_BUF is 4096 on Linux and
# 512+ on macOS. Larger custom fields could break this — keep
# multitask_group <= 64 chars (matches the JSON schema).
#
# Portable across macOS bash 3.2 and Linux bash 4+; uses python3 (always
# present on macOS + most Linux) for safe JSON encoding.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
REPO_NAME="$(basename "$REPO_ROOT")"
METRICS_FILE="$REPO_ROOT/.convoys/.metrics.jsonl"
mkdir -p "$REPO_ROOT/.convoys"
# Pull values out of args without using associative arrays (bash 3.2 compat)
ROLE=""; CONVOY=""; BRIEF=""; CLASSIFICATION=""
SKIP_FLAGS=""; DURATION_S=""; STACK_CLASS=""; OUTCOME=""; MULTITASK_GROUP=""
for arg in "$@"; do
k="${arg%%=*}"
v="${arg#*=}"
case "$k" in
role) ROLE="$v" ;;
convoy) CONVOY="$v" ;;
brief) BRIEF="$v" ;;
classification) CLASSIFICATION="$v" ;;
skip_flags) SKIP_FLAGS="$v" ;;
duration_s) DURATION_S="$v" ;;
stack_class) STACK_CLASS="$v" ;;
outcome) OUTCOME="$v" ;;
multitask_group) MULTITASK_GROUP="$v" ;;
*) echo "log-convoy-event: ignoring unknown arg '$k'" >&2 ;;
esac
done
if [ -z "$ROLE" ] || [ -z "$CONVOY" ]; then
echo "log-convoy-event: role and convoy are required" >&2
echo "Usage: $0 role=<role> convoy=<slug> [classification=...] [skip_flags=a,b] [duration_s=N] [brief=N] [stack_class=...] [outcome=...]" >&2
exit 1
fi
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Build the event with python3 — handles all string escaping and array encoding
python3 - <<PY >> "$METRICS_FILE"
import json, sys
ev = {
"ts": "$ts",
"role": $(printf '%s' "$ROLE" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))'),
"convoy": $(printf '%s' "$CONVOY" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))'),
"repo": $(printf '%s' "$REPO_NAME" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))'),
"skip_flags": [s for s in "$SKIP_FLAGS".split(",") if s],
}
if "$BRIEF": ev["brief"] = int("$BRIEF")
if "$CLASSIFICATION": ev["classification"] = "$CLASSIFICATION"
if "$DURATION_S": ev["duration_s"] = int("$DURATION_S")
if "$STACK_CLASS": ev["stack_class"] = "$STACK_CLASS"
if "$OUTCOME": ev["outcome"] = "$OUTCOME"
if "$MULTITASK_GROUP": ev["multitask_group"] = "$MULTITASK_GROUP"
print(json.dumps(ev))
PY
echo "Logged: role=$ROLE convoy=$CONVOY → .convoys/.metrics.jsonl"

37
scripts/wt.sh Executable file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# wt.sh — DEPRECATED in Cursor 3.2+.
#
# Cursor 3.2 (Apr 24, 2026) added native worktree management to the
# Agents Window with one-click foregrounding. Use that instead:
# https://cursor.com/docs/configuration/worktrees
#
# This stub is kept for two reasons:
# 1. Pre-3.2 users who haven't upgraded yet.
# 2. Scripted / CI worktree creation outside the IDE.
#
# To create a worktree manually:
# git worktree add -b brief/<convoy>/<N>-<title> \
# "$(dirname "$(git rev-parse --show-toplevel)")/$(basename "$(git rev-parse --show-toplevel)")-worktrees/brief-<N>-<title>" \
# develop
#
# See docs/multitask-playbook.md for when to spin up worktrees vs.
# running implementers in the same checkout.
set -euo pipefail
cat <<'EOF' >&2
wt.sh: deprecated. In Cursor 3.2+ use the Agents Window worktree UI.
Why this is deprecated:
- Cursor 3.2 worktrees integrate with subagent runs and one-click foreground.
- The legacy script duplicates that feature without the integration.
What to do instead:
- In Cursor: open Agents Window → "New worktree" → pick brief branch.
- For CI / scripted use: run `git worktree add` directly.
Reference: docs/multitask-playbook.md (worktrees section)
https://cursor.com/changelog/04-24-26
EOF
exit 0

View file

@ -377,6 +377,7 @@ export default function CardDetailPage() {
}
toast.success("Card updated");
await fetchCard();
router.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
} finally {
@ -407,6 +408,7 @@ export default function CardDetailPage() {
toast.success("Review complete — card will sync to Monday.com");
await fetchCard();
setEdits({});
router.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to complete review");
} finally {
@ -539,6 +541,26 @@ export default function CardDetailPage() {
const reviewStatus = card.reviewStatus;
const hasEdits = Object.keys(edits).length > 0 || Object.keys(fieldDataEdits).length > 0;
// Compute the header title from in-flight edits so it updates live as the
// user types. Falls back through edits → fieldData edits → card columns →
// legacy `card.name` so it works for both core and non-core templates.
const editedFirst =
"firstName" in edits
? String(edits.firstName ?? "")
: "firstName" in fieldDataEdits
? String(fieldDataEdits.firstName ?? "")
: (card.firstName ?? "");
const editedLast =
"lastName" in edits
? String(edits.lastName ?? "")
: "lastName" in fieldDataEdits
? String(fieldDataEdits.lastName ?? "")
: (card.lastName ?? "");
const displayName =
[editedFirst, editedLast].filter(Boolean).join(" ").trim() ||
card.name ||
"Unnamed Card";
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
@ -592,7 +614,7 @@ export default function CardDetailPage() {
</div>
)}
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
<Header title={displayName} icon={ScanLine}>
<div className="flex flex-wrap gap-2">
{canReprocess && ocrStatus !== "processing" && (
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleReprocess} disabled={reprocessing}>

View file

@ -0,0 +1,96 @@
import { HelpCircle } from "lucide-react";
import {
DocArticle,
DocSection,
DocContactCta,
} from "@/components/support/doc-article";
export const metadata = {
title: "FAQ — Echo Help",
};
export default function FaqPage() {
return (
<DocArticle
title="Frequently Asked Questions"
description="Quick answers to what comes up most often."
icon={<HelpCircle className="size-[18px]" />}
>
<DocSection title="How accurate is the OCR?">
<p>
For clean, typed-text cards Echo averages better than 98% accuracy.
Handwriting varies widely &mdash; expect 85&ndash;95% on neat printing
and lower on cursive. Every card passes through a{" "}
<strong>Needs Review</strong> state so a human can correct anything
the AI wasn&rsquo;t sure about before it hits your CRM.
</p>
</DocSection>
<DocSection title="Is my data secure?">
<p>
Yes. Echo stores card images in encrypted object storage, encrypts
integration credentials at rest, and transports everything over TLS.
Access is scoped to your organization &mdash; nobody outside your
team can see your cards. See the{" "}
<a href="/privacy">Privacy Policy</a> for full details.
</p>
</DocSection>
<DocSection title="How long are card images retained?">
<p>
By default, cards are retained for 2 years. Administrators can change
the retention window under{" "}
<strong>Settings &rarr; Organization</strong>, or delete individual
cards at any time.
</p>
</DocSection>
<DocSection title="Can I use Echo for non-English cards?">
<p>
Echo supports cards in most Latin-script languages out of the box
(Spanish, Portuguese, French, German, etc.). For non-Latin scripts
(Korean, Mandarin, Arabic), reach out &mdash; we can enable a
different extraction model for your organization.
</p>
</DocSection>
<DocSection title="What does it cost?">
<p>
Echo is priced per card processed, with a generous monthly free tier
for small churches. See <a href="/pricing">the pricing page</a> for
current tiers. Billing questions? Use the Contact button below &mdash;
it opens a billing-tagged ticket.
</p>
</DocSection>
<DocSection title="Can multiple people review the same batch?">
<p>
Yes. When you upload, cards go into the review queue for your whole
org. Any reviewer or editor can pick up a card; Echo locks it while
someone&rsquo;s editing to prevent conflicts.
</p>
</DocSection>
<DocSection title="Why did a card end up in 'Failed'?">
<p>
Usually because the file was too blurry, too dark, or didn&rsquo;t
look like a form at all (e.g., a blank envelope). The original file
is kept &mdash; you can retry after adjusting the scan, or re-upload
a better version. The{" "}
<a href="/docs/troubleshooting">Troubleshooting guide</a> has more
detail.
</p>
</DocSection>
<DocSection title="How do I cancel my subscription?">
<p>
Go to <strong>Settings &rarr; Organization &rarr; Billing</strong> and
click <strong>Cancel plan</strong>. Your data stays accessible until
the end of the billing period.
</p>
</DocSection>
<DocContactCta label="Didn't find an answer? Reach out and we'll help." />
</DocArticle>
);
}

View file

@ -0,0 +1,109 @@
import { FileText } from "lucide-react";
import {
DocArticle,
DocSection,
DocSteps,
DocStep,
DocCallout,
DocNext,
} from "@/components/support/doc-article";
export const metadata = {
title: "Forms & Templates — Echo Help",
};
export default function FormsTemplatesPage() {
return (
<DocArticle
title="Forms &amp; Templates"
description="Define what fields Echo should pull from each card, map them to your data model, and validate before going live."
icon={<FileText className="size-[18px]" />}
>
<DocSection title="What is a form template?">
<p>
A <strong>form template</strong> is a schema that describes the
fields on a physical response card. When Echo processes an uploaded
image or PDF, it uses your active template to decide what to extract
(e.g., <em>Name</em>, <em>Email</em>, <em>First-time visitor</em>,
etc.) and where to put the values.
</p>
<p>
You can have as many templates as you want &mdash; one per card type,
event, or campaign.
</p>
</DocSection>
<DocSection title="Creating a template">
<DocSteps>
<DocStep index={1} title="Open the template editor">
Go to <strong>Settings &rarr; Form Templates</strong> and click{" "}
<strong>New template</strong>.
</DocStep>
<DocStep index={2} title="Upload a sample card">
Upload a clean, filled-in sample. Echo uses it for the preview and
to test extraction later.
</DocStep>
<DocStep index={3} title="Add fields">
For each piece of data on the card, add a field with:
<ul className="ml-6 mt-2 list-disc space-y-1">
<li>
<strong>Label</strong> &mdash; human-readable name (e.g.,{" "}
&ldquo;Preferred Name&rdquo;).
</li>
<li>
<strong>Type</strong> &mdash; text, email, phone, checkbox,
choice, or multi-line.
</li>
<li>
<strong>Mapping</strong> &mdash; which person attribute this
field writes to (e.g.,{" "}
<code>person.email</code>, <code>person.visitType</code>).
</li>
<li>
<strong>Required?</strong> &mdash; required fields block{" "}
<em>Confirm</em> until filled.
</li>
</ul>
</DocStep>
<DocStep index={4} title="Test extraction">
Hit <strong>Test</strong>. Echo runs OCR on your sample and shows
exactly what it would write to each field. Iterate on labels and
mappings until the output is correct.
</DocStep>
<DocStep index={5} title="Publish">
Mark the template as <strong>Active</strong> and set it as the
default (or assign per upload source).
</DocStep>
</DocSteps>
</DocSection>
<DocSection title="Field mapping tips">
<DocCallout tone="tip" title="Match field labels to what's printed">
The AI uses your field labels as hints. If your card says &ldquo;Best
way to reach you?&rdquo;, name that field the same way &mdash; it
improves accuracy dramatically.
</DocCallout>
<DocCallout tone="tip" title="Use choice fields for checkboxes">
For rows of checkboxes (e.g., &ldquo;First time / Returning /
Member&rdquo;), use the <strong>Choice</strong> field type and list
the options exactly as they appear on the card.
</DocCallout>
</DocSection>
<DocSection title="Handling multiple card designs">
<p>
If you have more than one card design in rotation (e.g., visitor cards
vs. prayer request cards), create one template per design and assign
the right template at upload time or per upload source. Echo does{" "}
<em>not</em> auto-detect which template fits a given card.
</p>
</DocSection>
<DocNext
href="/docs/integrations"
title="Integrations"
description="Push confirmed data to Planning Center, Google Sheets, Monday, and more."
/>
</DocArticle>
);
}

View file

@ -0,0 +1,110 @@
import { Rocket } from "lucide-react";
import {
DocArticle,
DocSection,
DocSteps,
DocStep,
DocCallout,
DocNext,
} from "@/components/support/doc-article";
export const metadata = {
title: "Getting Started — Echo Help",
};
export default function GettingStartedPage() {
return (
<DocArticle
title="Getting Started"
description="Go from signup to your first processed response card in about 10 minutes."
icon={<Rocket className="size-[18px]" />}
>
<DocSection title="1. Create your account">
<DocSteps>
<DocStep index={1} title="Sign up">
Head to <code>/signup</code> and create an account with your work
email. If your church already uses Stillwell SSO, click{" "}
<strong>Sign in with SSO</strong> on the login page.
</DocStep>
<DocStep index={2} title="Verify your email">
Check your inbox for a verification email from{" "}
<code>mars@noreply.stillwell.cloud</code> and click the link. You
must verify before uploading cards.
</DocStep>
<DocStep index={3} title="Create your organization">
After verifying, you&rsquo;ll be guided through onboarding. Give your
organization a name (usually your church or ministry name) and
confirm.
</DocStep>
</DocSteps>
</DocSection>
<DocSection title="2. Invite your team">
<p>
Go to <strong>Settings &rarr; Team Members</strong> and click{" "}
<strong>Invite</strong>. Choose a role for each teammate:
</p>
<ul className="ml-6 list-disc space-y-1">
<li>
<strong>Admin</strong> &mdash; full access including billing and
settings.
</li>
<li>
<strong>Editor</strong> &mdash; can upload, review, and assign
cards.
</li>
<li>
<strong>Reviewer</strong> &mdash; can review and confirm OCR
results, but not change settings.
</li>
<li>
<strong>Viewer</strong> &mdash; read-only access to cards and
reports.
</li>
</ul>
<p>
Invites expire after 7 days. You can revoke an unused invitation from
the same page.
</p>
</DocSection>
<DocSection title="3. Configure a form template">
<p>
Echo uses <strong>form templates</strong> to know what fields to
extract from each card. Head to{" "}
<strong>Settings &rarr; Form Templates</strong> and either start from
a starter template or build your own.
</p>
<DocCallout tone="tip" title="Tip">
The default &ldquo;Response Card&rdquo; template handles name, email,
phone, and attendance &mdash; a great starting point for most
churches.
</DocCallout>
</DocSection>
<DocSection title="4. Upload your first card">
<DocSteps>
<DocStep index={1} title="Click Upload">
Use the <strong>Upload</strong> button in the top bar, or press{" "}
<code>&#8984;K</code> and choose <strong>Upload Documents</strong>.
</DocStep>
<DocStep index={2} title="Pick files">
Drag &amp; drop PDFs, JPGs, or PNGs. You can upload up to 50 files
at a time.
</DocStep>
<DocStep index={3} title="Review the extraction">
Echo processes each card in the background. Head to{" "}
<strong>Response Cards</strong> to review extracted fields, correct
anything, and assign the card to a person.
</DocStep>
</DocSteps>
</DocSection>
<DocNext
href="/docs/uploading-cards"
title="Uploading Cards"
description="Learn about every way to get cards into Echo — manual, email, FTP, and more."
/>
</DocArticle>
);
}

View file

@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
BookOpen,
Rocket,
Upload,
FileText,
Plug,
BarChart3,
HelpCircle,
Wrench,
LifeBuoy,
ArrowRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { openContactSupport } from "@/components/support/contact-support-dialog";
const SECTIONS = [
{ href: "/docs", label: "Overview", icon: BookOpen, exact: true },
{ href: "/docs/getting-started", label: "Getting Started", icon: Rocket },
{ href: "/docs/uploading-cards", label: "Uploading Cards", icon: Upload },
{ href: "/docs/forms-templates", label: "Forms & Templates", icon: FileText },
{ href: "/docs/integrations", label: "Integrations", icon: Plug },
{ href: "/docs/reports-exports", label: "Reports & Exports", icon: BarChart3 },
{ href: "/docs/faq", label: "FAQ", icon: HelpCircle },
{ href: "/docs/troubleshooting", label: "Troubleshooting", icon: Wrench },
];
export default function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<div className="flex flex-col gap-6 lg:flex-row">
<nav className="flex gap-1 overflow-x-auto lg:sticky lg:top-24 lg:h-fit lg:w-60 lg:shrink-0 lg:flex-col">
{SECTIONS.map((section) => {
const active = section.exact
? pathname === section.href
: pathname === section.href ||
pathname.startsWith(section.href + "/");
const Icon = section.icon;
return (
<Link
key={section.href}
href={section.href}
className={cn(
"flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
<Icon className="size-4 shrink-0" />
{section.label}
</Link>
);
})}
<div className="mt-4 rounded-xl border border-border/60 bg-muted/30 p-4 lg:max-w-60">
<div className="mb-2 flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-md bg-primary/10 text-primary">
<LifeBuoy className="size-3.5" />
</div>
<p className="text-sm font-semibold">Still stuck?</p>
</div>
<p className="mb-3 text-xs leading-relaxed text-muted-foreground">
Can't find what you need? Our team is happy to help.
</p>
<Button size="sm" className="w-full" onClick={openContactSupport}>
Contact support
<ArrowRight className="size-3.5" />
</Button>
</div>
</nav>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}

View file

@ -0,0 +1,261 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
BookOpen,
Rocket,
Upload,
FileText,
Plug,
BarChart3,
HelpCircle,
Wrench,
Search,
ArrowRight,
LifeBuoy,
type LucideIcon,
} from "lucide-react";
import { Header } from "@/components/layout/header";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { openContactSupport } from "@/components/support/contact-support-dialog";
type Topic = {
href: string;
title: string;
description: string;
icon: LucideIcon;
keywords: string[];
};
const TOPICS: Topic[] = [
{
href: "/docs/getting-started",
title: "Getting Started",
description:
"Create your account, set up your organization, invite your team, and upload your first response card.",
icon: Rocket,
keywords: [
"signup",
"onboarding",
"organization",
"invite",
"team",
"first upload",
"welcome",
"setup",
],
},
{
href: "/docs/uploading-cards",
title: "Uploading Cards",
description:
"Manual uploads, scanner/email ingestion, FTP watchers, supported file types, and the OCR lifecycle.",
icon: Upload,
keywords: [
"upload",
"scan",
"email watch",
"ftp",
"pdf",
"jpg",
"png",
"ocr",
"processing",
"queue",
],
},
{
href: "/docs/forms-templates",
title: "Forms & Templates",
description:
"Build form templates, map fields, test extraction, and handle edge cases.",
icon: FileText,
keywords: [
"form",
"template",
"fields",
"mapping",
"extraction",
"ai",
"testing",
],
},
{
href: "/docs/integrations",
title: "Integrations",
description:
"Connect Planning Center, Google Sheets, Monday, webhooks, and more to sync your data automatically.",
icon: Plug,
keywords: [
"integrations",
"planning center",
"pco",
"google sheets",
"monday",
"webhook",
"oauth",
"sync",
"export",
],
},
{
href: "/docs/reports-exports",
title: "Reports & Exports",
description:
"Generate reports, export CSVs, and push data to connected integrations.",
icon: BarChart3,
keywords: [
"reports",
"export",
"csv",
"download",
"analytics",
"charts",
],
},
{
href: "/docs/faq",
title: "FAQ",
description:
"Quick answers to the most common questions about billing, accuracy, security, and limits.",
icon: HelpCircle,
keywords: [
"faq",
"questions",
"billing",
"pricing",
"accuracy",
"security",
"privacy",
"limits",
],
},
{
href: "/docs/troubleshooting",
title: "Troubleshooting",
description:
"Fix OCR accuracy issues, email-watch failures, integration auth errors, and stuck jobs.",
icon: Wrench,
keywords: [
"troubleshoot",
"error",
"stuck",
"fail",
"broken",
"not working",
"fix",
"debug",
],
},
];
export default function DocsHomePage() {
const [query, setQuery] = React.useState("");
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return TOPICS;
return TOPICS.filter((t) => {
if (t.title.toLowerCase().includes(q)) return true;
if (t.description.toLowerCase().includes(q)) return true;
return t.keywords.some((k) => k.includes(q));
});
}, [query]);
return (
<div className="space-y-6">
<Header
title="Help & Docs"
description="Guides, references, and answers to help you get the most out of Echo."
icon={BookOpen}
/>
<Card className="glass-card">
<CardContent className="p-5">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search the docs…"
className="pl-9"
aria-label="Search the documentation"
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Try searching for &ldquo;upload&rdquo;, &ldquo;Planning Center&rdquo;,
or &ldquo;pricing&rdquo;.
</p>
</CardContent>
</Card>
{filtered.length === 0 ? (
<Card className="glass-card">
<CardContent className="p-8 text-center">
<p className="text-sm text-muted-foreground">
No matching topics found for &ldquo;{query}&rdquo;.
</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={openContactSupport}
>
<LifeBuoy className="size-3.5" />
Ask support
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{filtered.map((topic) => {
const Icon = topic.icon;
return (
<Link key={topic.href} href={topic.href} className="group">
<Card className="h-full transition-all hover:border-primary/40 hover:shadow-md">
<CardContent className="p-5">
<div className="mb-3 flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-4" />
</div>
<h3 className="text-base font-semibold tracking-tight">
{topic.title}
</h3>
<ArrowRight className="ml-auto size-4 text-muted-foreground transition-all group-hover:translate-x-0.5 group-hover:text-primary" />
</div>
<p className="text-sm leading-relaxed text-muted-foreground">
{topic.description}
</p>
</CardContent>
</Card>
</Link>
);
})}
</div>
)}
<Card className="border-primary/20 bg-primary/5">
<CardContent className="flex flex-col items-start gap-3 p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-primary">
<LifeBuoy className="size-4" />
</div>
<div>
<p className="text-sm font-semibold">Still have questions?</p>
<p className="text-xs text-muted-foreground">
Send us a message and we&rsquo;ll get back to you shortly.
</p>
</div>
</div>
<Button size="sm" onClick={openContactSupport}>
Contact support
<ArrowRight className="size-3.5" />
</Button>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,80 @@
import { BarChart3 } from "lucide-react";
import {
DocArticle,
DocSection,
DocSteps,
DocStep,
DocCallout,
DocNext,
} from "@/components/support/doc-article";
export const metadata = {
title: "Reports & Exports — Echo Help",
};
export default function ReportsExportsPage() {
return (
<DocArticle
title="Reports &amp; Exports"
description="Slice your card data, export CSVs, and push the results to your connected tools."
icon={<BarChart3 className="size-[18px]" />}
>
<DocSection title="Built-in reports">
<p>
The <strong>Reports</strong> page gives you a running view of:
</p>
<ul className="ml-6 list-disc space-y-1">
<li>New vs. returning visitors over time</li>
<li>Card volume per collection day</li>
<li>Top locations / services</li>
<li>Reviewer throughput &mdash; who confirmed how many cards</li>
</ul>
<p>
Use the filter bar at the top to scope any report by date range,
collection day, location, or assignee.
</p>
</DocSection>
<DocSection title="CSV export">
<DocSteps>
<DocStep index={1} title="Filter the list">
On the <strong>Response Cards</strong> page, filter to the exact
set you want to export.
</DocStep>
<DocStep index={2} title="Click Export CSV">
Use the Export button in the toolbar. The CSV includes every mapped
field plus metadata (upload date, reviewer, collection day).
</DocStep>
<DocStep index={3} title="Import into your CRM">
Most ChMS and CRM tools accept CSV uploads. Check their docs for
column-name requirements.
</DocStep>
</DocSteps>
<DocCallout tone="tip">
Need a recurring export? Set up the{" "}
<strong>CSV Export</strong> integration to drop a CSV to FTP, email,
or a webhook on a schedule.
</DocCallout>
</DocSection>
<DocSection title="Pushing to integrations">
<p>
When a card is <strong>Confirmed</strong>, Echo can push it to any
connected integration. Configure the trigger per integration (every
confirm vs. batched daily) under{" "}
<strong>Settings &rarr; Integrations</strong>.
</p>
<p>
For a full walkthrough of each provider, see the{" "}
<a href="/docs/integrations">Integrations docs</a>.
</p>
</DocSection>
<DocNext
href="/docs/faq"
title="FAQ"
description="Quick answers to the most common questions."
/>
</DocArticle>
);
}

View file

@ -0,0 +1,153 @@
import { Wrench } from "lucide-react";
import {
DocArticle,
DocSection,
DocCallout,
DocContactCta,
} from "@/components/support/doc-article";
export const metadata = {
title: "Troubleshooting — Echo Help",
};
export default function TroubleshootingPage() {
return (
<DocArticle
title="Troubleshooting"
description="Common problems, quick fixes, and how to get unstuck."
icon={<Wrench className="size-[18px]" />}
>
<DocSection title="OCR accuracy is lower than I expected">
<ul className="ml-6 list-disc space-y-2">
<li>
<strong>Scan at 300 DPI or higher.</strong> Lower resolutions blur
handwriting and drop accuracy fast.
</li>
<li>
<strong>Scan in color or grayscale</strong>, not bitonal B&amp;W.
Bitonal drops subtle handwriting strokes.
</li>
<li>
<strong>Make sure the form template matches.</strong> The AI uses
your field labels as hints; if the template is wrong, every card
will be off.
</li>
<li>
<strong>Crop out clutter.</strong> If your scanner includes a bunch
of blank space or overlap from the next card, extraction can
wander.
</li>
</ul>
</DocSection>
<DocSection title="Email-watch isn't picking up new mail">
<ul className="ml-6 list-disc space-y-2">
<li>
Confirm the inbox status on <strong>Settings &rarr; Upload Sources
&rarr; Email</strong> is <em>Connected</em>. If it shows{" "}
<em>Error</em>, the credentials are likely stale.
</li>
<li>
For Gmail, make sure you&rsquo;re using an{" "}
<strong>App Password</strong>, not your main account password.
Regular passwords will fail auth.
</li>
<li>
Check that IMAP access is enabled in the source account&rsquo;s
settings.
</li>
<li>
Verify the sender isn&rsquo;t being filtered into Spam &mdash; Echo
only watches the Inbox folder by default.
</li>
</ul>
</DocSection>
<DocSection title="FTP watcher says 'connection refused'">
<ul className="ml-6 list-disc space-y-2">
<li>
Double-check the host, port, and protocol (FTP vs. SFTP) match what
your scanner uses.
</li>
<li>
If your FTP is only reachable from inside your church&rsquo;s
network, expose it via SFTP or switch to email-watch.
</li>
<li>
Some FTP servers require <em>passive mode</em>. Toggle it in the
source settings if the initial listing hangs.
</li>
</ul>
</DocSection>
<DocSection title="An integration stopped syncing">
<ul className="ml-6 list-disc space-y-2">
<li>
Go to <strong>Settings &rarr; Integrations</strong> and check the{" "}
<em>Status</em> badge. A red <em>Auth expired</em> badge means
OAuth needs reconnecting.
</li>
<li>
Click the integration, then <strong>Reconnect</strong>. You may
need to sign into the provider (Google, Planning Center, etc.)
again.
</li>
<li>
For webhook destinations, inspect the latest delivery from{" "}
<strong>Settings &rarr; Integrations &rarr; Webhook &rarr; Recent
deliveries</strong>.
</li>
</ul>
</DocSection>
<DocSection title="Cards are stuck on 'Processing'">
<p>
This almost always means the background job queue is backed up.
Usually it clears within a few minutes. If a card stays in
Processing for more than an hour:
</p>
<ul className="ml-6 list-disc space-y-2">
<li>
Open the card, click <strong>More</strong> &rarr;{" "}
<strong>Retry processing</strong>.
</li>
<li>
If the retry also gets stuck, the file may be corrupted &mdash;
delete and re-upload.
</li>
<li>
If that still fails, reach out to support and include the card
ID.
</li>
</ul>
</DocSection>
<DocSection title="I can't sign in">
<ul className="ml-6 list-disc space-y-2">
<li>
Use the <strong>Forgot password</strong> link on the sign-in page
to reset.
</li>
<li>
If your church uses SSO, sign in with the{" "}
<strong>Sign in with SSO</strong> button &mdash; the
email/password flow won&rsquo;t work for SSO-only accounts.
</li>
<li>
If your email isn&rsquo;t verified yet, check your inbox (and
spam) for the verification email from{" "}
<code>mars@noreply.stillwell.cloud</code>.
</li>
</ul>
</DocSection>
<DocCallout tone="info" title="Have a card ID? Include it">
When you contact support about a specific card, mention the card ID
(shown in the URL when you open a card). It lets us jump directly to
the exact run.
</DocCallout>
<DocContactCta label="Still stuck? Describe what you're seeing and we'll dig in." />
</DocArticle>
);
}

View file

@ -0,0 +1,129 @@
import { Upload } from "lucide-react";
import {
DocArticle,
DocSection,
DocSteps,
DocStep,
DocCallout,
DocNext,
} from "@/components/support/doc-article";
export const metadata = {
title: "Uploading Cards — Echo Help",
};
export default function UploadingCardsPage() {
return (
<DocArticle
title="Uploading Cards"
description="Everything about getting response cards into Echo — manual uploads, email ingestion, FTP watchers, and the OCR processing lifecycle."
icon={<Upload className="size-[18px]" />}
>
<DocSection title="Supported file types">
<ul className="ml-6 list-disc space-y-1">
<li>
<strong>PDF</strong> &mdash; single-page or multi-page. Multi-page
PDFs are split into one card per page.
</li>
<li>
<strong>Images</strong> &mdash; JPG, JPEG, PNG, GIF, TIFF, BMP.
</li>
<li>
Maximum file size is <strong>25 MB</strong> per file,{" "}
<strong>50 files</strong> per upload batch.
</li>
</ul>
</DocSection>
<DocSection title="Manual upload">
<DocSteps>
<DocStep index={1} title="Open the Upload dialog">
Click the <strong>Upload</strong> button in the top bar, or press{" "}
<code>&#8984;K</code> &rarr; <strong>Upload Documents</strong>.
</DocStep>
<DocStep index={2} title="Pick a collection day (optional)">
Tagging the upload with a collection day (e.g., &ldquo;Easter
Sunday&rdquo;) makes later reporting easier.
</DocStep>
<DocStep index={3} title="Drag &amp; drop files">
Drop files anywhere on the dialog. Echo uploads them to storage and
queues each for OCR immediately.
</DocStep>
</DocSteps>
</DocSection>
<DocSection title="Email ingestion (email-watch)">
<p>
Echo can monitor an email inbox and automatically ingest any cards
sent as attachments. Configure it under{" "}
<strong>Settings &rarr; Upload Sources &rarr; Email</strong>:
</p>
<DocSteps>
<DocStep index={1} title="Create a dedicated inbox">
Ideally a Gmail or IMAP-capable inbox only used for Echo (e.g.,{" "}
<code>cards@yourchurch.org</code>).
</DocStep>
<DocStep index={2} title="Paste IMAP credentials">
Echo stores the credentials encrypted and uses a long-lived IMAP
IDLE connection to pick up new mail within seconds.
</DocStep>
<DocStep index={3} title="Send a test">
Forward a card to the watched address. It should appear in{" "}
<strong>Response Cards</strong> within a minute.
</DocStep>
</DocSteps>
<DocCallout tone="warning" title="Use an app password">
For Gmail, create an App Password &mdash; your main account password
will not work.
</DocCallout>
</DocSection>
<DocSection title="FTP watchers">
<p>
If your scanner drops files to an FTP/SFTP location, Echo can poll it
and pull new files in automatically. Configure under{" "}
<strong>Settings &rarr; Upload Sources &rarr; FTP</strong>. Both FTP
and SFTP are supported.
</p>
<DocCallout tone="tip">
Set your scanner to drop each scan into its own subfolder named for
the date &mdash; Echo tags the upload with that subfolder name so you
can group scans later.
</DocCallout>
</DocSection>
<DocSection title="The OCR processing lifecycle">
<p>Every uploaded file moves through these states:</p>
<ul className="ml-6 list-disc space-y-1">
<li>
<strong>Uploaded</strong> &mdash; file is stored, waiting for a
worker.
</li>
<li>
<strong>Processing</strong> &mdash; AI is extracting fields against
your active form template.
</li>
<li>
<strong>Needs Review</strong> &mdash; extraction succeeded; a human
should confirm unclear fields.
</li>
<li>
<strong>Confirmed</strong> &mdash; reviewer has signed off; card is
ready for export/integration push.
</li>
<li>
<strong>Failed</strong> &mdash; extraction couldn&rsquo;t run (bad
image, unreadable handwriting, etc.). The file is retained so you
can retry.
</li>
</ul>
</DocSection>
<DocNext
href="/docs/forms-templates"
title="Forms & Templates"
description="Build the templates that tell Echo what to extract from each card."
/>
</DocArticle>
);
}

View file

@ -156,7 +156,7 @@ export default function IntegrationsPage() {
on our Enterprise plan.
</p>
<Link
href="mailto:sales@echoocr.com"
href="mailto:support@stillwell.cloud?subject=Integration%20request"
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline"
>
Request an integration

View file

@ -83,7 +83,7 @@ const tiers = [
"Self-hosted deployment option",
],
cta: "Contact Sales",
ctaHref: "mailto:sales@echoocr.com",
ctaHref: "mailto:support@stillwell.cloud?subject=Sales%20inquiry",
highlighted: false,
},
];

View file

@ -390,8 +390,8 @@ export default function PrivacyPolicyPage() {
<p>
To request deletion of your account and all associated data, please contact us
at{" "}
<a href="mailto:privacy@echoocr.com" className="underline hover:text-foreground">
privacy@echoocr.com
<a href="mailto:support@stillwell.cloud?subject=Privacy%20inquiry" className="underline hover:text-foreground">
support@stillwell.cloud
</a>
. We will process deletion requests within 30 days.
</p>
@ -435,8 +435,8 @@ export default function PrivacyPolicyPage() {
</p>
<p>
To exercise your rights regarding your Echo account, contact us at{" "}
<a href="mailto:privacy@echoocr.com" className="underline hover:text-foreground">
privacy@echoocr.com
<a href="mailto:support@stillwell.cloud?subject=Privacy%20inquiry" className="underline hover:text-foreground">
support@stillwell.cloud
</a>
.
</p>
@ -454,8 +454,8 @@ export default function PrivacyPolicyPage() {
<p>
If you believe we have inadvertently collected information from a child under 13
without appropriate consent, please contact us immediately at{" "}
<a href="mailto:privacy@echoocr.com" className="underline hover:text-foreground">
privacy@echoocr.com
<a href="mailto:support@stillwell.cloud?subject=Privacy%20inquiry" className="underline hover:text-foreground">
support@stillwell.cloud
</a>
.
</p>
@ -490,14 +490,14 @@ export default function PrivacyPolicyPage() {
<ul className="list-none space-y-1 ml-2">
<li>
Email:{" "}
<a href="mailto:privacy@echoocr.com" className="underline hover:text-foreground">
privacy@echoocr.com
<a href="mailto:support@stillwell.cloud?subject=Privacy%20inquiry" className="underline hover:text-foreground">
support@stillwell.cloud
</a>
</li>
<li>
General support:{" "}
<a href="mailto:support@echoocr.com" className="underline hover:text-foreground">
support@echoocr.com
<a href="mailto:support@stillwell.cloud" className="underline hover:text-foreground">
support@stillwell.cloud
</a>
</li>
</ul>

View file

@ -377,10 +377,10 @@ export default function TermsOfServicePage() {
Either party may terminate these Terms at any time. You may stop using the
Service and delete your account by contacting{" "}
<a
href="mailto:support@echoocr.com"
href="mailto:support@stillwell.cloud"
className="underline hover:text-foreground"
>
support@echoocr.com
support@stillwell.cloud
</a>
.
</p>
@ -423,21 +423,21 @@ export default function TermsOfServicePage() {
</p>
<ul className="list-none space-y-1 ml-2">
<li>
Email:{" "}
Legal:{" "}
<a
href="mailto:legal@echoocr.com"
href="mailto:support@stillwell.cloud?subject=Legal%20inquiry"
className="underline hover:text-foreground"
>
legal@echoocr.com
support@stillwell.cloud
</a>
</li>
<li>
General support:{" "}
<a
href="mailto:support@echoocr.com"
href="mailto:support@stillwell.cloud"
className="underline hover:text-foreground"
>
support@echoocr.com
support@stillwell.cloud
</a>
</li>
</ul>

View file

@ -437,7 +437,7 @@ const tiers = [
"99.9% uptime SLA",
],
cta: "Contact Sales",
ctaHref: "mailto:sales@echoocr.com",
ctaHref: "mailto:support@stillwell.cloud?subject=Sales%20inquiry",
highlighted: false,
},
];

View file

@ -133,6 +133,60 @@ export async function PUT(
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
if (body.fieldData !== undefined) data.fieldData = body.fieldData;
// Promote any fieldData keys that match canonical top-level ResponseCard
// columns up to those columns. Form templates may mark contact/address
// fields as non-core, which means user edits land in `fieldData` only —
// promotion keeps the top-level columns (used by the list view, search,
// integrations, CSV export) in sync. Explicit body fields always win.
if (body.fieldData && typeof body.fieldData === "object") {
const fd = body.fieldData as Record<string, unknown>;
const PROMOTABLE_STRING = new Set([
"firstName", "lastName", "name",
"gender", "dateOfBirth",
"maritalStatus", "maritalStatusOther", "visitType",
"cellPhone", "homePhone", "email",
"address", "aptNumber", "city", "state", "zip",
"prayerRequests", "messageTopicsOther", "attendanceDuration",
"campusPreferenceOther", "howHeardOther", "serviceAttended",
"followUp", "notes", "serviceTime", "planningCenter",
]);
const PROMOTABLE_BOOL = new Set([
"prayerForTeam", "prayerConfidential",
"iSaidYesBookSent", "ftGuestLetterSent",
]);
const PROMOTABLE_ARRAY = new Set([
"messageTopics", "nextStep", "campusPreference", "howHeard",
]);
const PROMOTABLE_DATE = new Set([
"firstTimeGuestDate", "salvationDate",
]);
for (const [k, v] of Object.entries(fd)) {
if (k in data) continue;
if (PROMOTABLE_STRING.has(k)) {
data[k] = v == null || v === "" ? null : String(v);
} else if (PROMOTABLE_BOOL.has(k)) {
data[k] = Boolean(v);
} else if (PROMOTABLE_ARRAY.has(k)) {
data[k] = Array.isArray(v) ? v : null;
} else if (PROMOTABLE_DATE.has(k)) {
data[k] = v ? new Date(String(v)) : null;
}
}
}
// Keep the denormalized `name` field in sync with firstName/lastName.
// `name` is read by the list view, search, sort, integrations, and CSV
// export, so whenever first/last changes (and the caller didn't already
// pass an explicit `name`), recompute it.
if (("firstName" in data || "lastName" in data) && !("name" in data)) {
const nextFirst =
"firstName" in data ? (data.firstName as string | null) : card.firstName;
const nextLast =
"lastName" in data ? (data.lastName as string | null) : card.lastName;
const combined = [nextFirst, nextLast].filter(Boolean).join(" ").trim();
data.name = combined || null;
}
for (const assignField of ["assignedToId", "assignedById", "reviewedById"] as const) {
if (body[assignField] !== undefined) {
data[assignField] = body[assignField] || null;

View file

@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireAuth, RoleError } from "@/lib/auth";
import { prisma } from "@/lib/db";
import {
sendSupportTicketEmail,
type SupportTicketCategory,
} from "@/lib/email-sender";
const supportSchema = z.object({
subject: z.string().trim().min(3).max(120),
category: z.enum(["bug", "question", "feature", "billing", "other"]),
message: z.string().trim().min(10).max(5000),
pageUrl: z.string().trim().max(500).optional().nullable(),
});
// Simple in-memory rate limit: max 5 submissions per 10 minutes per user.
const WINDOW_MS = 10 * 60 * 1000;
const MAX_PER_WINDOW = 5;
const rateBucket = new Map<string, number[]>();
function rateLimit(key: string): { ok: boolean; retryAfterSeconds?: number } {
const now = Date.now();
const timestamps = rateBucket.get(key) || [];
const recent = timestamps.filter((t) => now - t < WINDOW_MS);
if (recent.length >= MAX_PER_WINDOW) {
const oldest = recent[0];
const retry = Math.ceil((WINDOW_MS - (now - oldest)) / 1000);
return { ok: false, retryAfterSeconds: retry };
}
recent.push(now);
rateBucket.set(key, recent);
return { ok: true };
}
export async function POST(req: NextRequest) {
try {
const user = await requireAuth();
const limit = rateLimit(`support:${user.id}`);
if (!limit.ok) {
return NextResponse.json(
{
error:
"You've submitted too many support requests. Please try again shortly.",
retryAfterSeconds: limit.retryAfterSeconds,
},
{ status: 429 }
);
}
const body = await req.json().catch(() => null);
const parsed = supportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid support request",
details: parsed.error.flatten(),
},
{ status: 400 }
);
}
const { subject, category, message, pageUrl } = parsed.data;
// Resolve org name if the user has one
let orgName: string | null = null;
if (user.orgId) {
const org = await prisma.organization.findUnique({
where: { id: user.orgId },
select: { name: true },
});
orgName = org?.name ?? null;
}
const userAgent = req.headers.get("user-agent");
await sendSupportTicketEmail({
subject,
message,
category: category as SupportTicketCategory,
userEmail: user.email,
userName: user.displayName || user.username || null,
userId: user.id,
orgId: user.orgId || null,
orgName,
pageUrl: pageUrl || null,
userAgent,
appEnv: process.env.NEXT_PUBLIC_ENV || null,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof RoleError) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
console.error("[api/support] Error:", error);
return NextResponse.json(
{ error: "Failed to submit support request" },
{ status: 500 }
);
}
}

View file

@ -14,7 +14,10 @@ import {
UserPlus,
FileText,
Search,
BookOpen,
LifeBuoy,
} from "lucide-react";
import { openContactSupport } from "@/components/support/contact-support-dialog";
const navItems = [
{ label: "Dashboard", href: "/", icon: LayoutDashboard, group: "Navigate" },
@ -24,11 +27,13 @@ const navItems = [
{ label: "Reports", href: "/reports", icon: BarChart3, group: "Navigate" },
{ label: "Settings", href: "/settings", icon: Settings, group: "Navigate" },
{ label: "Form Templates", href: "/settings/form-templates", icon: FileText, group: "Navigate" },
{ label: "Help Center", href: "/docs", icon: BookOpen, group: "Navigate" },
];
const actionItems = [
{ label: "Upload Documents", icon: Upload, group: "Actions", action: "upload" },
{ label: "Create Person", icon: UserPlus, group: "Actions", action: "create-person" },
{ label: "Contact Support", icon: LifeBuoy, group: "Actions", action: "contact-support" },
];
export function CommandPalette() {
@ -53,6 +58,8 @@ export function CommandPalette() {
window.dispatchEvent(new CustomEvent("open-upload-modal"));
} else if (action === "create-person") {
router.push("/people?new=true");
} else if (action === "contact-support") {
openContactSupport();
}
},
[router]

View file

@ -7,6 +7,7 @@ import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sideba
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
import { CommandPalette } from "@/components/command-palette";
import { UploadModal } from "@/components/cards/upload-modal";
import { ContactSupportDialog } from "@/components/support/contact-support-dialog";
function ShellContent({ children }: { children: React.ReactNode }) {
const { collapsed } = useSidebar();
@ -40,6 +41,7 @@ function ShellContent({ children }: { children: React.ReactNode }) {
onOpenChange={setUploadOpen}
initialFiles={uploadFiles.length > 0 ? uploadFiles : undefined}
/>
<ContactSupportDialog />
<main
id="main-content"
className={cn(

View file

@ -13,6 +13,7 @@ import {
Settings,
ChevronsLeft,
ChevronsRight,
BookOpen,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
@ -37,6 +38,7 @@ const navItems = [
];
const bottomItems = [
{ href: "/docs", label: "Help & Docs", icon: BookOpen },
{ href: "/settings", label: "Settings", icon: Settings },
];

View file

@ -13,6 +13,7 @@ import {
User,
UserCircle,
LifeBuoy,
BookOpen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -34,6 +35,7 @@ import {
import { useUserProfile } from "@/lib/user-profile";
import { isAdminRole } from "@/lib/permissions";
import { NotificationCenter } from "@/components/notifications/notification-center";
import { openContactSupport } from "@/components/support/contact-support-dialog";
export function TopBar() {
const { theme, setTheme } = useTheme();
@ -154,11 +156,13 @@ export function TopBar() {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => window.open("mailto:support@echoocr.app", "_blank")}
>
<DropdownMenuItem render={<Link href="/docs" />}>
<BookOpen className="size-4" />
Help Center
</DropdownMenuItem>
<DropdownMenuItem onClick={openContactSupport}>
<LifeBuoy className="size-4" />
Support
Contact Support
</DropdownMenuItem>
<DropdownMenuItem onClick={cycleTheme}>
<Sun className="size-4 dark:hidden" />

View file

@ -31,8 +31,8 @@ export function MarketingFooter() {
<div>
<h4 className="text-sm font-semibold mb-4">Company</h4>
<ul className="space-y-2.5 text-sm text-muted-foreground">
<li><a href="mailto:support@echoocr.com" className="hover:text-foreground transition-colors">Support</a></li>
<li><a href="mailto:sales@echoocr.com" className="hover:text-foreground transition-colors">Sales</a></li>
<li><a href="mailto:support@stillwell.cloud" className="hover:text-foreground transition-colors">Support</a></li>
<li><a href="mailto:support@stillwell.cloud?subject=Sales%20inquiry" className="hover:text-foreground transition-colors">Sales</a></li>
<li><Link href="/privacy" className="hover:text-foreground transition-colors">Privacy Policy</Link></li>
<li><Link href="/terms" className="hover:text-foreground transition-colors">Terms of Service</Link></li>
</ul>

View file

@ -0,0 +1,259 @@
"use client";
import * as React from "react";
import { usePathname } from "next/navigation";
import { toast } from "sonner";
import { LifeBuoy, Loader2, Send } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUserProfile } from "@/lib/user-profile";
export const OPEN_SUPPORT_MODAL_EVENT = "open-support-modal";
type Category = "bug" | "question" | "feature" | "billing" | "other";
const CATEGORY_OPTIONS: { value: Category; label: string; hint: string }[] = [
{ value: "bug", label: "Bug report", hint: "Something isn't working" },
{ value: "question", label: "Question", hint: "How do I…?" },
{ value: "feature", label: "Feature request", hint: "I wish Echo could…" },
{ value: "billing", label: "Billing", hint: "Plans, invoices, payment" },
{ value: "other", label: "Other", hint: "Anything else" },
];
const SUBJECT_MIN = 3;
const SUBJECT_MAX = 120;
const MESSAGE_MIN = 10;
const MESSAGE_MAX = 5000;
export function ContactSupportDialog() {
const { profile, isAuthenticated } = useUserProfile();
const pathname = usePathname();
const [open, setOpen] = React.useState(false);
const [category, setCategory] = React.useState<Category>("question");
const [subject, setSubject] = React.useState("");
const [message, setMessage] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
React.useEffect(() => {
const handler = () => setOpen(true);
window.addEventListener(OPEN_SUPPORT_MODAL_EVENT, handler);
return () => window.removeEventListener(OPEN_SUPPORT_MODAL_EVENT, handler);
}, []);
React.useEffect(() => {
if (!open) {
setCategory("question");
setSubject("");
setMessage("");
setSubmitting(false);
}
}, [open]);
const canSubmit =
subject.trim().length >= SUBJECT_MIN &&
subject.trim().length <= SUBJECT_MAX &&
message.trim().length >= MESSAGE_MIN &&
message.trim().length <= MESSAGE_MAX &&
!submitting;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
try {
const pageUrl =
typeof window !== "undefined"
? `${window.location.origin}${pathname || window.location.pathname}`
: pathname || "";
const res = await fetch("/api/support", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subject: subject.trim(),
category,
message: message.trim(),
pageUrl,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (res.status === 429) {
toast.error(
data.error ||
"You've submitted too many support requests. Please try again shortly."
);
} else if (res.status === 401) {
toast.error("Please sign in to contact support.");
} else {
toast.error(
data.error || "Couldn't send your message. Please try again."
);
}
setSubmitting(false);
return;
}
toast.success("Support request sent", {
description:
"We've received your message and will reply to " +
(profile.email || "your email") +
" shortly.",
});
setOpen(false);
} catch (err) {
console.error("[contact-support] submit failed", err);
toast.error("Couldn't send your message. Please try again.");
setSubmitting(false);
}
}
if (!isAuthenticated) {
// Fall back to mailto for logged-out users — the dialog depends on session.
return null;
}
const selectedCategory = CATEGORY_OPTIONS.find((c) => c.value === category);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<LifeBuoy className="size-4" />
</div>
<div className="flex-1">
<DialogTitle>Contact support</DialogTitle>
<DialogDescription>
Our team will reply directly to{" "}
<span className="font-medium text-foreground">
{profile.email || "your account email"}
</span>
.
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="support-category">What's this about?</Label>
<Select
value={category}
onValueChange={(v) => v && setCategory(v as Category)}
>
<SelectTrigger id="support-category" className="w-full rounded-lg">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span className="font-medium">{opt.label}</span>
<span className="ml-1.5 text-xs text-muted-foreground">
{opt.hint}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="support-subject">Subject</Label>
<Input
id="support-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={
selectedCategory?.value === "bug"
? "Short description of the bug"
: "One-line summary"
}
maxLength={SUBJECT_MAX}
required
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="support-message">Message</Label>
<span className="text-xs text-muted-foreground">
{message.length}/{MESSAGE_MAX}
</span>
</div>
<Textarea
id="support-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={
category === "bug"
? "What did you try? What happened? What did you expect?"
: "Share as much detail as you can."
}
maxLength={MESSAGE_MAX}
className="min-h-32"
required
/>
</div>
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
We'll attach your account details, current page, and browser info
automatically so we can help faster.
</div>
<DialogFooter>
<DialogClose
render={
<Button type="button" variant="outline" disabled={submitting} />
}
>
Cancel
</DialogClose>
<Button type="submit" disabled={!canSubmit}>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Sending
</>
) : (
<>
<Send className="size-4" />
Send message
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function openContactSupport() {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(OPEN_SUPPORT_MODAL_EVENT));
}

View file

@ -0,0 +1,186 @@
"use client";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { openContactSupport } from "@/components/support/contact-support-dialog";
interface DocArticleProps {
title: string;
description?: string;
/**
* Icon element, e.g. <Rocket className="size-[18px]" />. Passed as a
* rendered React element rather than a component type so server-rendered
* doc pages can pass it to this client component without tripping the
* "functions cannot be passed to client components" RSC check.
*/
icon?: React.ReactNode;
children: React.ReactNode;
}
export function DocArticle({
title,
description,
icon,
children,
}: DocArticleProps) {
return (
<article className="space-y-6">
<div>
<Link
href="/docs"
className="mb-3 inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3" />
All docs
</Link>
<div className="flex items-start gap-3">
{icon && (
<div className="mt-1 flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
{icon}
</div>
)}
<div>
<h1 className="text-xl font-bold tracking-tight text-foreground sm:text-2xl">
{title}
</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
</div>
</div>
<div className="space-y-6 text-sm leading-relaxed text-foreground">
{children}
</div>
</article>
);
}
interface DocSectionProps {
title: string;
children: React.ReactNode;
}
export function DocSection({ title, children }: DocSectionProps) {
return (
<section className="space-y-3">
<h2 className="border-b border-border/50 pb-2 text-base font-semibold tracking-tight text-foreground">
{title}
</h2>
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground [&_code]:rounded-md [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-foreground [&_strong]:text-foreground [&_a]:text-primary [&_a:hover]:underline">
{children}
</div>
</section>
);
}
interface DocStepsProps {
children: React.ReactNode;
}
export function DocSteps({ children }: DocStepsProps) {
return <ol className="space-y-3 pl-0">{children}</ol>;
}
interface DocStepProps {
title: string;
children: React.ReactNode;
index: number;
}
export function DocStep({ title, children, index }: DocStepProps) {
return (
<li className="flex gap-4">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-bold text-primary">
{index}
</span>
<div className="min-w-0 flex-1 space-y-1 pt-0.5">
<p className="text-sm font-semibold text-foreground">{title}</p>
<div className="text-sm leading-relaxed text-muted-foreground">
{children}
</div>
</div>
</li>
);
}
interface DocCalloutProps {
tone?: "info" | "warning" | "tip";
title?: string;
children: React.ReactNode;
}
export function DocCallout({
tone = "info",
title,
children,
}: DocCalloutProps) {
const tones = {
info: "border-primary/30 bg-primary/5",
warning: "border-amber-500/30 bg-amber-500/5",
tip: "border-emerald-500/30 bg-emerald-500/5",
} as const;
return (
<Card className={tones[tone]}>
<CardContent className="p-4 text-sm">
{title && (
<p className="mb-1 text-sm font-semibold text-foreground">{title}</p>
)}
<div className="text-sm leading-relaxed text-muted-foreground">
{children}
</div>
</CardContent>
</Card>
);
}
interface DocNextProps {
href: string;
title: string;
description?: string;
}
export function DocNext({ href, title, description }: DocNextProps) {
return (
<Card className="border-primary/20 transition-colors hover:border-primary/40">
<CardContent className="p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Next up
</p>
<Link href={href} className="mt-1 block">
<p className="text-sm font-semibold text-foreground hover:text-primary">
{title} &rarr;
</p>
{description && (
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
)}
</Link>
</CardContent>
</Card>
);
}
interface DocCtaContactProps {
label?: string;
}
export function DocContactCta({ label }: DocCtaContactProps) {
return (
<Card className="border-primary/20 bg-primary/5">
<CardContent className="flex flex-col items-start gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{label || "Still stuck? Our team is happy to help."}
</p>
<Button size="sm" type="button" onClick={openContactSupport}>
Contact support
</Button>
</CardContent>
</Card>
);
}

View file

@ -203,6 +203,91 @@ export async function sendVerificationEmail(email: string, baseUrl: string) {
await sendEmail(email, "Verify your email — Echo", html);
}
/* ------------------------------------------------------------------ */
/* Support ticket email (ingested by Libredesk) */
/* ------------------------------------------------------------------ */
export type SupportTicketCategory =
| "bug"
| "question"
| "feature"
| "billing"
| "other";
const CATEGORY_LABELS: Record<SupportTicketCategory, string> = {
bug: "Bug report",
question: "Question",
feature: "Feature request",
billing: "Billing",
other: "Other",
};
export interface SupportTicketPayload {
subject: string;
message: string;
category: SupportTicketCategory;
userEmail: string;
userName?: string | null;
userId?: string | null;
orgName?: string | null;
orgId?: string | null;
pageUrl?: string | null;
userAgent?: string | null;
appEnv?: string | null;
}
export async function sendSupportTicketEmail(payload: SupportTicketPayload) {
const supportInbox = process.env.SUPPORT_EMAIL || "support@stillwell.cloud";
const categoryLabel = CATEGORY_LABELS[payload.category] || payload.category;
const submittedAt = new Date().toISOString();
const lines = [
"New support request submitted from the Echo app.",
"",
"---- Request ----",
`Subject: ${payload.subject}`,
`Category: ${categoryLabel} (${payload.category})`,
`Submitted At: ${submittedAt}`,
"",
"---- User ----",
`Name: ${payload.userName || "(not set)"}`,
`Email: ${payload.userEmail}`,
`User ID: ${payload.userId || "(anonymous)"}`,
"",
"---- Organization ----",
`Organization: ${payload.orgName || "(none)"}`,
`Org ID: ${payload.orgId || "(none)"}`,
"",
"---- Context ----",
`App URL: ${payload.pageUrl || SITE_URL}`,
`Site: ${SITE_HOSTNAME}`,
`App Env: ${payload.appEnv || process.env.NEXT_PUBLIC_ENV || "production"}`,
`User Agent: ${payload.userAgent || "(unknown)"}`,
"",
"---- Message ----",
payload.message,
"",
"---- End ----",
"This ticket was generated by the Echo in-app Contact Support form.",
"Reply directly to this email to respond to the user.",
];
const textContent = lines.join("\n");
const subject = `[Echo Support] [${categoryLabel}] ${payload.subject}`;
await brevo.transactionalEmails.sendTransacEmail({
sender: DEFAULT_SENDER,
to: [{ email: supportInbox }],
replyTo: {
email: payload.userEmail,
name: payload.userName || undefined,
},
subject,
textContent,
tags: ["echo-support", `category:${payload.category}`],
});
}
/* ------------------------------------------------------------------ */
/* Invitation email */
/* ------------------------------------------------------------------ */

77
src/lib/flags/index.ts Normal file
View file

@ -0,0 +1,77 @@
/**
* Feature flag wrapper. Lightweight, dependency-free, env-var driven.
*
* Usage:
* import { isEnabled } from '@/lib/flags';
* if (isEnabled('bookmark_count_badge', { userId: session?.user?.id })) {
* // ...
* }
*
* Flag values are resolved from env vars: FLAG_<UPPER_SNAKE_NAME>=on|off|<percent>|<comma list of user ids>
* Examples:
* FLAG_BOOKMARK_COUNT_BADGE=on # everyone
* FLAG_BOOKMARK_COUNT_BADGE=off # nobody
* FLAG_BOOKMARK_COUNT_BADGE=10 # 10% of users (deterministic per userId)
* FLAG_BOOKMARK_COUNT_BADGE=u1,u2,u3 # specific user ids
*
* For richer flag systems (LaunchDarkly, Statsig, Unleash, etc.) replace the
* resolver below with an SDK call. The public API (`isEnabled`) stays the same.
*
* Pipeline integration: convoys with `skip: flag` in their frontmatter ship
* without flag-gating. Convoys without `skip: flag` MUST gate the new code
* behind a flag and document the rollout plan in the convoy file.
*/
type FlagContext = {
userId?: string | null;
/** Optional override — useful for tests. */
envValue?: string;
};
const KNOWN_FLAGS = new Set<string>([
// Add flags here as they're created. Helps catch typos.
// 'bookmark_count_badge',
]);
function envName(flag: string): string {
return `FLAG_${flag.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
}
function hashUserId(userId: string, salt: string): number {
let h = 0;
const s = `${salt}::${userId}`;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h) % 100;
}
export function isEnabled(flag: string, ctx: FlagContext = {}): boolean {
if (process.env.NODE_ENV !== 'test' && !KNOWN_FLAGS.has(flag)) {
if (typeof console !== 'undefined') {
console.warn(`[flags] unknown flag '${flag}'. Add it to KNOWN_FLAGS in lib/flags/index.ts.`);
}
}
const raw = (ctx.envValue ?? process.env[envName(flag)] ?? 'off').trim().toLowerCase();
if (raw === 'on' || raw === 'true' || raw === '1') return true;
if (raw === 'off' || raw === 'false' || raw === '0' || raw === '') return false;
const pct = Number(raw);
if (!Number.isNaN(pct) && pct >= 0 && pct <= 100) {
if (!ctx.userId) return false; // no user context, no rollout
return hashUserId(ctx.userId, flag) < pct;
}
if (raw.includes(',') || raw.length > 0) {
const ids = raw.split(',').map((s) => s.trim()).filter(Boolean);
return Boolean(ctx.userId && ids.includes(ctx.userId));
}
return false;
}
export function listKnownFlags(): string[] {
return [...KNOWN_FLAGS].sort();
}

View file

@ -1,32 +1,37 @@
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "./db";
// Every default field maps to a canonical top-level column on ResponseCard,
// so they're all marked `isCore: true`. That way, edits flow through the
// `edits` channel and write to top-level columns (read by the list view,
// search, integrations, etc.) instead of the `fieldData` JSON blob.
// `fieldData` is reserved for user-defined custom fields.
const DEFAULT_FIELDS = [
{ key: "firstName", label: "First Name", type: "text", section: "personal", required: true, removable: false, isCore: true, sortOrder: 0 },
{ key: "lastName", label: "Last Name", type: "text", section: "personal", required: true, removable: false, isCore: true, sortOrder: 1 },
{ key: "email", label: "Email", type: "email", section: "contact", sortOrder: 2 },
{ key: "cellPhone", label: "Cell Phone", type: "phone", section: "contact", sortOrder: 3 },
{ key: "homePhone", label: "Home Phone", type: "phone", section: "contact", sortOrder: 4 },
{ key: "gender", label: "Gender", type: "select", section: "personal", options: ["Male", "Female"], sortOrder: 5 },
{ key: "dateOfBirth", label: "Date of Birth", type: "date", section: "personal", sortOrder: 6 },
{ key: "maritalStatus", label: "Marital Status", type: "select", section: "personal", options: ["Married", "Single", "Other"], sortOrder: 7 },
{ key: "visitType", label: "Visit Type", type: "select", section: "survey", options: ["First/Second Time Guest", "Update My Information"], sortOrder: 8 },
{ key: "address", label: "Address", type: "text", section: "address", sortOrder: 9 },
{ key: "aptNumber", label: "Apt #", type: "text", section: "address", sortOrder: 10 },
{ key: "city", label: "City", type: "text", section: "address", sortOrder: 11 },
{ key: "state", label: "State", type: "text", section: "address", sortOrder: 12 },
{ key: "zip", label: "Zip", type: "text", section: "address", sortOrder: 13 },
{ key: "prayerRequests", label: "Prayer Requests", type: "textarea", section: "survey", sortOrder: 14 },
{ key: "prayerForTeam", label: "For Prayer Team", type: "checkbox", section: "survey", sortOrder: 15 },
{ key: "prayerConfidential", label: "Confidential", type: "checkbox", section: "survey", sortOrder: 16 },
{ key: "messageTopics", label: "Message Topics", type: "multiselect", section: "survey", options: ["Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt", "Parenting", "Grief & Loss", "Forgiveness", "Finances", "Purpose/Calling", "Prayer", "Healthy Boundaries", "Understanding The Bible", "Emotional Health", "Sharing My Faith", "Decision Making", "Spiritual Disciplines", "Spiritual Gifts"], sortOrder: 17 },
{ key: "nextStep", label: "Next Steps", type: "multiselect", section: "survey", options: ["Baptism", "Next Steps"], sortOrder: 18 },
{ key: "attendanceDuration", label: "Attendance Duration", type: "radio", section: "survey", options: ["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"], sortOrder: 19 },
{ key: "campusPreference", label: "Campus Preference", type: "multiselect", section: "survey", options: ["Beulah", "Pace/Milton", "Gulf Breeze", "Warrington"], sortOrder: 20 },
{ key: "howHeard", label: "How Did You Hear About Us?", type: "multiselect", section: "survey", options: ["This is my church home", "Regular Attender", "Drove by", "Social Media", "Google", "Personal Invite"], sortOrder: 21 },
{ key: "serviceAttended", label: "Service Attended", type: "select", section: "survey", options: ["A", "B", "C", "D"], sortOrder: 22 },
{ key: "followUp", label: "Follow-Up", type: "text", section: "followup", sortOrder: 23 },
{ key: "notes", label: "Notes", type: "textarea", section: "followup", sortOrder: 24 },
{ key: "email", label: "Email", type: "email", section: "contact", isCore: true, sortOrder: 2 },
{ key: "cellPhone", label: "Cell Phone", type: "phone", section: "contact", isCore: true, sortOrder: 3 },
{ key: "homePhone", label: "Home Phone", type: "phone", section: "contact", isCore: true, sortOrder: 4 },
{ key: "gender", label: "Gender", type: "select", section: "personal", options: ["Male", "Female"], isCore: true, sortOrder: 5 },
{ key: "dateOfBirth", label: "Date of Birth", type: "date", section: "personal", isCore: true, sortOrder: 6 },
{ key: "maritalStatus", label: "Marital Status", type: "select", section: "personal", options: ["Married", "Single", "Other"], isCore: true, sortOrder: 7 },
{ key: "visitType", label: "Visit Type", type: "select", section: "survey", options: ["First/Second Time Guest", "Update My Information"], isCore: true, sortOrder: 8 },
{ key: "address", label: "Address", type: "text", section: "address", isCore: true, sortOrder: 9 },
{ key: "aptNumber", label: "Apt #", type: "text", section: "address", isCore: true, sortOrder: 10 },
{ key: "city", label: "City", type: "text", section: "address", isCore: true, sortOrder: 11 },
{ key: "state", label: "State", type: "text", section: "address", isCore: true, sortOrder: 12 },
{ key: "zip", label: "Zip", type: "text", section: "address", isCore: true, sortOrder: 13 },
{ key: "prayerRequests", label: "Prayer Requests", type: "textarea", section: "survey", isCore: true, sortOrder: 14 },
{ key: "prayerForTeam", label: "For Prayer Team", type: "checkbox", section: "survey", isCore: true, sortOrder: 15 },
{ key: "prayerConfidential", label: "Confidential", type: "checkbox", section: "survey", isCore: true, sortOrder: 16 },
{ key: "messageTopics", label: "Message Topics", type: "multiselect", section: "survey", options: ["Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt", "Parenting", "Grief & Loss", "Forgiveness", "Finances", "Purpose/Calling", "Prayer", "Healthy Boundaries", "Understanding The Bible", "Emotional Health", "Sharing My Faith", "Decision Making", "Spiritual Disciplines", "Spiritual Gifts"], isCore: true, sortOrder: 17 },
{ key: "nextStep", label: "Next Steps", type: "multiselect", section: "survey", options: ["Baptism", "Next Steps"], isCore: true, sortOrder: 18 },
{ key: "attendanceDuration", label: "Attendance Duration", type: "radio", section: "survey", options: ["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"], isCore: true, sortOrder: 19 },
{ key: "campusPreference", label: "Campus Preference", type: "multiselect", section: "survey", options: ["Beulah", "Pace/Milton", "Gulf Breeze", "Warrington"], isCore: true, sortOrder: 20 },
{ key: "howHeard", label: "How Did You Hear About Us?", type: "multiselect", section: "survey", options: ["This is my church home", "Regular Attender", "Drove by", "Social Media", "Google", "Personal Invite"], isCore: true, sortOrder: 21 },
{ key: "serviceAttended", label: "Service Attended", type: "select", section: "survey", options: ["A", "B", "C", "D"], isCore: true, sortOrder: 22 },
{ key: "followUp", label: "Follow-Up", type: "text", section: "followup", isCore: true, sortOrder: 23 },
{ key: "notes", label: "Notes", type: "textarea", section: "followup", isCore: true, sortOrder: 24 },
];
export async function seedDefaultTemplate(organizationId: string) {

View file

@ -0,0 +1,32 @@
import { test, expect } from '@playwright/test';
/**
* Smoke tests run against a deployed preview URL.
* BASE_URL is injected by the GitHub Action (PREVIEW_URL).
*
* Add or replace tests here for each critical user path you ship.
* Keep this file fast (<60s total). For deeper E2E, use a separate suite.
*/
const BASE = process.env.BASE_URL ?? 'http://localhost:3000';
test.describe('smoke: app boots and core pages render', () => {
test('home redirects or renders without 5xx', async ({ page }) => {
const response = await page.goto(BASE);
expect(response?.status(), 'home should not 5xx').toBeLessThan(500);
});
test('sign-in page renders', async ({ page }) => {
await page.goto(`${BASE}/login`);
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible({ timeout: 10_000 });
});
test('public health endpoint responds', async ({ request }) => {
const res = await request.get(`${BASE}/api/health`);
expect(res.ok(), `${BASE}/api/health should respond 2xx`).toBeTruthy();
});
});
// Add convoy-specific smoke tests below as features ship. Each new flag-gated
// feature should add a smoke test that exercises the happy path with the flag
// forced on (if your flag wrapper supports query-string overrides).

View file

@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "tests"]
}