From f53b08f99f01128700ecd21ea24100b8ba002533 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Thu, 16 Apr 2026 23:29:26 -0500 Subject: [PATCH] Add dynamic fields, people directory, analytics, security hardening, and UX polish 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 --- package-lock.json | 601 +++++++++++- package.json | 3 + prisma/schema.prisma | 115 ++- src/app/(auth)/forgot-password/page.tsx | 92 ++ src/app/(auth)/login/page.tsx | 9 +- src/app/(auth)/onboarding/page.tsx | 6 +- src/app/(auth)/reset-password/page.tsx | 168 ++++ src/app/(auth)/setup/page.tsx | 2 +- src/app/(auth)/signup/page.tsx | 2 +- src/app/(dashboard)/cards/[id]/page.tsx | 342 +++++-- src/app/(dashboard)/events/[id]/page.tsx | 158 +++- src/app/(dashboard)/events/page.tsx | 128 ++- src/app/(dashboard)/page.tsx | 70 +- src/app/(dashboard)/people/[id]/page.tsx | 690 ++++++++++++++ src/app/(dashboard)/people/page.tsx | 402 +++++++- src/app/(dashboard)/profile/page.tsx | 37 +- src/app/(dashboard)/reports/page.tsx | 363 +++++++- .../(dashboard)/settings/forms/[id]/page.tsx | 867 ++++++++++++++++++ src/app/(dashboard)/settings/forms/page.tsx | 347 +++++++ src/app/(dashboard)/settings/general/page.tsx | 18 +- src/app/(dashboard)/settings/layout.tsx | 2 + src/app/api/ai-test/route.ts | 13 +- src/app/api/auth/forgot-password/route.ts | 90 ++ src/app/api/auth/me/route.ts | 21 + src/app/api/auth/register/route.ts | 6 + src/app/api/auth/reset-password/route.ts | 38 + src/app/api/cards/[id]/activity/route.ts | 15 +- src/app/api/cards/[id]/export/route.ts | 10 +- src/app/api/cards/[id]/reprocess/route.ts | 10 +- src/app/api/cards/[id]/route.ts | 59 +- src/app/api/cards/assign/route.ts | 26 +- src/app/api/cards/recover-survey/route.ts | 20 +- src/app/api/cards/reprocess-batch/route.ts | 9 +- src/app/api/cards/route.ts | 32 +- src/app/api/cleanup/route.ts | 28 +- src/app/api/collection-days/[id]/route.ts | 36 + src/app/api/email-watch/poll/route.ts | 3 + src/app/api/email-watch/route.ts | 7 +- src/app/api/email-watch/test/route.ts | 7 +- .../[id]/fields/[fieldId]/route.ts | 102 +++ .../api/form-templates/[id]/fields/route.ts | 144 +++ src/app/api/form-templates/[id]/qr/route.ts | 46 + src/app/api/form-templates/[id]/route.ts | 112 +++ src/app/api/form-templates/route.ts | 131 +++ src/app/api/ftp-watch/poll/route.ts | 3 + src/app/api/ftp-watch/route.ts | 7 +- src/app/api/ftp-watch/test/route.ts | 8 +- src/app/api/images/[...path]/route.ts | 8 +- .../api/integrations/monday/columns/route.ts | 11 +- .../integrations/monday/push/[id]/route.ts | 6 +- .../integrations/monday/subscribe/route.ts | 6 +- .../api/integrations/monday/sync-all/route.ts | 6 +- .../api/integrations/webhook/test/route.ts | 6 +- src/app/api/jobs/route.ts | 11 +- src/app/api/notifications/route.ts | 29 +- src/app/api/people/[id]/merge/route.ts | 70 ++ src/app/api/people/[id]/route.ts | 93 ++ src/app/api/people/link/route.ts | 47 + src/app/api/people/route.ts | 81 ++ src/app/api/settings/route.ts | 22 +- src/app/api/stats/route.ts | 30 +- src/app/api/stats/trends/route.ts | 197 ++++ src/app/api/survey/submit/route.ts | 74 ++ src/app/api/upload/route.ts | 9 +- src/app/api/users/route.ts | 42 +- src/app/s/[orgSlug]/[formSlug]/page.tsx | 84 ++ src/app/s/layout.tsx | 12 + src/components/cards/dashboard-content.tsx | 42 +- src/components/cards/data-table.tsx | 111 ++- src/components/cards/dynamic-field.tsx | 191 ++++ src/components/cards/stat-cards.tsx | 21 +- src/components/cards/upload-modal.tsx | 28 +- src/components/command-palette.tsx | 148 +++ src/components/forms/share-section.tsx | 146 +++ src/components/layout/app-shell.tsx | 9 + src/components/layout/org-switcher.tsx | 17 +- src/components/layout/sidebar.tsx | 3 + src/components/layout/top-bar.tsx | 5 +- src/components/marketing/nav.tsx | 15 +- .../notifications/notification-center.tsx | 68 +- src/components/survey/survey-form.tsx | 373 ++++++++ src/lib/ai-ocr.ts | 89 +- src/lib/api-auth.ts | 33 + src/lib/email-watcher.ts | 5 +- src/lib/form-templates.ts | 65 ++ src/lib/job-recovery.ts | 16 + src/lib/notifications.ts | 21 +- src/lib/ocr.ts | 16 +- src/lib/person-linker.ts | 53 ++ src/lib/user-profile.tsx | 20 +- src/middleware.ts | 7 +- 91 files changed, 7301 insertions(+), 450 deletions(-) create mode 100644 src/app/(auth)/forgot-password/page.tsx create mode 100644 src/app/(auth)/reset-password/page.tsx create mode 100644 src/app/(dashboard)/people/[id]/page.tsx create mode 100644 src/app/(dashboard)/settings/forms/[id]/page.tsx create mode 100644 src/app/(dashboard)/settings/forms/page.tsx create mode 100644 src/app/api/auth/forgot-password/route.ts create mode 100644 src/app/api/auth/reset-password/route.ts create mode 100644 src/app/api/collection-days/[id]/route.ts create mode 100644 src/app/api/form-templates/[id]/fields/[fieldId]/route.ts create mode 100644 src/app/api/form-templates/[id]/fields/route.ts create mode 100644 src/app/api/form-templates/[id]/qr/route.ts create mode 100644 src/app/api/form-templates/[id]/route.ts create mode 100644 src/app/api/form-templates/route.ts create mode 100644 src/app/api/people/[id]/merge/route.ts create mode 100644 src/app/api/people/[id]/route.ts create mode 100644 src/app/api/people/link/route.ts create mode 100644 src/app/api/people/route.ts create mode 100644 src/app/api/stats/trends/route.ts create mode 100644 src/app/api/survey/submit/route.ts create mode 100644 src/app/s/[orgSlug]/[formSlug]/page.tsx create mode 100644 src/app/s/layout.tsx create mode 100644 src/components/cards/dynamic-field.tsx create mode 100644 src/components/command-palette.tsx create mode 100644 src/components/forms/share-section.tsx create mode 100644 src/components/survey/survey-form.tsx create mode 100644 src/lib/api-auth.ts create mode 100644 src/lib/form-templates.ts create mode 100644 src/lib/job-recovery.ts create mode 100644 src/lib/person-linker.ts diff --git a/package-lock.json b/package-lock.json index e2df600..2c181b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", "@tanstack/react-table": "^8.21.3", + "@types/qrcode": "^1.5.6", "@upstash/qstash": "^2.10.1", "ai": "^6.0.116", "basic-ftp": "^5.3.0", @@ -39,8 +40,10 @@ "pdfjs-dist": "^5.6.205", "pg": "^8.20.0", "prisma": "^7.4.2", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", + "recharts": "^3.8.1", "rrule": "^2.8.1", "shadcn": "^4.0.2", "sharp": "^0.34.5", @@ -3969,6 +3972,42 @@ } } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -4745,6 +4784,12 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5151,6 +5196,69 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5200,7 +5308,6 @@ "version": "25.4.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -5218,6 +5325,15 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -5243,6 +5359,12 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -6551,6 +6673,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001777", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", @@ -6944,6 +7075,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -7041,6 +7293,21 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -7210,6 +7477,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -7595,6 +7868,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -8075,6 +8358,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -9093,6 +9382,16 @@ "node": ">=6.0.0" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9140,6 +9439,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -11287,6 +11595,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -11373,7 +11690,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11626,6 +11942,15 @@ "pathe": "^2.0.3" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11933,6 +12258,168 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -12033,9 +12520,31 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -12130,6 +12639,51 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -12207,6 +12761,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -12511,6 +13071,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -13661,7 +14227,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -13868,6 +14433,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -13959,6 +14546,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/package.json b/package.json index c393149..9ec1497 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", "@tanstack/react-table": "^8.21.3", + "@types/qrcode": "^1.5.6", "@upstash/qstash": "^2.10.1", "ai": "^6.0.116", "basic-ftp": "^5.3.0", @@ -43,8 +44,10 @@ "pdfjs-dist": "^5.6.205", "pg": "^8.20.0", "prisma": "^7.4.2", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", + "recharts": "^3.8.1", "rrule": "^2.8.1", "shadcn": "^4.0.2", "sharp": "^0.34.5", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 09b781d..accdc75 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -84,13 +84,15 @@ model Organization { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - locations Location[] - members OrgMember[] - invitations Invitation[] - apiKeys ApiKey[] - cards ResponseCard[] - jobs ProcessingJob[] - integrations Integration[] + locations Location[] + members OrgMember[] + invitations Invitation[] + apiKeys ApiKey[] + cards ResponseCard[] + jobs ProcessingJob[] + integrations Integration[] + formTemplates FormTemplate[] + persons Person[] } model OrgMember { @@ -137,6 +139,81 @@ model CollectionDay { updatedAt DateTime @updatedAt } +// ─── Form Templates (Dynamic Fields) ──────────────────────── + +model FormTemplate { + id String @id @default(cuid()) + organizationId String + name String + slug String + description String? + isDefault Boolean @default(false) + isActive Boolean @default(true) + version Int @default(1) + branding Json? + settings Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + fields FormField[] + cards ResponseCard[] + + @@unique([organizationId, slug]) + @@index([organizationId]) +} + +model FormField { + id String @id @default(cuid()) + formTemplateId String + key String + label String + type String + section String @default("personal") + required Boolean @default(false) + removable Boolean @default(true) + sortOrder Int @default(0) + options Json? + placeholder String? + helpText String? + validation Json? + visibleOnCard Boolean @default(true) + visibleOnSurvey Boolean @default(true) + isCore Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + formTemplate FormTemplate @relation(fields: [formTemplateId], references: [id], onDelete: Cascade) + + @@unique([formTemplateId, key]) + @@index([formTemplateId, sortOrder]) +} + +// ─── People (Contact Directory) ───────────────────────────── + +model Person { + id String @id @default(cuid()) + organizationId String + firstName String + lastName String + email String? + cellPhone String? + fieldData Json? + mergedIntoId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + cards ResponseCard[] + mergedInto Person? @relation("PersonMerge", fields: [mergedIntoId], references: [id]) + mergedFrom Person[] @relation("PersonMerge") + + @@index([organizationId]) + @@index([email]) + @@index([organizationId, firstName, lastName]) + @@index([mergedIntoId]) +} + // ─── Integrations ──────────────────────────────────────────── model Integration { @@ -174,6 +251,18 @@ model Invitation { organization Organization @relation(fields: [organizationId], references: [id]) } +// ─── Password Reset ───────────────────────────────────────── + +model PasswordResetToken { + id String @id @default(cuid()) + email String + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([email]) +} + // ─── System config ─────────────────────────────────────────── model SystemConfig { @@ -191,6 +280,8 @@ model ResponseCard { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + firstName String? + lastName String? name String? gender String? dateOfBirth String? @@ -250,6 +341,11 @@ model ResponseCard { mondayItemId String? + fieldData Json? + formTemplateId String? + personId String? + submissionSource String? + organizationId String? locationId String? collectionDayId String? @@ -258,10 +354,13 @@ model ResponseCard { organization Organization? @relation(fields: [organizationId], references: [id]) location Location? @relation(fields: [locationId], references: [id]) collectionDay CollectionDay? @relation(fields: [collectionDayId], references: [id]) + formTemplate FormTemplate? @relation(fields: [formTemplateId], references: [id]) + person Person? @relation(fields: [personId], references: [id]) @@index([ocrStatus]) @@index([reviewStatus]) @@index([name]) + @@index([firstName, lastName]) @@index([createdAt]) @@index([mondayItemId]) @@index([assignedToId]) @@ -269,6 +368,8 @@ model ResponseCard { @@index([organizationId]) @@index([locationId]) @@index([collectionDayId]) + @@index([formTemplateId]) + @@index([personId]) } model ProcessingJob { diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..35e001c --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ScanLine, Mail, Loader2, ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + + await fetch("/api/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + setSent(true); + setLoading(false); + } + + return ( +
+
+
+ +
+

Reset your password

+

+ Enter your email and we'll send you a reset link +

+
+ + {sent ? ( +
+
+ If an account with that email exists, we've sent a password + reset link. Check your inbox. +
+ + + Back to login + +
+ ) : ( +
+
+ +
+ + setEmail(e.target.value)} + required + autoComplete="email" + className="pl-10" + /> +
+
+ + + +

+ + + Back to login + +

+
+ )} +
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 8f55335..c532932 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -60,7 +60,7 @@ function LoginForm() {

Welcome back

- Sign in to your Echo OCR account + Sign in to your Echo account

@@ -89,7 +89,12 @@ function LoginForm() {
- +
+ + + Forgot password? + +

- Get Echo OCR ready in just a few steps + Get Echo ready in just a few steps

@@ -587,7 +587,7 @@ export default function OnboardingPage() {

Upload Source

- How will response cards get into Echo OCR? + How will response cards get into Echo?

@@ -746,7 +746,7 @@ export default function OnboardingPage() {

AI Provider

- Choose how Echo OCR processes your scanned cards. + Choose how Echo processes your scanned cards.

diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..b7dde50 --- /dev/null +++ b/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { Suspense, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { ScanLine, Lock, Loader2, ArrowLeft, CheckCircle2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function ResetPasswordPage() { + return ( + + + + ); +} + +function ResetForm() { + const searchParams = useSearchParams(); + const router = useRouter(); + const token = searchParams.get("token"); + + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (password.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + if (password !== confirm) { + setError("Passwords do not match"); + return; + } + + setLoading(true); + + const res = await fetch("/api/auth/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || "Something went wrong. Please try again."); + setLoading(false); + return; + } + + setSuccess(true); + setLoading(false); + } + + if (!token) { + return ( +
+

+ Invalid or missing reset token. +

+ + Request a new reset link + +
+ ); + } + + return ( +
+
+
+ +
+

Choose a new password

+

+ Enter your new password below +

+
+ + {success ? ( +
+
+ +

Password updated successfully!

+
+ +
+ ) : ( + <> + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + autoComplete="new-password" + className="pl-10" + /> +
+
+ +
+ +
+ + setConfirm(e.target.value)} + required + minLength={8} + autoComplete="new-password" + className="pl-10" + /> +
+
+ + + +

+ + + Back to login + +

+
+ + )} +
+ ); +} diff --git a/src/app/(auth)/setup/page.tsx b/src/app/(auth)/setup/page.tsx index a89d0cd..8ce40fe 100644 --- a/src/app/(auth)/setup/page.tsx +++ b/src/app/(auth)/setup/page.tsx @@ -122,7 +122,7 @@ export default function SetupPage() {
-

Set up Echo OCR

+

Set up Echo

Configure your instance in a few steps

diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 89741b1..3dc1445 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -90,7 +90,7 @@ function SignupForm() {

{token ? "Complete your account setup" - : "Get started with Echo OCR"} + : "Get started with Echo"}

diff --git a/src/app/(dashboard)/cards/[id]/page.tsx b/src/app/(dashboard)/cards/[id]/page.tsx index c91e03c..04c4540 100644 --- a/src/app/(dashboard)/cards/[id]/page.tsx +++ b/src/app/(dashboard)/cards/[id]/page.tsx @@ -47,6 +47,27 @@ import { import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import { useUserProfile } from "@/lib/user-profile"; +import { DynamicField, type FormFieldDef } from "@/components/cards/dynamic-field"; + +const SECTION_LABELS: Record = { + personal: "Personal Information", + contact: "Contact Details", + address: "Address", + survey: "Survey Responses", + followup: "Follow-Up", + custom: "Custom Fields", +}; + +const SECTION_ORDER = ["personal", "contact", "address", "survey", "followup", "custom"]; + +const SECTION_ICONS: Record = { + personal: "user", + contact: "user", + address: "user", + survey: "clipboardList", + followup: "clipboardCheck", + custom: "clipboardList", +}; const MESSAGE_TOPIC_OPTIONS = [ "Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt", @@ -64,8 +85,16 @@ const HOW_HEARD_OPTIONS = [ "Social Media", "Google", "Personal Invite", ]; +type TemplateData = { + id: string; + name: string; + fields: FormFieldDef[]; +}; + type CardData = { id: string; + firstName: string | null; + lastName: string | null; name: string | null; gender: string | null; dateOfBirth: string | null; @@ -114,6 +143,9 @@ type CardData = { mondayItemId: string | null; frontImageUrl: string | null; backImageUrl: string | null; + fieldData: Record | null; + formTemplateId: string | null; + formTemplate?: TemplateData | null; }; type ActivityEntry = { @@ -137,6 +169,7 @@ export default function CardDetailPage() { const isViewer = role === "viewer"; const [card, setCard] = React.useState(null); + const [loadError, setLoadError] = React.useState(false); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [reprocessing, setReprocessing] = React.useState(false); @@ -149,6 +182,8 @@ export default function CardDetailPage() { const [pushingToMonday, setPushingToMonday] = React.useState(false); const [users, setUsers] = React.useState([]); const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null }); + const [templateFields, setTemplateFields] = React.useState(null); + const [fieldDataEdits, setFieldDataEdits] = React.useState>({}); const isAssignedToMe = card?.assignedToId && userId === card.assignedToId; const canEdit = isAdmin || (isReviewer && isAssignedToMe); @@ -156,13 +191,27 @@ export default function CardDetailPage() { const fetchCard = React.useCallback(async () => { setLoading(true); + setLoadError(false); try { const res = await fetch(`/api/cards/${id}`); if (!res.ok) throw new Error(); const data = await res.json(); setCard(data); setEdits({}); + setFieldDataEdits({}); + + if (data.formTemplate?.fields) { + setTemplateFields(data.formTemplate.fields); + } else if (!data.formTemplateId) { + fetch("/api/form-templates?default=true") + .then((r) => r.json()) + .then((d) => { + if (d.template?.fields) setTemplateFields(d.template.fields); + }) + .catch(() => {}); + } } catch { + setLoadError(true); toast.error("Failed to load card"); } finally { setLoading(false); @@ -253,11 +302,62 @@ export default function CardDetailPage() { setEdits((prev) => ({ ...prev, [field]: value })); }; + const getDynamicValue = React.useCallback( + (field: FormFieldDef): unknown => { + if (field.isCore) { + const key = field.key; + if (key in edits) return edits[key]; + return (card as unknown as Record)?.[key] ?? null; + } + if (field.key in fieldDataEdits) return fieldDataEdits[field.key]; + const fd = card?.fieldData as Record | null; + if (fd && field.key in fd) return fd[field.key]; + if (card && field.key in (card as unknown as Record)) { + return (card as unknown as Record)[field.key]; + } + return null; + }, + [card, edits, fieldDataEdits] + ); + + const setDynamicValue = React.useCallback( + (field: FormFieldDef, value: unknown) => { + if (field.isCore) { + setEdits((prev) => ({ ...prev, [field.key]: value })); + } else { + setFieldDataEdits((prev) => ({ ...prev, [field.key]: value })); + } + }, + [] + ); + + const dynamicSections = React.useMemo(() => { + if (!templateFields) return null; + const grouped: Record = {}; + for (const field of templateFields) { + if (!grouped[field.section]) grouped[field.section] = []; + grouped[field.section].push(field); + } + return SECTION_ORDER + .filter((s) => grouped[s]?.length > 0) + .map((s) => ({ + key: s, + label: SECTION_LABELS[s] || s, + iconKey: SECTION_ICONS[s] || "clipboardList", + fields: grouped[s], + })); + }, [templateFields]); + const handleSave = async () => { - if (Object.keys(edits).length === 0) return; + const hasFieldDataChanges = Object.keys(fieldDataEdits).length > 0; + if (Object.keys(edits).length === 0 && !hasFieldDataChanges) return; setSaving(true); try { const payload: Record = { ...edits }; + if (hasFieldDataChanges) { + const currentFieldData = (card?.fieldData as Record) || {}; + payload.fieldData = { ...currentFieldData, ...fieldDataEdits }; + } if (card?.reviewStatus === "assigned") { payload.reviewStatus = "in_review"; } @@ -286,6 +386,10 @@ export default function CardDetailPage() { ...edits, reviewStatus: "reviewed", }; + if (Object.keys(fieldDataEdits).length > 0) { + const currentFieldData = (card?.fieldData as Record) || {}; + payload.fieldData = { ...currentFieldData, ...fieldDataEdits }; + } const res = await fetch(`/api/cards/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -399,10 +503,28 @@ export default function CardDetailPage() { if (!card) { return (
-
- +
+
+ +

+ {loadError ? "Something went wrong" : "This card doesn't exist"} +

+

+ {loadError + ? "We couldn't load this card. Check your connection and try again." + : "The card you're looking for may have been deleted or the link is incorrect."} +

+
+ {loadError && ( + + )} + +
+
); } @@ -410,7 +532,7 @@ export default function CardDetailPage() { const confidence = card.ocrConfidence; const ocrStatus = card.ocrStatus; const reviewStatus = card.reviewStatus; - const hasEdits = Object.keys(edits).length > 0; + const hasEdits = Object.keys(edits).length > 0 || Object.keys(fieldDataEdits).length > 0; return (
@@ -552,92 +674,130 @@ export default function CardDetailPage() { - - - - - Personal Information - - - -
- setField("name", v)} readOnly={!canEdit} /> - setField("email", v)} readOnly={!canEdit} /> - setField("cellPhone", v)} readOnly={!canEdit} /> - setField("homePhone", v)} readOnly={!canEdit} /> - setField("gender", v)} readOnly={!canEdit} /> - setField("dateOfBirth", v)} readOnly={!canEdit} /> - setField("maritalStatus", v)} readOnly={!canEdit} /> - setField("visitType", v)} readOnly={!canEdit} /> -
- -
- setField("address", v)} readOnly={!canEdit} /> - setField("aptNumber", v)} readOnly={!canEdit} /> - setField("city", v)} readOnly={!canEdit} /> - setField("state", v)} readOnly={!canEdit} /> - setField("zip", v)} readOnly={!canEdit} /> -
- -
- -