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>
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>
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>
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
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
Centralizes role/permission enforcement so each role (owner, admin, editor,
reviewer, viewer) behaves consistently in the API and UI.
- Extend src/lib/permissions.ts with an expanded action map (cards.reprocess,
cards.assign, uploads.create, integrations.manage, etc.) plus helper
predicates (isAdminRole, canEditContent).
- Add requireApiAuthWithPermission(action) to src/lib/api-auth.ts with a
narrowed OrgSession return type and PermissionError -> 403 handling.
- Replace hand-rolled role checks in card, org, integration, form-template,
settings, upload, and location routes with the shared helpers so 403s are
uniform and derived from one permission map.
- Close the editor UI gap: the dashboard upload button, row-level mark
reviewed/reprocess/delete, and card detail edit/reprocess/export/assign
now flow from can(role, action) instead of ad-hoc isAdmin checks.
- Gate /settings/* at the middleware layer for non-admins and hide the
Settings entry in the sidebar and top-bar menu when the role cannot
access it.
- Use isAdminRole() in the team members settings page for consistency.
Made-with: Cursor
The banner was wired off session.user.isEmailVerified, which lives on
the JWT cookie. If a user verified their email in another tab or was
auto-verified by the invite flow after an existing session was issued,
the token kept saying unverified until sign-out, so the banner hung
around indefinitely.
- Add GET /api/auth/verify-email/status, a tiny authenticated endpoint
that returns the authoritative emailVerified state straight from the
DB.
- Banner now fetches that on mount and only renders if the DB agrees
the user is unverified. If the DB says verified but the cookie is
stale, we call useSession().update() to refresh the JWT so the rest
of the app sees the correct state on the next render.
Made-with: Cursor
Rewrite /invite/[token] so invited users can create an account and
join a workspace in a single page instead of bouncing to /signup.
The page branches on verify response + session state:
- New user: inline signup form (name + password, email locked), then
auto signs in and redirects to the dashboard.
- Existing user, signed out: password-only login form that signs in
and auto-calls /api/invitations/accept.
- Existing user, signed in with matching email: single Accept button.
- Existing user, signed in with mismatched email: amber notice plus
one-click "Sign out and continue" that returns to the same invite
URL logged out.
Also:
- Expired / already-accepted / not-found states now show the inviter's
name and org context so users know who to contact for a new link.
- Extract the password + confirm-password + strength meter into a
reusable <PasswordFields> component consumed by both the signup and
invite pages so the UI stays in lockstep. The component supports
hiding the confirm field and overriding autoComplete for login use.
Made-with: Cursor
- /api/invitations/verify now returns inviterName and a structured
failure reason ("not_found" | "expired" | "accepted") plus orgName,
so the invite page can show contextual error messages and know who
to blame for an expired link.
- /api/auth/register marks emailVerified immediately and skips the
verification email when an inviteToken is present, since clicking
the tokenized invite link already proves email ownership.
Made-with: Cursor
Introduces src/lib/site-url.ts with getSiteUrl(), getSiteUrlFromRequest(),
and getSiteHostname() helpers that cascade through NEXT_PUBLIC_SITE_URL,
AUTH_URL, VERCEL_URL, and localhost so no code path ever falls back to a
third-party domain we may not own.
- forgot-password route now derives the base URL from the incoming request
origin so reset links always match the host the user hit
- email-sender, layout metadata, email preview, and QR code routes use the
new helper; email footers display the derived hostname instead of a
hardcoded brand string
- .env.example clarifies the real expected values for AUTH_URL and
NEXT_PUBLIC_SITE_URL per environment
Made-with: Cursor
The Prisma client was constructed eagerly at module load, which called
new URL(process.env.DATABASE_URL!) during Next.js 16 "Collect page data".
When DATABASE_URL wasn't in the build environment (production), this threw
TypeError: Invalid URL { input: 'undefined' } and failed the build for
routes like /api/auth/forgot-password.
Use a Proxy to defer client construction until the first property access,
so build-time module evaluation no longer touches DATABASE_URL.
Made-with: Cursor
Adds a reusable FieldMappingEditor component to the integration
detail page that works across all providers (Monday.com, Google
Sheets, Airtable, Planning Center). Users can visually map card
fields (core + dynamic FormTemplate fields) to external columns.
- New GET /api/integrations/source-fields returns curated core
card fields plus the org's default FormTemplate dynamic fields
(prefixed with fieldData.)
- flattenCardFieldData helper hoists fieldData entries to top-
level keys before pushCard so dynamic fields are mappable
- Monday provider now upserts: re-syncing a card updates the
existing item instead of creating duplicates, and the
mondayItemId is persisted back to the ResponseCard
Made-with: Cursor
- Add jobTitle, company, bio columns to User model (previously localStorage only)
- Replace legacy Authentik GET /api/auth/me with session-based profile endpoint
- Update PUT /api/auth/me to persist all profile fields to DB
- Add PUT /api/auth/change-password endpoint with current password verification
- Add Security card to profile page with change password form
- Update user-profile provider to fetch from API instead of localStorage
Made-with: Cursor
Dreamhost (and most modern hosts) use SFTP (port 22), not FTP (port 21).
The previous implementation only supported FTP/FTPS via basic-ftp, causing
timeouts when connecting to SFTP servers.
Changes:
- Add ssh2-sftp-client for SFTP connections
- Replace ftpTls boolean with ftpProtocol ("sftp" | "ftp" | "ftps") in schema
- Rewrite ftp-watcher.ts to support both SFTP and FTP/FTPS protocols
- Update UI with protocol dropdown that auto-switches the default port
- Add ssh2/ssh2-sftp-client to serverExternalPackages in next.config
- Default to SFTP on port 22
Made-with: Cursor
The FTP client had no timeout, causing Vercel function timeouts on
connection issues. Added a 15-second timeout to both pollFtp and
testFtpConnection. Also added secureOptions.rejectUnauthorized=false
to prevent TLS failures with self-signed or non-standard certificates.
Made-with: Cursor
The settings GET endpoint masks emailImapPass and ftpPass for security.
The test endpoints and save handler were using these masked values, causing
IMAP/FTP test failures and potentially overwriting real passwords. Now the
test endpoints fall back to the DB-stored password when they receive the
masked value, and the save handler strips masked passwords from the payload.
Made-with: Cursor
- Create PATCH/DELETE /api/org/members/[id] for role updates and member removal
with owner/admin guards, self-action prevention, and activeOrgId cleanup
- Create POST/DELETE /api/org/invitations/[id] for resending and revoking invites
with automatic expiry extension on resend
- Filter accepted invitations from GET /api/org/invitations response
- Overhaul Team Members UI with inline role dropdowns, remove buttons,
resend/revoke controls, expired invitation indicators, and admin-only guards
Made-with: Cursor
- Mount UploadModal globally in AppShell so upload works from any page
- Add /forgot-password, /reset-password, /s, /api/survey/submit to public paths in middleware
- Fix IMAP test URL (/api/email/test -> /api/email-watch/test) and body shape
- Fix IMAP scan URL to use /api/email-watch with action body
- Add FTP server settings section to Upload Sources page with test connection
- Add "Surveys" nav item in sidebar with dedicated page showing links, QR codes
- Include org slug in form-templates API response for survey URL construction
- Add pre-creation "Test Connection" button on new integration page
- Create /api/integrations/test endpoint for pre-creation connection testing
- Replace overflow-hidden with overflow-clip on Card to fix click/z-index issues
Made-with: Cursor
Data migration:
- Associate all 978 cards with Echo Life Church organization
- Split name field into firstName/lastName on all cards
- Seed default Connect Card form template with 25 fields
- Migrate legacy column data into fieldData JSON
- Create Echo Life Church location with 3 Sunday services (8:00, 9:30, 11:00)
- Add all users as members of Echo Life Church
- Populate 819 people from card data with dedup matching
Invitation flow fix:
- Add POST /api/invitations/accept for logged-in users to join orgs
- Update /api/invitations/verify to return org name and existing account flag
- Rewrite invite page to handle 3 scenarios:
1. Logged in + email matches: one-click "Accept Invitation" button
2. Logged in + email mismatch: prompt to switch accounts
3. Not logged in + has account: "Sign In to Accept" button
4. Not logged in + new user: "Accept & Create Account" (existing flow)
Made-with: Cursor
Phase 1 - Security & Bug Fixes:
- Add requireApiAuth helper and protect all 25 unprotected API routes
- Add org-tenant scoping to all card, job, stats, and notification queries
- Fix SSRF in ai-test, mask secrets in settings API, fix middleware bypass
- Fix cards pagination routing, stat filter sync, drag-drop file passing
- Add PUT /api/auth/me for profile persistence, stuck job recovery
- Fix email watcher MIME type detection
Phase 2 - Dynamic Fields & Digital Survey:
- Add FormTemplate, FormField, Person, PasswordResetToken models to schema
- Add fieldData, formTemplateId, firstName, lastName, personId to ResponseCard
- Build FormTemplate CRUD API with field management and org scoping
- Build Form Builder UI with field ordering, type config, and section management
- Refactor card detail page to render fields dynamically from templates
- Add dynamic OCR prompt/schema generation from template fields
- Build public survey page at /s/[orgSlug]/[formSlug] with branding
- Add QR code generation API and share section component
Phase 3 - People & Analytics:
- Build People CRUD API with merge and batch auto-link endpoints
- Build People list and detail pages with search, merge dialog
- Add auto-link logic in OCR completion to match/create Person records
- Add /api/stats/trends endpoint with time series and team activity
- Build Reports page with Recharts (area charts, bar charts, pipeline)
- Upgrade dashboard with sparklines and People stat card
Phase 4 - UX Polish:
- Replace silent error handling with toast notifications across all pages
- Add loading skeletons, differentiated empty states
- Add ARIA labels, skip-to-content link, accessible column toggle
- Add forgot password flow, Cmd+K command palette, Collection Days pages
- Unify Echo branding and theme toggle consistency
Made-with: Cursor
The onboarding wizard was wrapped in the dashboard layout (sidebar +
topbar), which felt jarring when coming from the clean workspace-setup
page. Moved it to the (auth) layout so the entire setup flow stays in
the same centered, chrome-free experience. Widened the auth layout to
max-w-3xl and added max-w-md to each narrow auth page individually.
Made-with: Cursor
- Create comprehensive privacy policy covering all data categories:
account info, org data, scanned card PII (20+ field types),
AI processing (OpenAI via Vercel AI Gateway), subprocessors
(Supabase, Vercel, Upstash, Brevo), configurable retention,
data controller/processor roles, and individual rights
- Create terms of service covering eligibility, accounts, acceptable
use, data responsibilities, subscriptions/billing, free tier,
IP, third-party integrations, availability, liability limitation,
indemnification, termination, and governing law
- Add /privacy and /terms to middleware public paths
- Update footer links from placeholder # hrefs to actual pages
- Add metadata layouts for both pages
Made-with: Cursor
- Replace bare inline HTML with a shared emailShell template system
- Use brand colors (warm neutrals), Quicksand font, and rounded card layout
- Add hidden preheader text for better inbox previews
- Proper table-based layout for email client compatibility (Outlook, Gmail, Apple Mail)
- Include Mso conditionals for Outlook button rendering
- Add dev-only email preview route at /api/dev/email-preview (gated behind NODE_ENV)
- Fix invitation email copy bug ("has invited you" / "You've been invited")
Made-with: Cursor
- Update root layout with full Open Graph, Twitter Card, and SEO metadata
- Add OG image screenshot (1200x630) of the landing page hero
- Create layout.tsx with page-specific metadata for each marketing page
(welcome, features index, 8 feature detail pages, pricing)
- Use Next.js title template ("%s | Echo") for consistent page titles
- Add NEXT_PUBLIC_SITE_URL to .env.example for metadataBase config
Made-with: Cursor
After completing onboarding, the JWT cookie still has stale org data.
Fetching /api/auth/session triggers the JWT callback to query the DB
and write the updated cookie before the hard navigate to /.
Made-with: Cursor
The JWT callback was optimized to only query the DB on sign-in or
explicit session update, but the middleware reads the JWT cookie which
was stale after org changes. Reverted to always refreshing org data in
the JWT callback (matching original behavior). Also switched workspace-
setup and onboarding completion to hard navigation to guarantee the
middleware re-evaluates with the latest JWT cookie.
Made-with: Cursor
- Middleware now redirects unauthenticated users to /welcome instead of /login
- Added /features and /pricing to public paths
- Created 8 dedicated feature pages: AI OCR, Scanner Integration, Integrations,
Team Collaboration, Collection Days, Multi-Site, Analytics, Security
- Created features index page at /features with links to all detail pages
- Created standalone pricing page at /pricing with comparison table
- Extracted shared MarketingNav (with features dropdown) and MarketingFooter
- Created FeaturePageShell for consistent feature page layout
- Updated landing page to use shared components and link to feature pages
Made-with: Cursor
- Open registration to all users (remove invite-only gate)
- Add domain auto-join: new users matching org allowedDomains get auto-added
- Track active workspace via activeOrgId on User model
- Refactor JWT callback to resolve active org from all memberships
- Add org switch, list, and create-personal API endpoints
- Add workspace-setup page for users without an org
- Build OrgSwitcher dropdown in sidebar header
- Add allowed email domains management to org settings
Made-with: Cursor
Add /invite and /api/invitations/verify to public paths so invited
users can access the invite page and complete registration without
being redirected to login.
Made-with: Cursor
- Swap nodemailer SMTP transport for @getbrevo/brevo SDK, which uses
HTTP with built-in retries (better for Vercel serverless)
- Add sendInvitationEmail function and wire it into the org invitations
POST route so new invites trigger an email automatically
- Update .env.example with BREVO_API_KEY, EMAIL_FROM_NAME,
EMAIL_FROM_ADDRESS replacing the old SMTP_* vars
Made-with: Cursor
The org membership role is "owner" but UI components only checked for
"admin", hiding action buttons (reprocess, push, export, etc.) for
org owners. Update isAdmin checks in card detail page and dashboard,
plus the delete API route, to include "owner".
Made-with: Cursor
Newer pg versions treat sslmode=require as verify-full, overriding
the programmatic ssl.rejectUnauthorized=false config. Strip sslmode
from the URL and handle SSL entirely via the Pool ssl option.
Made-with: Cursor
Supabase's PgBouncer pooler uses a certificate not in Node's default
CA store, causing "self-signed certificate in certificate chain" errors
on Vercel. Configure pg.Pool with ssl.rejectUnauthorized: false.
Made-with: Cursor
- Replace pdf2pic/GraphicsMagick with pdfjs-dist + @napi-rs/canvas for
Vercel-compatible PDF rasterization
- Replace MinIO with Supabase Storage (S3-compatible); rename minio.ts
to storage.ts and update all imports
- Replace in-memory job queue with Upstash QStash; upload route now
persists files to storage before enqueuing, /api/jobs/process handles
the QStash callback
- Convert email watcher from persistent IMAP connection to stateless
scanInbox() polled by Vercel Cron every 2 minutes
- Add FTP watcher (basic-ftp) with cron polling for scanner integration
via Dreamhost FTP drop directory
- Add FTP config fields to AppSettings schema
- Remove folder watcher (chokidar), standalone output, Docker-only code
- Update next.config.ts, middleware, instrumentation for serverless
- Add vercel.json with cron schedules for email and FTP polling
- Add migration scripts for database (pg_dump/restore) and storage
(S3-to-S3 copy) with verification
Made-with: Cursor
- Create /api/org (GET details, PUT update)
- Create /api/org/members (GET list)
- Create /api/org/invitations (GET list, POST create)
- Create /api/org/locations (GET list with collection days, POST create)
- Fix registration: check org count instead of user count so
a fresh domain setup works even if orphaned users exist
from a prior AUTH_SECRET rotation
Made-with: Cursor
- Middleware now redirects to /onboarding when user has no org
(previously required orgRole=owner which was undefined for new users)
- Refresh JWT cookie after onboarding completes so middleware sees
updated orgId/onboardingComplete on next navigation
- Integrations page shows meaningful error instead of empty grid
when API returns 401 due to missing org
Made-with: Cursor
getToken() defaults secureCookie to false, so it looks for
"authjs.session-token" cookie. Behind Traefik over HTTPS, Auth.js
sets "__Secure-authjs.session-token". Detect HTTPS via
x-forwarded-proto header and pass secureCookie accordingly.
Made-with: Cursor
Add trustHost: true to NextAuth config so Auth.js accepts requests
when running behind Traefik/reverse proxy. Without this, Auth.js
rejects the credential callback because the forwarded host doesn't
match its expectations.
Made-with: Cursor
Next.js requires useSearchParams() to be inside a Suspense boundary
for static page generation. Wraps the new integration page component.
Made-with: Cursor
- Auto-sign-in after registration instead of redirect to login
- Email verification system with token generation, send/confirm API routes, and persistent banner
- 7-step onboarding wizard (org, location, services, upload source, AI, integrations, complete)
- Middleware redirects owners with incomplete onboarding to /onboarding
- Integration provider plugin architecture with registry and 6 providers (Planning Center, Monday.com, Airtable, Google Sheets, Webhook, CSV Export)
- Full integration CRUD API with test, sync, fields, and OAuth authorize/callback routes
- Refactored fireIntegrationEvent to use Integration model with legacy AppSettings fallback
- Migration script for existing Monday.com/webhook config to Integration rows
- Settings page restructured from monolithic 1290-line file into focused sub-routes with section navigation
- Integration hub UI with provider tiles, connect flow, and individual config pages
- Post-onboarding contextual guidance cards on dashboard with dismissible hints
- Schema: Integration model, onboardingComplete/onboardingStep on Organization, dismissedHints on OrgMember
Made-with: Cursor
- Add User model synced from Authentik headers with admin/reviewer/viewer roles
- Add assignment fields (assignedToId, assignedById, reviewedById, etc.) to ResponseCard
- Add userId tracking to ActivityLog and Notification models
- Create auth.ts with getOrCreateUser() and role mapping from Authentik groups
- Create /api/users endpoint and /api/cards/assign batch assignment endpoint
- Gate card mutations behind role checks (viewers read-only, reviewers edit assigned only)
- Gate Monday.com push behind reviewStatus=reviewed instead of ocr_complete
- Add "My Cards" stat card, Assigned To filter, and assignment columns to table
- Add Assign button with user picker to batch selection toolbar (admin only)
- Update card detail: assignment banner, Mark Complete button, prev/next nav, reassign
- Make all field components accept readOnly prop for role-based editing
- Gate settings page behind admin role
- Add userId to stats API for per-user card counts
- Expose dbUser and role through UserProfileProvider context
Made-with: Cursor
- Add scanInbox function that opens a separate IMAP connection and
processes all unseen messages in the configured folder
- Wire to email-watch API as action "scan"
- Add Scan Inbox button to settings page next to Start/Stop Monitoring
- Useful for processing emails that arrived before monitoring started,
since IMAP IDLE only notifies about new messages
Made-with: Cursor
- Add firstTimeGuestDate and salvationDate (DateTime?) to Prisma schema
- Auto-compute dates during OCR: find previous Sunday from card createdAt
when visitType indicates first/second time guest or nextStep includes
Baptism
- Add editable date inputs on card detail page in Workflow section
- Add to Monday.com mappable fields in settings for column mapping
- Add table columns (hidden by default) for both date fields
- Handle full ISO datetime strings in Monday.com date parser
- Apply same logic during reprocessing
Made-with: Cursor
- Sync-all endpoint now supports mode param: "push" (new only), "update"
(existing only), or "all" (both); also accepts cardIds for batch ops
- Response includes separate created/updated/failed counts
- Settings button renamed to "Sync All to Monday.com" and shows breakdown
- Add "Monday.com" button to the floating selection toolbar for syncing
selected cards in batch
Made-with: Cursor
- CSV export now respects column visibility settings, exporting only the
columns the user has toggled on
- Boolean fields export as "Yes"/"No", arrays as comma-separated values
- Toast message shows row and column counts for confirmation
Made-with: Cursor