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
This commit is contained in:
Randall Stillwell 2026-04-16 23:29:26 -05:00
parent 1bceeb0418
commit f53b08f99f
91 changed files with 7301 additions and 450 deletions

601
package-lock.json generated
View file

@ -20,6 +20,7 @@
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/qrcode": "^1.5.6",
"@upstash/qstash": "^2.10.1", "@upstash/qstash": "^2.10.1",
"ai": "^6.0.116", "ai": "^6.0.116",
"basic-ftp": "^5.3.0", "basic-ftp": "^5.3.0",
@ -39,8 +40,10 @@
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.6.205",
"pg": "^8.20.0", "pg": "^8.20.0",
"prisma": "^7.4.2", "prisma": "^7.4.2",
"qrcode": "^1.5.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"recharts": "^3.8.1",
"rrule": "^2.8.1", "rrule": "^2.8.1",
"shadcn": "^4.0.2", "shadcn": "^4.0.2",
"sharp": "^0.34.5", "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": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -4745,6 +4784,12 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT" "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": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -5151,6 +5196,69 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -5200,7 +5308,6 @@
"version": "25.4.0", "version": "25.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
@ -5218,6 +5325,15 @@
"pg-types": "^2.2.0" "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": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -5243,6 +5359,12 @@
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
"license": "MIT" "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": { "node_modules/@types/validate-npm-package-name": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "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": ">=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": { "node_modules/caniuse-lite": {
"version": "1.0.30001777", "version": "1.0.30001777",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
@ -6944,6 +7075,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "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": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "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": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@ -7210,6 +7477,12 @@
"node": ">=0.3.1" "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": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -7595,6 +7868,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -8075,6 +8358,12 @@
"node": ">= 0.6" "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": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@ -9093,6 +9382,16 @@
"node": ">=6.0.0" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -9140,6 +9439,15 @@
"node": ">= 0.4" "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": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@ -11287,6 +11595,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/package-manager-detector": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
@ -11373,7 +11690,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -11626,6 +11942,15 @@
"pathe": "^2.0.3" "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": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -11933,6 +12258,168 @@
], ],
"license": "MIT" "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": { "node_modules/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@ -12033,9 +12520,31 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "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": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@ -12130,6 +12639,51 @@
"node": ">= 4" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -12207,6 +12761,12 @@
"node": ">=0.10.0" "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": { "node_modules/reselect": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@ -12511,6 +13071,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@ -13661,7 +14227,6 @@
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicorn-magic": { "node_modules/unicorn-magic": {
@ -13868,6 +14433,28 @@
"node": ">= 0.8" "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": { "node_modules/web-streams-polyfill": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "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" "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": { "node_modules/which-typed-array": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",

View file

@ -24,6 +24,7 @@
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/qrcode": "^1.5.6",
"@upstash/qstash": "^2.10.1", "@upstash/qstash": "^2.10.1",
"ai": "^6.0.116", "ai": "^6.0.116",
"basic-ftp": "^5.3.0", "basic-ftp": "^5.3.0",
@ -43,8 +44,10 @@
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.6.205",
"pg": "^8.20.0", "pg": "^8.20.0",
"prisma": "^7.4.2", "prisma": "^7.4.2",
"qrcode": "^1.5.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"recharts": "^3.8.1",
"rrule": "^2.8.1", "rrule": "^2.8.1",
"shadcn": "^4.0.2", "shadcn": "^4.0.2",
"sharp": "^0.34.5", "sharp": "^0.34.5",

View file

@ -91,6 +91,8 @@ model Organization {
cards ResponseCard[] cards ResponseCard[]
jobs ProcessingJob[] jobs ProcessingJob[]
integrations Integration[] integrations Integration[]
formTemplates FormTemplate[]
persons Person[]
} }
model OrgMember { model OrgMember {
@ -137,6 +139,81 @@ model CollectionDay {
updatedAt DateTime @updatedAt 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 ──────────────────────────────────────────── // ─── Integrations ────────────────────────────────────────────
model Integration { model Integration {
@ -174,6 +251,18 @@ model Invitation {
organization Organization @relation(fields: [organizationId], references: [id]) 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 ─────────────────────────────────────────── // ─── System config ───────────────────────────────────────────
model SystemConfig { model SystemConfig {
@ -191,6 +280,8 @@ model ResponseCard {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
firstName String?
lastName String?
name String? name String?
gender String? gender String?
dateOfBirth String? dateOfBirth String?
@ -250,6 +341,11 @@ model ResponseCard {
mondayItemId String? mondayItemId String?
fieldData Json?
formTemplateId String?
personId String?
submissionSource String?
organizationId String? organizationId String?
locationId String? locationId String?
collectionDayId String? collectionDayId String?
@ -258,10 +354,13 @@ model ResponseCard {
organization Organization? @relation(fields: [organizationId], references: [id]) organization Organization? @relation(fields: [organizationId], references: [id])
location Location? @relation(fields: [locationId], references: [id]) location Location? @relation(fields: [locationId], references: [id])
collectionDay CollectionDay? @relation(fields: [collectionDayId], 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([ocrStatus])
@@index([reviewStatus]) @@index([reviewStatus])
@@index([name]) @@index([name])
@@index([firstName, lastName])
@@index([createdAt]) @@index([createdAt])
@@index([mondayItemId]) @@index([mondayItemId])
@@index([assignedToId]) @@index([assignedToId])
@ -269,6 +368,8 @@ model ResponseCard {
@@index([organizationId]) @@index([organizationId])
@@index([locationId]) @@index([locationId])
@@index([collectionDayId]) @@index([collectionDayId])
@@index([formTemplateId])
@@index([personId])
} }
model ProcessingJob { model ProcessingJob {

View file

@ -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 (
<div className="glass-card mx-auto w-full max-w-md rounded-2xl p-8">
<div className="mb-8 flex flex-col items-center">
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
<ScanLine className="size-7 text-white" />
</div>
<h1 className="text-2xl font-bold tracking-tight">Reset your password</h1>
<p className="mt-1 text-sm text-muted-foreground">
Enter your email and we&apos;ll send you a reset link
</p>
</div>
{sent ? (
<div className="space-y-4 text-center">
<div className="rounded-lg bg-primary/10 p-4 text-sm text-foreground">
If an account with that email exists, we&apos;ve sent a password
reset link. Check your inbox.
</div>
<Link
href="/login"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
<ArrowLeft className="size-3.5" />
Back to login
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@church.org"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="pl-10"
/>
</div>
</div>
<Button type="submit" className="w-full rounded-xl" disabled={loading}>
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
Send Reset Link
</Button>
<p className="text-center">
<Link
href="/login"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3" />
Back to login
</Link>
</p>
</form>
)}
</div>
);
}

View file

@ -60,7 +60,7 @@ function LoginForm() {
</div> </div>
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1> <h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Sign in to your Echo OCR account Sign in to your Echo account
</p> </p>
</div> </div>
@ -89,7 +89,12 @@ function LoginForm() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<Link href="/forgot-password" className="text-xs text-primary hover:underline">
Forgot password?
</Link>
</div>
<div className="relative"> <div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input

View file

@ -202,7 +202,7 @@ export default function OnboardingPage() {
Set up your workspace Set up your workspace
</h1> </h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Get Echo OCR ready in just a few steps Get Echo ready in just a few steps
</p> </p>
</div> </div>
@ -587,7 +587,7 @@ export default function OnboardingPage() {
<div> <div>
<h2 className="text-xl font-semibold">Upload Source</h2> <h2 className="text-xl font-semibold">Upload Source</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
How will response cards get into Echo OCR? How will response cards get into Echo?
</p> </p>
</div> </div>
@ -746,7 +746,7 @@ export default function OnboardingPage() {
<div> <div>
<h2 className="text-xl font-semibold">AI Provider</h2> <h2 className="text-xl font-semibold">AI Provider</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Choose how Echo OCR processes your scanned cards. Choose how Echo processes your scanned cards.
</p> </p>
</div> </div>

View file

@ -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 (
<Suspense>
<ResetForm />
</Suspense>
);
}
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 (
<div className="glass-card mx-auto w-full max-w-md rounded-2xl p-8 text-center">
<p className="text-sm text-muted-foreground">
Invalid or missing reset token.
</p>
<Link
href="/forgot-password"
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Request a new reset link
</Link>
</div>
);
}
return (
<div className="glass-card mx-auto w-full max-w-md rounded-2xl p-8">
<div className="mb-8 flex flex-col items-center">
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
<ScanLine className="size-7 text-white" />
</div>
<h1 className="text-2xl font-bold tracking-tight">Choose a new password</h1>
<p className="mt-1 text-sm text-muted-foreground">
Enter your new password below
</p>
</div>
{success ? (
<div className="space-y-4 text-center">
<div className="flex flex-col items-center gap-2 rounded-lg bg-primary/10 p-4">
<CheckCircle2 className="size-6 text-primary" />
<p className="text-sm font-medium">Password updated successfully!</p>
</div>
<Button
className="w-full rounded-xl"
onClick={() => router.push("/login")}
>
Sign in with new password
</Button>
</div>
) : (
<>
{error && (
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-center text-sm text-destructive">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New password</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Confirm password</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="confirm"
type="password"
placeholder="Re-enter your password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="pl-10"
/>
</div>
</div>
<Button type="submit" className="w-full rounded-xl" disabled={loading}>
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
Reset Password
</Button>
<p className="text-center">
<Link
href="/login"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3" />
Back to login
</Link>
</p>
</form>
</>
)}
</div>
);
}

View file

@ -122,7 +122,7 @@ export default function SetupPage() {
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg"> <div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
<ScanLine className="size-7 text-white" /> <ScanLine className="size-7 text-white" />
</div> </div>
<h1 className="text-2xl font-bold tracking-tight">Set up Echo OCR</h1> <h1 className="text-2xl font-bold tracking-tight">Set up Echo</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Configure your instance in a few steps Configure your instance in a few steps
</p> </p>

View file

@ -90,7 +90,7 @@ function SignupForm() {
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{token {token
? "Complete your account setup" ? "Complete your account setup"
: "Get started with Echo OCR"} : "Get started with Echo"}
</p> </p>
</div> </div>

View file

@ -47,6 +47,27 @@ import {
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useUserProfile } from "@/lib/user-profile"; import { useUserProfile } from "@/lib/user-profile";
import { DynamicField, type FormFieldDef } from "@/components/cards/dynamic-field";
const SECTION_LABELS: Record<string, string> = {
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<string, string> = {
personal: "user",
contact: "user",
address: "user",
survey: "clipboardList",
followup: "clipboardCheck",
custom: "clipboardList",
};
const MESSAGE_TOPIC_OPTIONS = [ const MESSAGE_TOPIC_OPTIONS = [
"Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt", "Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt",
@ -64,8 +85,16 @@ const HOW_HEARD_OPTIONS = [
"Social Media", "Google", "Personal Invite", "Social Media", "Google", "Personal Invite",
]; ];
type TemplateData = {
id: string;
name: string;
fields: FormFieldDef[];
};
type CardData = { type CardData = {
id: string; id: string;
firstName: string | null;
lastName: string | null;
name: string | null; name: string | null;
gender: string | null; gender: string | null;
dateOfBirth: string | null; dateOfBirth: string | null;
@ -114,6 +143,9 @@ type CardData = {
mondayItemId: string | null; mondayItemId: string | null;
frontImageUrl: string | null; frontImageUrl: string | null;
backImageUrl: string | null; backImageUrl: string | null;
fieldData: Record<string, unknown> | null;
formTemplateId: string | null;
formTemplate?: TemplateData | null;
}; };
type ActivityEntry = { type ActivityEntry = {
@ -137,6 +169,7 @@ export default function CardDetailPage() {
const isViewer = role === "viewer"; const isViewer = role === "viewer";
const [card, setCard] = React.useState<CardData | null>(null); const [card, setCard] = React.useState<CardData | null>(null);
const [loadError, setLoadError] = React.useState(false);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [reprocessing, setReprocessing] = React.useState(false); const [reprocessing, setReprocessing] = React.useState(false);
@ -149,6 +182,8 @@ export default function CardDetailPage() {
const [pushingToMonday, setPushingToMonday] = React.useState(false); const [pushingToMonday, setPushingToMonday] = React.useState(false);
const [users, setUsers] = React.useState<AssignableUser[]>([]); const [users, setUsers] = React.useState<AssignableUser[]>([]);
const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null }); const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null });
const [templateFields, setTemplateFields] = React.useState<FormFieldDef[] | null>(null);
const [fieldDataEdits, setFieldDataEdits] = React.useState<Record<string, unknown>>({});
const isAssignedToMe = card?.assignedToId && userId === card.assignedToId; const isAssignedToMe = card?.assignedToId && userId === card.assignedToId;
const canEdit = isAdmin || (isReviewer && isAssignedToMe); const canEdit = isAdmin || (isReviewer && isAssignedToMe);
@ -156,13 +191,27 @@ export default function CardDetailPage() {
const fetchCard = React.useCallback(async () => { const fetchCard = React.useCallback(async () => {
setLoading(true); setLoading(true);
setLoadError(false);
try { try {
const res = await fetch(`/api/cards/${id}`); const res = await fetch(`/api/cards/${id}`);
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const data = await res.json(); const data = await res.json();
setCard(data); setCard(data);
setEdits({}); 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 { } catch {
setLoadError(true);
toast.error("Failed to load card"); toast.error("Failed to load card");
} finally { } finally {
setLoading(false); setLoading(false);
@ -253,11 +302,62 @@ export default function CardDetailPage() {
setEdits((prev) => ({ ...prev, [field]: value })); 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<string, unknown>)?.[key] ?? null;
}
if (field.key in fieldDataEdits) return fieldDataEdits[field.key];
const fd = card?.fieldData as Record<string, unknown> | null;
if (fd && field.key in fd) return fd[field.key];
if (card && field.key in (card as unknown as Record<string, unknown>)) {
return (card as unknown as Record<string, unknown>)[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<string, FormFieldDef[]> = {};
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 () => { 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); setSaving(true);
try { try {
const payload: Record<string, unknown> = { ...edits }; const payload: Record<string, unknown> = { ...edits };
if (hasFieldDataChanges) {
const currentFieldData = (card?.fieldData as Record<string, unknown>) || {};
payload.fieldData = { ...currentFieldData, ...fieldDataEdits };
}
if (card?.reviewStatus === "assigned") { if (card?.reviewStatus === "assigned") {
payload.reviewStatus = "in_review"; payload.reviewStatus = "in_review";
} }
@ -286,6 +386,10 @@ export default function CardDetailPage() {
...edits, ...edits,
reviewStatus: "reviewed", reviewStatus: "reviewed",
}; };
if (Object.keys(fieldDataEdits).length > 0) {
const currentFieldData = (card?.fieldData as Record<string, unknown>) || {};
payload.fieldData = { ...currentFieldData, ...fieldDataEdits };
}
const res = await fetch(`/api/cards/${id}`, { const res = await fetch(`/api/cards/${id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -399,10 +503,28 @@ export default function CardDetailPage() {
if (!card) { if (!card) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Header title="Card Not Found" icon={ScanLine} /> <Header title={loadError ? "Failed to Load Card" : "Card Not Found"} icon={loadError ? AlertCircle : ScanLine} />
<Button variant="outline" className="rounded-xl" onClick={() => router.push("/")}> <div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<ArrowLeft className="mr-2 size-4" /> Back to Dashboard <AlertCircle className="mb-4 size-12 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">
{loadError ? "Something went wrong" : "This card doesn't exist"}
</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{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."}
</p>
<div className="mt-6 flex gap-3">
{loadError && (
<Button variant="outline" className="rounded-xl" onClick={fetchCard}>
<RefreshCw className="mr-2 size-4" /> Retry
</Button> </Button>
)}
<Button variant="outline" className="rounded-xl" onClick={() => router.push("/cards")}>
<ArrowLeft className="mr-2 size-4" /> Go back to cards
</Button>
</div>
</div>
</div> </div>
); );
} }
@ -410,7 +532,7 @@ export default function CardDetailPage() {
const confidence = card.ocrConfidence; const confidence = card.ocrConfidence;
const ocrStatus = card.ocrStatus; const ocrStatus = card.ocrStatus;
const reviewStatus = card.reviewStatus; const reviewStatus = card.reviewStatus;
const hasEdits = Object.keys(edits).length > 0; const hasEdits = Object.keys(edits).length > 0 || Object.keys(fieldDataEdits).length > 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -552,6 +674,42 @@ export default function CardDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{dynamicSections ? (
dynamicSections.map((section) => {
const IconComp =
section.iconKey === "user" ? User :
section.iconKey === "clipboardCheck" ? ClipboardCheck :
ClipboardList;
const isWide = section.key === "survey" || section.key === "custom";
return (
<Card key={section.key} variant="glass" className={isWide ? "xl:col-span-2" : ""}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<IconComp className="size-4 text-primary" />
{section.label}
</CardTitle>
</CardHeader>
<CardContent>
<div className={cn(
"grid gap-4 grid-cols-1 sm:grid-cols-2",
isWide && "lg:grid-cols-3"
)}>
{section.fields.map((field) => (
<DynamicField
key={field.id}
field={field}
value={getDynamicValue(field)}
onChange={(v) => setDynamicValue(field, v)}
readOnly={!canEdit}
/>
))}
</div>
</CardContent>
</Card>
);
})
) : (
<>
<Card variant="glass"> <Card variant="glass">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
@ -638,6 +796,8 @@ export default function CardDetailPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</>
)}
<Card variant="glass" className="xl:col-span-2"> <Card variant="glass" className="xl:col-span-2">
<CardHeader> <CardHeader>

View file

@ -1,17 +1,163 @@
import { CalendarDays } from "lucide-react"; "use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
CalendarDays,
MapPin,
CreditCard,
Clock,
ArrowLeft,
Loader2,
RefreshCw,
} from "lucide-react";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
type CollectionDay = {
id: string;
name: string;
description: string | null;
dayOfWeek: number | null;
timeStart: string | null;
timeEnd: string | null;
isRecurring: boolean;
isActive: boolean;
date: string | null;
rrule: string | null;
location: { id: string; name: string };
_count: { cards: number };
};
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export default function EventDetailPage() { export default function EventDetailPage() {
const { id } = useParams<{ id: string }>();
const [day, setDay] = useState<CollectionDay | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
fetch(`/api/collection-days/${id}`)
.then((r) => {
if (!r.ok) throw new Error("not found");
return r.json();
})
.then(setDay)
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [id]);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (notFound || !day) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Header <Header
title="Event Detail" title="Not Found"
description="View cards assigned to this event" description="This collection day doesn't exist or you don't have access."
icon={CalendarDays} icon={CalendarDays}
/> />
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground"> <Button render={<Link href="/events" />} variant="outline" size="sm" className="rounded-xl">
Event detail view coming soon. <ArrowLeft className="size-4" />
Back to Collection Days
</Button>
</div>
);
}
return (
<div className="space-y-6">
<Header
title={day.name}
description={day.description || "Collection day details"}
icon={CalendarDays}
>
<Button render={<Link href="/events" />} variant="outline" size="sm" className="rounded-xl">
<ArrowLeft className="size-4" />
All Collection Days
</Button>
</Header>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoCard
icon={MapPin}
label="Location"
value={day.location.name}
/>
<InfoCard
icon={CalendarDays}
label="Day"
value={
day.dayOfWeek != null
? DAY_NAMES[day.dayOfWeek]
: day.date
? new Date(day.date).toLocaleDateString()
: "Not set"
}
/>
<InfoCard
icon={Clock}
label="Time"
value={
day.timeStart
? `${day.timeStart}${day.timeEnd ? ` ${day.timeEnd}` : ""}`
: "Not set"
}
/>
<InfoCard
icon={CreditCard}
label="Linked Cards"
value={String(day._count.cards)}
/>
</div>
<div className="glass-card rounded-2xl p-5">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={day.isActive ? "default" : "secondary"}>
{day.isActive ? "Active" : "Inactive"}
</Badge>
{day.isRecurring && (
<Badge variant="outline">
<RefreshCw className="size-3" />
Recurring
</Badge>
)}
{day.rrule && (
<span className="text-xs text-muted-foreground">
Rule: {day.rrule}
</span>
)}
</div>
</div> </div>
</div> </div>
); );
} }
function InfoCard({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
}) {
return (
<div className="glass-card rounded-2xl p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Icon className="size-3.5" />
{label}
</div>
<p className="mt-1 text-lg font-semibold tracking-tight">{value}</p>
</div>
);
}

View file

@ -1,22 +1,130 @@
import { CalendarDays } from "lucide-react"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
CalendarDays,
MapPin,
CreditCard,
Plus,
Loader2,
Settings,
} from "lucide-react";
import { toast } from "sonner";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
type CollectionDay = {
id: string;
name: string;
description: string | null;
dayOfWeek: number | null;
timeStart: string | null;
timeEnd: string | null;
isRecurring: boolean;
isActive: boolean;
location: { id: string; name: string };
_count: { cards: number };
};
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export default function EventsPage() { export default function EventsPage() {
const [days, setDays] = useState<CollectionDay[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/collection-days")
.then((r) => r.json())
.then((data) => {
setDays(Array.isArray(data) ? data : []);
setLoading(false);
})
.catch(() => {
toast.error("Failed to load collection days");
setLoading(false);
});
}, []);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Header <Header
title="Collection Days" title="Collection Days"
description="Manage recurring events and service times" description="Manage recurring events and service times"
icon={CalendarDays} icon={CalendarDays}
/> >
<Button render={<Link href="/settings/locations" />} size="sm" className="rounded-xl">
<Plus className="size-4" />
Create Collection Day
</Button>
</Header>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : days.length === 0 ? (
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center"> <div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<CalendarDays className="mb-4 size-12 text-muted-foreground/40" /> <CalendarDays className="mb-4 size-12 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">No collection days yet</h2> <h2 className="text-lg font-semibold">No collection days configured</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground"> <p className="mt-1 max-w-sm text-sm text-muted-foreground">
Collection days will be available once organizations and locations are Set up your locations and service schedule to start organizing
configured. Check back after setup is complete. response cards by collection day.
</p> </p>
<Button
render={<Link href="/settings/locations" />}
size="sm"
className="mt-6 rounded-xl"
>
<Settings className="mr-1 size-4" />
Configure Locations
</Button>
</div> </div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{days.map((day) => (
<Link
key={day.id}
href={`/events/${day.id}`}
className="glass-card group rounded-2xl p-5 transition-shadow hover:shadow-lg"
>
<div className="flex items-start justify-between gap-3">
<h3 className="font-semibold tracking-tight group-hover:text-primary transition-colors">
{day.name}
</h3>
<Badge variant={day.isActive ? "default" : "secondary"}>
{day.isActive ? "Active" : "Inactive"}
</Badge>
</div>
{day.description && (
<p className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">
{day.description}
</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<MapPin className="size-3" />
{day.location.name}
</span>
{day.dayOfWeek != null && (
<span className="inline-flex items-center gap-1">
<CalendarDays className="size-3" />
{DAY_NAMES[day.dayOfWeek]}
{day.timeStart && ` at ${day.timeStart}`}
</span>
)}
<span className="inline-flex items-center gap-1">
<CreditCard className="size-3" />
{day._count.cards} card{day._count.cards !== 1 ? "s" : ""}
</span>
</div>
</Link>
))}
</div>
)}
</div> </div>
); );
} }

View file

@ -15,6 +15,12 @@ import {
UserPlus, UserPlus,
X, X,
} from "lucide-react"; } from "lucide-react";
import {
ResponsiveContainer,
AreaChart,
Area,
} from "recharts";
import { toast } from "sonner";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -25,6 +31,14 @@ type Stats = {
byReviewStatus: Record<string, number>; byReviewStatus: Record<string, number>;
}; };
type TrendPoint = { date: string; total: number; scanned: number; survey: number };
type TrendsData = {
series: TrendPoint[];
peopleCount: number;
newPeopleThisPeriod: number;
};
const HINTS_STORAGE_KEY = "echo-ocr-dismissed-hints"; const HINTS_STORAGE_KEY = "echo-ocr-dismissed-hints";
function loadDismissedHints(): string[] { function loadDismissedHints(): string[] {
@ -49,6 +63,7 @@ function dismissHint(id: string) {
export default function DashboardHomePage() { export default function DashboardHomePage() {
const [stats, setStats] = useState<Stats | null>(null); const [stats, setStats] = useState<Stats | null>(null);
const [trends, setTrends] = useState<TrendsData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dismissedHints, setDismissedHints] = useState<string[]>([]); const [dismissedHints, setDismissedHints] = useState<string[]>([]);
@ -62,10 +77,17 @@ export default function DashboardHomePage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetch("/api/stats") Promise.all([
.then((r) => r.json()) fetch("/api/stats").then((r) => r.json()),
.then(setStats) fetch("/api/stats/trends?period=7&groupBy=day")
.catch(() => {}) .then((r) => (r.ok ? r.json() : null))
.catch(() => null),
])
.then(([statsData, trendsData]) => {
setStats(statsData);
setTrends(trendsData);
})
.catch(() => { toast.error("Failed to load dashboard stats"); })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@ -74,30 +96,44 @@ export default function DashboardHomePage() {
const pending = (stats?.byOcrStatus?.pending ?? 0) + (stats?.byOcrStatus?.processing ?? 0); const pending = (stats?.byOcrStatus?.pending ?? 0) + (stats?.byOcrStatus?.processing ?? 0);
const unreviewed = stats?.byReviewStatus?.unreviewed ?? 0; const unreviewed = stats?.byReviewStatus?.unreviewed ?? 0;
const sparkData = trends?.series ?? [];
const summaryCards = [ const summaryCards = [
{ {
label: "Total Cards", label: "Total Cards",
value: stats?.total ?? 0, value: stats?.total ?? 0,
icon: CreditCard, icon: CreditCard,
href: "/cards", href: "/cards",
sparkKey: "total" as const,
}, },
{ {
label: "OCR Complete", label: "OCR Complete",
value: completed, value: completed,
icon: CheckCircle2, icon: CheckCircle2,
href: "/cards?ocrStatus=complete", href: "/cards?ocrStatus=complete",
sparkKey: "scanned" as const,
}, },
{ {
label: "Needs Review", label: "Needs Review",
value: unreviewed, value: unreviewed,
icon: Clock, icon: Clock,
href: "/cards?reviewStatus=unreviewed", href: "/cards?reviewStatus=unreviewed",
sparkKey: null,
}, },
{ {
label: "Errors", label: "Errors",
value: errors, value: errors,
icon: AlertCircle, icon: AlertCircle,
href: "/cards?ocrStatus=error", href: "/cards?ocrStatus=error",
sparkKey: null,
},
{
label: "People",
value: trends?.peopleCount ?? 0,
icon: Users,
href: "/people",
sparkKey: null,
sub: trends ? `+${trends.newPeopleThisPeriod} this week` : undefined,
}, },
]; ];
@ -136,7 +172,7 @@ export default function DashboardHomePage() {
icon={CreditCard} icon={CreditCard}
/> />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
{summaryCards.map((card) => {summaryCards.map((card) =>
loading ? ( loading ? (
<Skeleton key={card.label} className="h-28 rounded-2xl" /> <Skeleton key={card.label} className="h-28 rounded-2xl" />
@ -155,6 +191,30 @@ export default function DashboardHomePage() {
<p className="mt-2 text-3xl font-bold tracking-tight"> <p className="mt-2 text-3xl font-bold tracking-tight">
{card.value.toLocaleString()} {card.value.toLocaleString()}
</p> </p>
{"sub" in card && card.sub && (
<p className="text-xs text-muted-foreground">{card.sub}</p>
)}
{card.sparkKey && sparkData.length > 1 && (
<div className="mt-1">
<ResponsiveContainer width="100%" height={40}>
<AreaChart data={sparkData}>
<defs>
<linearGradient id={`spark-${card.sparkKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.15} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey={card.sparkKey}
stroke="#6366f1"
fill={`url(#spark-${card.sparkKey})`}
strokeWidth={1.5}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</Link> </Link>
) )
)} )}

View file

@ -0,0 +1,690 @@
"use client";
import * as React from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import {
ArrowLeft,
User,
Edit2,
Save,
X,
Merge,
Trash2,
Loader2,
FileText,
Search,
} from "lucide-react";
import { formatDistanceToNow, differenceInDays, format } from "date-fns";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
type LinkedCard = {
id: string;
name: string | null;
ocrStatus: string;
reviewStatus: string;
createdAt: string;
};
type PersonData = {
id: string;
firstName: string;
lastName: string;
email: string | null;
cellPhone: string | null;
fieldData: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
cards: LinkedCard[];
_count: { cards: number };
};
type PersonSearchResult = {
id: string;
firstName: string;
lastName: string;
email: string | null;
_count: { cards: number };
};
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
if (differenceInDays(new Date(), d) < 30) {
return formatDistanceToNow(d, { addSuffix: true });
}
return format(d, "MMM d, yyyy");
}
export default function PersonDetailPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const id = params.id as string;
const [person, setPerson] = React.useState<PersonData | null>(null);
const [loading, setLoading] = React.useState(true);
const [notFound, setNotFound] = React.useState(false);
const [editing, setEditing] = React.useState(false);
const [editFields, setEditFields] = React.useState({
firstName: "",
lastName: "",
email: "",
cellPhone: "",
});
const [saving, setSaving] = React.useState(false);
const [mergeOpen, setMergeOpen] = React.useState(false);
const [mergeSearch, setMergeSearch] = React.useState("");
const [mergeResults, setMergeResults] = React.useState<PersonSearchResult[]>([]);
const [mergeSearching, setMergeSearching] = React.useState(false);
const [mergeTarget, setMergeTarget] = React.useState<PersonSearchResult | null>(null);
const [merging, setMerging] = React.useState(false);
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const fetchPerson = React.useCallback(async () => {
setLoading(true);
setNotFound(false);
try {
const res = await fetch(`/api/people/${id}`);
if (res.status === 404) {
setNotFound(true);
return;
}
if (!res.ok) throw new Error();
const data = await res.json();
setPerson(data);
} catch {
toast.error("Failed to load person");
} finally {
setLoading(false);
}
}, [id]);
React.useEffect(() => {
fetchPerson();
}, [fetchPerson]);
React.useEffect(() => {
if (searchParams.get("action") === "merge") {
setMergeOpen(true);
}
}, [searchParams]);
const startEditing = () => {
if (!person) return;
setEditFields({
firstName: person.firstName,
lastName: person.lastName,
email: person.email ?? "",
cellPhone: person.cellPhone ?? "",
});
setEditing(true);
};
const cancelEditing = () => {
setEditing(false);
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch(`/api/people/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editFields),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to save");
}
toast.success("Person updated");
setEditing(false);
fetchPerson();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
React.useEffect(() => {
if (!mergeOpen) {
setMergeSearch("");
setMergeResults([]);
setMergeTarget(null);
return;
}
}, [mergeOpen]);
React.useEffect(() => {
if (!mergeSearch.trim()) {
setMergeResults([]);
return;
}
const timer = setTimeout(async () => {
setMergeSearching(true);
try {
const res = await fetch(
`/api/people?search=${encodeURIComponent(mergeSearch)}&limit=10`
);
if (!res.ok) throw new Error();
const data = await res.json();
setMergeResults(
(data.people as PersonSearchResult[]).filter((p) => p.id !== id)
);
} catch {
// ignore search errors
} finally {
setMergeSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [mergeSearch, id]);
const handleMerge = async () => {
if (!mergeTarget) return;
setMerging(true);
try {
const res = await fetch(`/api/people/${id}/merge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetPersonId: mergeTarget.id }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Merge failed");
}
toast.success("People merged successfully");
setMergeOpen(false);
router.push(`/people/${mergeTarget.id}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Merge failed");
} finally {
setMerging(false);
}
};
const handleDelete = async () => {
setDeleting(true);
try {
const res = await fetch(`/api/people/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
toast.success("Person deleted");
setDeleteOpen(false);
router.push("/people");
} catch {
toast.error("Failed to delete person");
} finally {
setDeleting(false);
}
};
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-24 rounded-xl" />
<Skeleton className="h-10 w-64 rounded-xl" />
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-80 rounded-2xl" />
<Skeleton className="h-80 rounded-2xl" />
</div>
</div>
);
}
if (notFound || !person) {
return (
<div className="space-y-6">
<Header title="Person Not Found" icon={User} />
<p className="text-sm text-muted-foreground">
The person you&apos;re looking for doesn&apos;t exist or has been
removed.
</p>
<Button
variant="outline"
className="rounded-xl"
onClick={() => router.push("/people")}
>
<ArrowLeft className="mr-1 size-4" /> Back to People
</Button>
</div>
);
}
const fieldDataEntries = person.fieldData
? Object.entries(person.fieldData).filter(
([, v]) => v !== null && v !== undefined && v !== ""
)
: [];
return (
<div className="space-y-6">
<Button
variant="ghost"
size="sm"
className="w-fit rounded-xl"
onClick={() => router.push("/people")}
>
<ArrowLeft className="mr-1 size-4" /> Back to People
</Button>
<Header
title={`${person.firstName} ${person.lastName}`}
description="Contact Details"
icon={User}
>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => setMergeOpen(true)}
>
<Merge className="mr-1 size-4" /> Merge with...
</Button>
<Button
variant="destructive"
size="sm"
className="rounded-xl"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="mr-1 size-4" /> Delete Person
</Button>
</div>
</Header>
<div className="grid gap-6 lg:grid-cols-2">
{/* Contact info card */}
<Card variant="glass">
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span className="flex items-center gap-2">
<User className="size-4 text-primary" />
Contact Information
</span>
{!editing ? (
<Button
variant="ghost"
size="icon-xs"
onClick={startEditing}
>
<Edit2 className="size-3.5" />
</Button>
) : (
<div className="flex gap-1">
<Button
variant="ghost"
size="icon-xs"
onClick={cancelEditing}
disabled={saving}
>
<X className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
</Button>
</div>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{editing ? (
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
First Name
</Label>
<Input
value={editFields.firstName}
onChange={(e) =>
setEditFields((f) => ({
...f,
firstName: e.target.value,
}))
}
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Last Name
</Label>
<Input
value={editFields.lastName}
onChange={(e) =>
setEditFields((f) => ({
...f,
lastName: e.target.value,
}))
}
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Email
</Label>
<Input
type="email"
value={editFields.email}
onChange={(e) =>
setEditFields((f) => ({ ...f, email: e.target.value }))
}
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Cell Phone
</Label>
<Input
value={editFields.cellPhone}
onChange={(e) =>
setEditFields((f) => ({
...f,
cellPhone: e.target.value,
}))
}
/>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<InfoRow label="First Name" value={person.firstName} />
<InfoRow label="Last Name" value={person.lastName} />
<InfoRow label="Email" value={person.email} />
<InfoRow label="Cell Phone" value={person.cellPhone} />
</div>
)}
{fieldDataEntries.length > 0 && (
<>
<Separator className="opacity-50" />
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">
Additional Data
</p>
<div className="grid gap-3 sm:grid-cols-2">
{fieldDataEntries.map(([key, value]) => (
<InfoRow
key={key}
label={key
.replace(/([A-Z])/g, " $1")
.replace(/^./, (s) => s.toUpperCase())
.trim()}
value={String(value)}
/>
))}
</div>
</div>
</>
)}
<Separator className="opacity-50" />
<div className="flex gap-4 text-xs text-muted-foreground">
<span>Created {formatDate(person.createdAt)}</span>
<span>Updated {formatDate(person.updatedAt)}</span>
</div>
</CardContent>
</Card>
{/* Linked cards */}
<Card variant="glass">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="size-4 text-primary" />
Linked Response Cards
<Badge variant="secondary" className="ml-1">
{person._count.cards}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{person.cards.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FileText className="mb-3 size-10 text-muted-foreground/30" />
<p className="text-sm font-medium">No linked cards</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Cards will appear here when linked to this person
</p>
</div>
) : (
<div className="space-y-2">
{person.cards.map((card) => (
<button
key={card.id}
className="flex w-full items-center justify-between gap-3 rounded-xl border border-border/50 bg-muted/20 px-3 py-2.5 text-left transition-colors hover:bg-muted/40"
onClick={() => router.push(`/cards/${card.id}`)}
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{card.name || "Unnamed Card"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{formatDate(card.createdAt)}
</p>
</div>
<div className="flex shrink-0 gap-1.5">
<Badge
variant="secondary"
className={cn(
"text-[10px] capitalize",
card.ocrStatus === "complete" &&
"bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
card.ocrStatus === "error" &&
"bg-red-500/10 text-red-700 dark:text-red-300",
card.ocrStatus === "processing" &&
"bg-primary/10 text-primary"
)}
>
{card.ocrStatus}
</Badge>
<Badge
variant="secondary"
className={cn(
"text-[10px] capitalize",
card.reviewStatus === "reviewed" &&
"bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
card.reviewStatus === "unreviewed" &&
"bg-amber-500/10 text-amber-700 dark:text-amber-300"
)}
>
{card.reviewStatus}
</Badge>
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Merge dialog */}
<Dialog open={mergeOpen} onOpenChange={setMergeOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Merge Person</DialogTitle>
<DialogDescription>
Search for another person to merge{" "}
<strong>
{person.firstName} {person.lastName}
</strong>{" "}
into. Cards and data will be moved to the target person.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search people..."
value={mergeSearch}
onChange={(e) => {
setMergeSearch(e.target.value);
setMergeTarget(null);
}}
className="pl-9"
/>
</div>
{mergeSearching && (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!mergeSearching && mergeResults.length > 0 && (
<div className="max-h-48 space-y-1 overflow-y-auto">
{mergeResults.map((p) => (
<button
key={p.id}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors",
mergeTarget?.id === p.id
? "bg-primary/10 text-primary ring-1 ring-primary/30"
: "hover:bg-muted/60"
)}
onClick={() => setMergeTarget(p)}
>
<div>
<span className="font-medium">
{p.firstName} {p.lastName}
</span>
{p.email && (
<span className="ml-2 text-xs text-muted-foreground">
{p.email}
</span>
)}
</div>
<Badge variant="secondary" className="text-[10px]">
{p._count.cards} cards
</Badge>
</button>
))}
</div>
)}
{!mergeSearching &&
mergeSearch.trim() &&
mergeResults.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No matching people found
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setMergeOpen(false)}
>
Cancel
</Button>
<Button
size="sm"
disabled={!mergeTarget || merging}
onClick={handleMerge}
>
{merging ? (
<>
<Loader2 className="mr-1 size-4 animate-spin" /> Merging...
</>
) : (
<>
<Merge className="mr-1 size-4" /> Merge into{" "}
{mergeTarget
? `${mergeTarget.firstName} ${mergeTarget.lastName}`
: "..."}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Person</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<strong>
{person.firstName} {person.lastName}
</strong>
? Their linked cards will be unlinked but not deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
disabled={deleting}
onClick={handleDelete}
>
{deleting ? (
<>
<Loader2 className="mr-1 size-4 animate-spin" /> Deleting...
</>
) : (
<>
<Trash2 className="mr-1 size-4" /> Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function InfoRow({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
return (
<div>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-0.5 text-sm">{value || "—"}</p>
</div>
);
}

View file

@ -1,22 +1,404 @@
import { Users } from "lucide-react"; "use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
Users,
Search,
MoreHorizontal,
Eye,
Merge,
Trash2,
Link2,
Loader2,
ChevronLeft,
ChevronRight,
CalendarPlus,
} from "lucide-react";
import { formatDistanceToNow, differenceInDays, format } from "date-fns";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
type PersonRow = {
id: string;
firstName: string;
lastName: string;
email: string | null;
cellPhone: string | null;
createdAt: string;
updatedAt: string;
_count: { cards: number };
cards?: { createdAt: string }[];
};
type PeopleResponse = {
people: PersonRow[];
total: number;
page: number;
limit: number;
};
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
if (differenceInDays(new Date(), d) < 30) {
return formatDistanceToNow(d, { addSuffix: true });
}
return format(d, "MMM d, yyyy");
}
export default function PeoplePage() { export default function PeoplePage() {
const router = useRouter();
const [data, setData] = React.useState<PeopleResponse | null>(null);
const [loading, setLoading] = React.useState(true);
const [search, setSearch] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
const [page, setPage] = React.useState(1);
const [linking, setLinking] = React.useState(false);
const [deletingId, setDeletingId] = React.useState<string | null>(null);
const limit = 20;
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search]);
const fetchPeople = React.useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
});
if (debouncedSearch) params.set("search", debouncedSearch);
const res = await fetch(`/api/people?${params.toString()}`);
if (!res.ok) throw new Error();
const json = await res.json();
setData(json);
} catch {
toast.error("Failed to load people");
} finally {
setLoading(false);
}
}, [page, debouncedSearch]);
React.useEffect(() => {
fetchPeople();
}, [fetchPeople]);
const totalPages = data ? Math.max(1, Math.ceil(data.total / limit)) : 1;
const newThisMonth = React.useMemo(() => {
if (!data) return 0;
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return data.people.filter(
(p) => new Date(p.createdAt) >= startOfMonth
).length;
}, [data]);
const handleLinkCards = async () => {
setLinking(true);
try {
const res = await fetch("/api/people/link", { method: "POST" });
if (!res.ok) throw new Error();
const result = await res.json();
toast.success(
`Linked ${result.linked} card${result.linked !== 1 ? "s" : ""}, created ${result.created} new ${result.created !== 1 ? "people" : "person"}`
);
fetchPeople();
} catch {
toast.error("Failed to link cards");
} finally {
setLinking(false);
}
};
const handleDelete = async (personId: string) => {
setDeletingId(personId);
try {
const res = await fetch(`/api/people/${personId}`, { method: "DELETE" });
if (!res.ok) throw new Error();
toast.success("Person deleted");
fetchPeople();
} catch {
toast.error("Failed to delete person");
} finally {
setDeletingId(null);
}
};
if (loading && !data) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64 rounded-xl" />
<Skeleton className="h-4 w-80 rounded-lg" />
<Skeleton className="h-10 w-full rounded-xl" />
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-20 rounded-2xl" />
<Skeleton className="h-20 rounded-2xl" />
</div>
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-xl" />
))}
</div>
</div>
);
}
const people = data?.people ?? [];
const isEmpty = !loading && people.length === 0 && !debouncedSearch;
const noResults = !loading && people.length === 0 && !!debouncedSearch;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Header <Header
title="People" title="People"
description="Contact directory from scanned response cards" description="Contact directory from scanned response cards"
icon={Users} icon={Users}
/> >
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={handleLinkCards}
disabled={linking}
>
{linking ? (
<>
<Loader2 className="mr-1 size-4 animate-spin" /> Linking...
</>
) : (
<>
<Link2 className="mr-1 size-4" /> Link Unlinked Cards
</>
)}
</Button>
</Header>
{isEmpty ? (
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center"> <div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<Users className="mb-4 size-12 text-muted-foreground/40" /> <Users className="mb-4 size-12 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">People directory</h2> <h2 className="text-lg font-semibold">No people yet</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground"> <p className="mt-1 max-w-sm text-sm text-muted-foreground">
A deduplicated contact list aggregated from your response cards will People are automatically created when response cards are processed.
appear here once the people module is built out.
</p> </p>
</div> </div>
) : (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="gradient-stat flex flex-col gap-1 rounded-2xl p-4">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Users className="size-5" />
</div>
<p className="mt-1 text-2xl font-bold tracking-tight">
{data?.total.toLocaleString() ?? "—"}
</p>
<p className="text-xs text-muted-foreground">Total People</p>
</div>
<div className="gradient-stat flex flex-col gap-1 rounded-2xl p-4">
<div className="flex size-9 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<CalendarPlus className="size-5" />
</div>
<p className="mt-1 text-2xl font-bold tracking-tight">
{newThisMonth}
</p>
<p className="text-xs text-muted-foreground">New This Month</p>
</div>
</div>
{noResults ? (
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<Search className="mb-4 size-10 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">No results</h2>
<p className="mt-1 text-sm text-muted-foreground">
No people match &ldquo;{debouncedSearch}&rdquo;
</p>
</div>
) : (
<div className="glass-card overflow-hidden rounded-2xl">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">
Email
</TableHead>
<TableHead className="hidden md:table-cell">
Phone
</TableHead>
<TableHead className="text-center">Cards</TableHead>
<TableHead className="hidden lg:table-cell">
Last Seen
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{loading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-5 w-32 rounded" />
</TableCell>
<TableCell className="hidden sm:table-cell">
<Skeleton className="h-4 w-40 rounded" />
</TableCell>
<TableCell className="hidden md:table-cell">
<Skeleton className="h-4 w-28 rounded" />
</TableCell>
<TableCell className="text-center">
<Skeleton className="mx-auto h-5 w-8 rounded-full" />
</TableCell>
<TableCell className="hidden lg:table-cell">
<Skeleton className="h-4 w-24 rounded" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-6 rounded" />
</TableCell>
</TableRow>
))
: people.map((person) => {
const lastSeen =
person.cards?.[0]?.createdAt ?? person.updatedAt;
return (
<TableRow key={person.id}>
<TableCell>
<button
className="font-medium text-foreground hover:text-primary hover:underline"
onClick={() =>
router.push(`/people/${person.id}`)
}
>
{person.firstName} {person.lastName}
</button>
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">
{person.email || "—"}
</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">
{person.cellPhone || "—"}
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">
{person._count.cards}
</Badge>
</TableCell>
<TableCell className="hidden text-muted-foreground lg:table-cell">
{formatDate(lastSeen)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-xs"
/>
}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(`/people/${person.id}`)
}
>
<Eye className="mr-2 size-4" /> View
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
router.push(
`/people/${person.id}?action=merge`
)
}
>
<Merge className="mr-2 size-4" /> Merge
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={deletingId === person.id}
onClick={() => handleDelete(person.id)}
>
<Trash2 className="mr-2 size-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="rounded-xl"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="mr-1 size-4" /> Previous
</Button>
<Button
variant="outline"
size="sm"
className="rounded-xl"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next <ChevronRight className="ml-1 size-4" />
</Button>
</div>
</div>
)}
</>
)}
</div> </div>
); );
} }

View file

@ -59,8 +59,12 @@ export default function ProfilePage() {
setDirty(true); setDirty(true);
}; };
const handleSave = () => { const [saving, setSaving] = React.useState(false);
updateProfile({
const handleSave = async () => {
setSaving(true);
try {
await updateProfile({
jobTitle: form.jobTitle, jobTitle: form.jobTitle,
company: form.company, company: form.company,
bio: form.bio, bio: form.bio,
@ -70,6 +74,11 @@ export default function ProfilePage() {
}); });
setDirty(false); setDirty(false);
toast.success("Profile updated"); toast.success("Profile updated");
} catch {
toast.error("Failed to save profile");
} finally {
setSaving(false);
}
}; };
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -108,9 +117,9 @@ export default function ProfilePage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Header title="Profile" description="Manage your personal information" icon={UserCircle}> <Header title="Profile" description="Manage your personal information" icon={UserCircle}>
<Button className="rounded-xl" onClick={handleSave} disabled={!dirty}> <Button className="rounded-xl" onClick={handleSave} disabled={!dirty || saving}>
<Save className="mr-2 size-4" /> {saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Save className="mr-2 size-4" />}
Save Changes {saving ? "Saving…" : "Save Changes"}
</Button> </Button>
</Header> </Header>

View file

@ -1,22 +1,367 @@
import { BarChart3 } from "lucide-react"; "use client";
import { useEffect, useState, useCallback } from "react";
import { BarChart3, Download, Users, Clock, CreditCard, FileText } from "lucide-react";
import {
ResponsiveContainer,
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from "recharts";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
type Period = 7 | 30 | 90 | 365;
type SeriesPoint = {
date: string;
total: number;
scanned: number;
survey: number;
};
type Pipeline = {
pending: number;
processing: number;
complete: number;
error: number;
unreviewed: number;
reviewed: number;
exported: number;
};
type TeamMember = {
userId: string;
name: string;
reviewed: number;
assigned: number;
};
type TrendsData = {
series: SeriesPoint[];
pipeline: Pipeline;
teamActivity: TeamMember[];
avgReviewTimeHours: number | null;
peopleCount: number;
newPeopleThisPeriod: number;
};
const PERIOD_OPTIONS: { label: string; value: Period }[] = [
{ label: "7D", value: 7 },
{ label: "30D", value: 30 },
{ label: "90D", value: 90 },
{ label: "1Y", value: 365 },
];
function groupByForPeriod(period: Period): string {
if (period <= 30) return "day";
if (period <= 90) return "week";
return "month";
}
export default function ReportsPage() { export default function ReportsPage() {
const [period, setPeriod] = useState<Period>(30);
const [data, setData] = useState<TrendsData | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async (p: Period) => {
setLoading(true);
try {
const res = await fetch(
`/api/stats/trends?period=${p}&groupBy=${groupByForPeriod(p)}`
);
if (res.ok) setData(await res.json());
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData(period);
}, [period, fetchData]);
const handlePeriodChange = (p: Period) => setPeriod(p);
const downloadCsv = () => {
if (!data) return;
const header = "date,total,scanned,survey\n";
const rows = data.series
.map((r) => `${r.date},${r.total},${r.scanned},${r.survey}`)
.join("\n");
const blob = new Blob([header + rows], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `echo-report-${period}d.csv`;
a.click();
URL.revokeObjectURL(url);
};
const totalCards = data?.series.reduce((s, r) => s + r.total, 0) ?? 0;
const totalSurvey = data?.series.reduce((s, r) => s + r.survey, 0) ?? 0;
const pipelineChartData = data
? [
{ stage: "Pending", count: data.pipeline.pending },
{ stage: "Processing", count: data.pipeline.processing },
{ stage: "Complete", count: data.pipeline.complete },
{ stage: "Unreviewed", count: data.pipeline.unreviewed },
{ stage: "Reviewed", count: data.pipeline.reviewed },
{ stage: "Exported", count: data.pipeline.exported },
]
: [];
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<Header <Header
title="Reports" title="Reports"
description="Analytics, trends, and export center" description="Analytics, trends, and export center"
icon={BarChart3} icon={BarChart3}
/> />
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<BarChart3 className="mb-4 size-12 text-muted-foreground/40" /> {/* Period selector + export */}
<h2 className="text-lg font-semibold">Reports &amp; Analytics</h2> <div className="flex flex-wrap items-center justify-between gap-3">
<p className="mt-1 max-w-sm text-sm text-muted-foreground"> <div className="flex gap-1 rounded-lg bg-muted/50 p-1">
Detailed reports, trends over time, and data exports will be available {PERIOD_OPTIONS.map((opt) => (
here in a future update. <button
</p> key={opt.value}
onClick={() => handlePeriodChange(opt.value)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
period === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{opt.label}
</button>
))}
</div>
<Button variant="outline" size="sm" onClick={downloadCsv} disabled={!data}>
<Download className="mr-2 size-4" />
Export CSV
</Button>
</div>
{/* Summary stat cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-2xl" />
))
) : (
<>
<StatCard
label="Total Cards"
value={totalCards}
icon={CreditCard}
sub={`in last ${period} days`}
/>
<StatCard
label="People"
value={data?.peopleCount ?? 0}
icon={Users}
sub={`+${data?.newPeopleThisPeriod ?? 0} this period`}
/>
<StatCard
label="Avg Review Time"
value={
data?.avgReviewTimeHours != null
? `${data.avgReviewTimeHours}h`
: "—"
}
icon={Clock}
sub="hours to review"
/>
<StatCard
label="Survey Submissions"
value={totalSurvey}
icon={FileText}
sub={`of ${totalCards} total`}
/>
</>
)}
</div>
{/* Charts grid */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Cards Over Time */}
<div className="glass-card rounded-2xl p-5">
<h3 className="mb-4 text-sm font-semibold">Cards Over Time</h3>
{loading ? (
<Skeleton className="h-64 rounded-xl" />
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={data?.series ?? []}>
<defs>
<linearGradient id="gradScanned" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradSurvey" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="100%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="date"
tick={{ fontSize: 11 }}
stroke="hsl(var(--muted-foreground))"
tickFormatter={(v: string) =>
new Date(v + "T00:00:00").toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
/>
<YAxis
tick={{ fontSize: 11 }}
stroke="hsl(var(--muted-foreground))"
allowDecimals={false}
/>
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="scanned"
stroke="#6366f1"
fill="url(#gradScanned)"
strokeWidth={2}
name="Scanned"
/>
<Area
type="monotone"
dataKey="survey"
stroke="#10b981"
fill="url(#gradSurvey)"
strokeWidth={2}
name="Survey"
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
{/* Processing Pipeline */}
<div className="glass-card rounded-2xl p-5">
<h3 className="mb-4 text-sm font-semibold">Processing Pipeline</h3>
{loading ? (
<Skeleton className="h-64 rounded-xl" />
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={pipelineChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="stage"
tick={{ fontSize: 11 }}
stroke="hsl(var(--muted-foreground))"
/>
<YAxis
tick={{ fontSize: 11 }}
stroke="hsl(var(--muted-foreground))"
allowDecimals={false}
/>
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
/>
<Bar dataKey="count" fill="#6366f1" radius={[6, 6, 0, 0]} name="Cards" />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Team Activity */}
<div className="glass-card col-span-1 rounded-2xl p-5 lg:col-span-2">
<h3 className="mb-4 text-sm font-semibold">Team Activity</h3>
{loading ? (
<Skeleton className="h-64 rounded-xl" />
) : data?.teamActivity.length === 0 ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
No team activity yet
</div>
) : (
<ResponsiveContainer width="100%" height={Math.max(200, (data?.teamActivity.length ?? 0) * 50)}>
<BarChart
data={data?.teamActivity ?? []}
layout="vertical"
margin={{ left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
type="number"
tick={{ fontSize: 11 }}
stroke="hsl(var(--muted-foreground))"
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 11 }}
stroke="hsl(var(--muted-foreground))"
width={100}
/>
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="reviewed" fill="#6366f1" radius={[0, 6, 6, 0]} name="Reviewed" />
<Bar dataKey="assigned" fill="#a78bfa" radius={[0, 6, 6, 0]} name="Assigned" />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div> </div>
</div> </div>
); );
} }
function StatCard({
label,
value,
icon: Icon,
sub,
}: {
label: string;
value: string | number;
icon: React.ComponentType<{ className?: string }>;
sub: string;
}) {
return (
<div className="gradient-stat flex flex-col justify-between rounded-2xl p-5">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">{label}</span>
<Icon className="size-4 text-muted-foreground" />
</div>
<p className="mt-2 text-3xl font-bold tracking-tight">
{typeof value === "number" ? value.toLocaleString() : value}
</p>
<p className="mt-1 text-xs text-muted-foreground">{sub}</p>
</div>
);
}

View file

@ -0,0 +1,867 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { toast } from "sonner";
import {
ArrowLeft,
ChevronUp,
ChevronDown,
FileText,
GripVertical,
Loader2,
Plus,
Save,
Trash2,
X,
Asterisk,
Lock,
} from "lucide-react";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const FIELD_TYPES = [
{ value: "text", label: "Text" },
{ value: "textarea", label: "Text Area" },
{ value: "email", label: "Email" },
{ value: "phone", label: "Phone" },
{ value: "date", label: "Date" },
{ value: "select", label: "Select" },
{ value: "multiselect", label: "Multi-Select" },
{ value: "checkbox", label: "Checkbox" },
{ value: "radio", label: "Radio" },
{ value: "number", label: "Number" },
{ value: "url", label: "URL" },
] as const;
const SECTIONS = [
{ value: "personal", label: "Personal" },
{ value: "contact", label: "Contact" },
{ value: "address", label: "Address" },
{ value: "survey", label: "Survey" },
{ value: "followup", label: "Follow-up" },
{ value: "custom", label: "Custom" },
] as const;
const HAS_OPTIONS = new Set(["select", "multiselect", "radio"]);
type TemplateField = {
id: string;
label: string;
key: string;
type: string;
section: string;
isRequired: boolean;
isCore: boolean;
sortOrder: number;
options: string[] | null;
placeholder: string | null;
helpText: string | null;
visibleOnCard: boolean;
visibleOnSurvey: boolean;
};
type Template = {
id: string;
name: string;
description: string | null;
isDefault: boolean;
isActive: boolean;
fields: TemplateField[];
};
export default function TemplateEditorPage() {
const params = useParams();
const id = params.id as string;
const [template, setTemplate] = React.useState<Template | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [selectedFieldId, setSelectedFieldId] = React.useState<string | null>(
null
);
const [savingTemplate, setSavingTemplate] = React.useState(false);
const [savingField, setSavingField] = React.useState(false);
const [addingField, setAddingField] = React.useState(false);
const [showAddForm, setShowAddForm] = React.useState(false);
const [newField, setNewField] = React.useState({
label: "",
key: "",
type: "text",
section: "custom",
});
const fetchTemplate = React.useCallback(async () => {
try {
const res = await fetch(`/api/form-templates/${id}`);
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
setTemplate(data.template ?? data);
setError(null);
} catch {
setError("Failed to load template.");
} finally {
setLoading(false);
}
}, [id]);
React.useEffect(() => {
fetchTemplate();
}, [fetchTemplate]);
const selectedField = template?.fields.find(
(f) => f.id === selectedFieldId
);
const fieldsBySection = React.useMemo(() => {
if (!template) return {};
const grouped: Record<string, TemplateField[]> = {};
for (const field of template.fields) {
const section = field.section || "custom";
if (!grouped[section]) grouped[section] = [];
grouped[section].push(field);
}
for (const section of Object.keys(grouped)) {
grouped[section].sort((a, b) => a.sortOrder - b.sortOrder);
}
return grouped;
}, [template]);
const handleSaveTemplate = async () => {
if (!template) return;
setSavingTemplate(true);
try {
const res = await fetch(`/api/form-templates/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: template.name,
description: template.description,
isActive: template.isActive,
}),
});
if (!res.ok) throw new Error("Failed to update");
toast.success("Template updated");
} catch {
toast.error("Failed to update template");
} finally {
setSavingTemplate(false);
}
};
const handleSaveField = async () => {
if (!selectedField) return;
setSavingField(true);
try {
const res = await fetch(
`/api/form-templates/${id}/fields/${selectedField.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: selectedField.label,
type: selectedField.type,
section: selectedField.section,
isRequired: selectedField.isRequired,
options: selectedField.options,
placeholder: selectedField.placeholder,
helpText: selectedField.helpText,
visibleOnCard: selectedField.visibleOnCard,
visibleOnSurvey: selectedField.visibleOnSurvey,
}),
}
);
if (!res.ok) throw new Error("Failed to update");
toast.success("Field updated");
} catch {
toast.error("Failed to update field");
} finally {
setSavingField(false);
}
};
const handleAddField = async () => {
if (!newField.label.trim() || !newField.key.trim()) return;
setAddingField(true);
try {
const res = await fetch(`/api/form-templates/${id}/fields`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: newField.label.trim(),
key: newField.key.trim(),
type: newField.type,
section: newField.section,
}),
});
if (!res.ok) throw new Error("Failed to add field");
toast.success("Field added");
setNewField({ label: "", key: "", type: "text", section: "custom" });
setShowAddForm(false);
fetchTemplate();
} catch {
toast.error("Failed to add field");
} finally {
setAddingField(false);
}
};
const handleDeleteField = async (field: TemplateField) => {
if (field.isCore) return;
try {
const res = await fetch(
`/api/form-templates/${id}/fields/${field.id}`,
{ method: "DELETE" }
);
if (!res.ok) throw new Error("Failed to delete");
setTemplate((prev) =>
prev
? { ...prev, fields: prev.fields.filter((f) => f.id !== field.id) }
: prev
);
if (selectedFieldId === field.id) setSelectedFieldId(null);
toast.success("Field deleted");
} catch {
toast.error("Failed to delete field");
}
};
const handleReorder = async (
field: TemplateField,
direction: "up" | "down"
) => {
if (!template) return;
const sectionFields = template.fields
.filter((f) => f.section === field.section)
.sort((a, b) => a.sortOrder - b.sortOrder);
const idx = sectionFields.findIndex((f) => f.id === field.id);
if (
(direction === "up" && idx === 0) ||
(direction === "down" && idx === sectionFields.length - 1)
)
return;
const swapWith =
direction === "up" ? sectionFields[idx - 1] : sectionFields[idx + 1];
const newFields = template.fields.map((f) => {
if (f.id === field.id) return { ...f, sortOrder: swapWith.sortOrder };
if (f.id === swapWith.id) return { ...f, sortOrder: field.sortOrder };
return f;
});
setTemplate({ ...template, fields: newFields });
try {
await fetch(`/api/form-templates/${id}/fields/${field.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sortOrder: swapWith.sortOrder }),
});
await fetch(`/api/form-templates/${id}/fields/${swapWith.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sortOrder: field.sortOrder }),
});
} catch {
toast.error("Failed to reorder fields");
fetchTemplate();
}
};
const updateSelectedField = (updates: Partial<TemplateField>) => {
if (!selectedFieldId || !template) return;
setTemplate({
...template,
fields: template.fields.map((f) =>
f.id === selectedFieldId ? { ...f, ...updates } : f
),
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !template) {
return (
<div className="space-y-4">
<Button
variant="ghost"
size="sm"
render={<Link href="/settings/forms" />}
>
<ArrowLeft className="size-4" />
Back to templates
</Button>
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
{error ?? "Template not found."}
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
render={<Link href="/settings/forms" />}
>
<ArrowLeft className="size-4" />
</Button>
<Header title={template.name} icon={FileText} />
</div>
{/* Template Settings */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="text-base">Template Settings</CardTitle>
<CardDescription>
Update the template name, description, and status.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={template.name}
onChange={(e) =>
setTemplate({ ...template, name: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
value={template.description ?? ""}
onChange={(e) =>
setTemplate({
...template,
description: e.target.value || null,
})
}
placeholder="Optional description..."
/>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Active</Label>
<p className="text-xs text-muted-foreground">
Inactive templates won&apos;t appear when creating new cards.
</p>
</div>
<Switch
checked={template.isActive}
onCheckedChange={(checked) =>
setTemplate({ ...template, isActive: !!checked })
}
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveTemplate} disabled={savingTemplate}>
{savingTemplate ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
Save Settings
</Button>
</div>
</CardContent>
</Card>
<Separator />
{/* Field Editor Layout */}
<div className="grid gap-6 lg:grid-cols-[1fr_1fr]">
{/* Left Panel - Field List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground">Fields</h2>
<Button
variant="outline"
size="xs"
onClick={() => setShowAddForm((v) => !v)}
>
{showAddForm ? (
<>
<X className="size-3" />
Cancel
</>
) : (
<>
<Plus className="size-3" />
Add Field
</>
)}
</Button>
</div>
{showAddForm && (
<Card className="glass-card">
<CardContent className="space-y-3 pt-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Label</Label>
<Input
placeholder="e.g. Phone Number"
value={newField.label}
onChange={(e) => {
const label = e.target.value;
setNewField({
...newField,
label,
key: label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_|_$/g, ""),
});
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Key</Label>
<Input
placeholder="phone_number"
value={newField.key}
onChange={(e) =>
setNewField({ ...newField, key: e.target.value })
}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Type</Label>
<Select
value={newField.type}
onValueChange={(v) =>
setNewField({ ...newField, type: v as string })
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((ft) => (
<SelectItem key={ft.value} value={ft.value}>
{ft.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Section</Label>
<Select
value={newField.section}
onValueChange={(v) =>
setNewField({ ...newField, section: v as string })
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SECTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleAddField}
disabled={
addingField ||
!newField.label.trim() ||
!newField.key.trim()
}
>
{addingField && (
<Loader2 className="mr-1 size-3 animate-spin" />
)}
Add Field
</Button>
</div>
</CardContent>
</Card>
)}
{Object.keys(fieldsBySection).length === 0 ? (
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
No fields yet. Add your first field to get started.
</div>
) : (
<div className="space-y-4">
{SECTIONS.map((section) => {
const fields = fieldsBySection[section.value];
if (!fields || fields.length === 0) return null;
return (
<div key={section.value}>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{section.label}
</p>
<div className="space-y-1">
{fields.map((field, idx) => (
<button
key={field.id}
type="button"
onClick={() => setSelectedFieldId(field.id)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedFieldId === field.id
? "bg-primary/10 text-primary ring-1 ring-primary/20"
: "hover:bg-muted/50"
}`}
>
<GripVertical className="size-3.5 shrink-0 text-muted-foreground/50" />
<span className="min-w-0 flex-1 truncate font-medium">
{field.label}
</span>
<div className="flex shrink-0 items-center gap-1">
{field.isRequired && (
<Asterisk className="size-3 text-destructive" />
)}
{field.isCore && (
<Lock className="size-3 text-muted-foreground" />
)}
<Badge variant="secondary" className="text-[10px]">
{field.type}
</Badge>
</div>
<div className="flex shrink-0 items-center">
<Button
variant="ghost"
size="icon-xs"
onClick={(e) => {
e.stopPropagation();
handleReorder(field, "up");
}}
disabled={idx === 0}
className="size-5"
>
<ChevronUp className="size-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={(e) => {
e.stopPropagation();
handleReorder(field, "down");
}}
disabled={idx === fields.length - 1}
className="size-5"
>
<ChevronDown className="size-3" />
</Button>
</div>
</button>
))}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Right Panel - Field Editor */}
<div>
{selectedField ? (
<FieldEditor
field={selectedField}
isCore={selectedField.isCore}
onChange={updateSelectedField}
onSave={handleSaveField}
onDelete={() => handleDeleteField(selectedField)}
saving={savingField}
/>
) : (
<div className="glass-card flex flex-col items-center gap-2 rounded-2xl p-12 text-center">
<FileText className="size-8 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
Select a field to edit its properties
</p>
</div>
)}
</div>
</div>
</div>
);
}
function FieldEditor({
field,
isCore,
onChange,
onSave,
onDelete,
saving,
}: {
field: TemplateField;
isCore: boolean;
onChange: (updates: Partial<TemplateField>) => void;
onSave: () => void;
onDelete: () => void;
saving: boolean;
}) {
const [optionInput, setOptionInput] = React.useState("");
const addOption = () => {
const val = optionInput.trim();
if (!val) return;
const current = field.options ?? [];
if (!current.includes(val)) {
onChange({ options: [...current, val] });
}
setOptionInput("");
};
const removeOption = (opt: string) => {
onChange({ options: (field.options ?? []).filter((o) => o !== opt) });
};
return (
<Card className="glass-card sticky top-4">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Edit Field</CardTitle>
{!isCore && (
<Button variant="destructive" size="xs" onClick={onDelete}>
<Trash2 className="size-3" />
Delete
</Button>
)}
</div>
<CardDescription>
{isCore
? "Core fields cannot be deleted or have their key changed."
: "Configure this field's properties."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Label</Label>
<Input
value={field.label}
onChange={(e) => onChange({ label: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Key</Label>
<Input value={field.key} disabled={isCore} className={isCore ? "opacity-60" : ""} onChange={(e) => !isCore && onChange({ key: e.target.value })} />
{isCore && (
<p className="text-xs text-muted-foreground">
Core field keys cannot be changed.
</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Type</Label>
<Select
value={field.type}
onValueChange={(v) => onChange({ type: v as string })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((ft) => (
<SelectItem key={ft.value} value={ft.value}>
{ft.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Section</Label>
<Select
value={field.section}
onValueChange={(v) => onChange({ section: v as string })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SECTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Placeholder</Label>
<Input
value={field.placeholder ?? ""}
onChange={(e) =>
onChange({ placeholder: e.target.value || null })
}
placeholder="Optional placeholder text..."
/>
</div>
<div className="space-y-2">
<Label>Help Text</Label>
<Textarea
value={field.helpText ?? ""}
onChange={(e) =>
onChange({ helpText: e.target.value || null })
}
placeholder="Optional help text shown below the field..."
rows={2}
/>
</div>
{HAS_OPTIONS.has(field.type) && (
<div className="space-y-2">
<Label>Options</Label>
{field.options && field.options.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{field.options.map((opt) => (
<span
key={opt}
className="inline-flex items-center gap-1 rounded-full bg-secondary px-2.5 py-0.5 text-xs font-medium text-secondary-foreground"
>
{opt}
<button
type="button"
onClick={() => removeOption(opt)}
className="rounded-full p-0.5 hover:bg-foreground/10"
>
<X className="size-2.5" />
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<Input
placeholder="Add an option..."
value={optionInput}
onChange={(e) => setOptionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addOption();
}
}}
/>
<Button
variant="outline"
size="sm"
onClick={addOption}
disabled={!optionInput.trim()}
>
Add
</Button>
</div>
</div>
)}
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Required</Label>
<p className="text-xs text-muted-foreground">
Field must be filled when submitting
</p>
</div>
<Switch
size="sm"
checked={field.isRequired}
onCheckedChange={(checked) =>
onChange({ isRequired: !!checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Visible on Card</Label>
<p className="text-xs text-muted-foreground">
Show this field on the connect card
</p>
</div>
<Switch
size="sm"
checked={field.visibleOnCard}
onCheckedChange={(checked) =>
onChange({ visibleOnCard: !!checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Visible on Survey</Label>
<p className="text-xs text-muted-foreground">
Show this field on the digital survey
</p>
</div>
<Switch
size="sm"
checked={field.visibleOnSurvey}
onCheckedChange={(checked) =>
onChange({ visibleOnSurvey: !!checked })
}
/>
</div>
</div>
<div className="flex justify-end pt-2">
<Button onClick={onSave} disabled={saving}>
{saving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
Save Field
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,347 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { toast } from "sonner";
import {
FileText,
Plus,
Loader2,
Copy,
Trash2,
Pencil,
LayoutGrid,
ListChecks,
} from "lucide-react";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
type FormTemplate = {
id: string;
name: string;
description: string | null;
isDefault: boolean;
isActive: boolean;
_count?: { fields: number; cards: number };
};
export default function FormTemplatesPage() {
const [templates, setTemplates] = React.useState<FormTemplate[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [creating, setCreating] = React.useState(false);
const [newName, setNewName] = React.useState("");
const [newDescription, setNewDescription] = React.useState("");
const [togglingId, setTogglingId] = React.useState<string | null>(null);
const [deletingId, setDeletingId] = React.useState<string | null>(null);
const [duplicatingId, setDuplicatingId] = React.useState<string | null>(null);
const fetchTemplates = React.useCallback(async () => {
try {
const res = await fetch("/api/form-templates");
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
setTemplates(data.templates ?? []);
setError(null);
} catch {
setError("Failed to load form templates.");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
fetchTemplates();
}, [fetchTemplates]);
const handleCreate = async () => {
if (!newName.trim()) return;
setCreating(true);
try {
const res = await fetch("/api/form-templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: newName.trim(),
description: newDescription.trim() || null,
}),
});
if (!res.ok) throw new Error("Failed to create");
toast.success("Template created");
setNewName("");
setNewDescription("");
setDialogOpen(false);
fetchTemplates();
} catch {
toast.error("Failed to create template");
} finally {
setCreating(false);
}
};
const handleToggleActive = async (template: FormTemplate) => {
setTogglingId(template.id);
try {
const res = await fetch(`/api/form-templates/${template.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: !template.isActive }),
});
if (!res.ok) throw new Error("Failed to update");
setTemplates((prev) =>
prev.map((t) =>
t.id === template.id ? { ...t, isActive: !t.isActive } : t
)
);
toast.success(
`Template ${!template.isActive ? "activated" : "deactivated"}`
);
} catch {
toast.error("Failed to update template");
} finally {
setTogglingId(null);
}
};
const handleDuplicate = async (template: FormTemplate) => {
setDuplicatingId(template.id);
try {
const res = await fetch(`/api/form-templates/${template.id}/duplicate`, {
method: "POST",
});
if (!res.ok) throw new Error("Failed to duplicate");
toast.success("Template duplicated");
fetchTemplates();
} catch {
toast.error("Failed to duplicate template");
} finally {
setDuplicatingId(null);
}
};
const handleDelete = async (template: FormTemplate) => {
if (template.isDefault) {
toast.error("Cannot delete the default template");
return;
}
setDeletingId(template.id);
try {
const res = await fetch(`/api/form-templates/${template.id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete");
setTemplates((prev) => prev.filter((t) => t.id !== template.id));
toast.success("Template deleted");
} catch {
toast.error("Failed to delete template");
} finally {
setDeletingId(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
{error}
</div>
);
}
return (
<div className="space-y-6">
<Header
title="Form Templates"
description="Define the fields for your connect cards and surveys"
icon={FileText}
>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger
render={
<Button size="sm">
<Plus className="size-4" />
Create Template
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Template</DialogTitle>
<DialogDescription>
Create a new form template with custom fields.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="e.g. Sunday Service Card"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreate();
}
}}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
placeholder="Optional description..."
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button
onClick={handleCreate}
disabled={creating || !newName.trim()}
>
{creating && <Loader2 className="mr-2 size-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Header>
{templates.length === 0 ? (
<div className="glass-card flex flex-col items-center gap-3 rounded-2xl p-12 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<FileText className="size-6" />
</div>
<div>
<p className="font-medium text-foreground">No templates yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Create your first form template to get started.
</p>
</div>
<Button
size="sm"
className="mt-2"
onClick={() => setDialogOpen(true)}
>
<Plus className="size-4" />
Create Template
</Button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{templates.map((template) => (
<Card key={template.id} className="glass-card">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="flex items-center gap-2 text-base">
<span className="truncate">{template.name}</span>
{template.isDefault && (
<Badge variant="secondary" className="shrink-0">
Default
</Badge>
)}
</CardTitle>
{template.description && (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{template.description}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Switch
size="sm"
checked={template.isActive}
onCheckedChange={() => handleToggleActive(template)}
disabled={togglingId === template.id}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<ListChecks className="size-3.5" />
{template._count?.fields ?? 0} fields
</span>
<span className="inline-flex items-center gap-1">
<LayoutGrid className="size-3.5" />
{template._count?.cards ?? 0} cards
</span>
</div>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="xs"
render={<Link href={`/settings/forms/${template.id}`} />}
>
<Pencil className="size-3" />
Edit
</Button>
<Button
variant="outline"
size="xs"
onClick={() => handleDuplicate(template)}
disabled={duplicatingId === template.id}
>
{duplicatingId === template.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Copy className="size-3" />
)}
Duplicate
</Button>
<Button
variant="destructive"
size="xs"
onClick={() => handleDelete(template)}
disabled={
template.isDefault || deletingId === template.id
}
>
{deletingId === template.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
Delete
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View file

@ -62,6 +62,7 @@ export default function GeneralSettingsPage() {
imagesEligible: number; imagesEligible: number;
} | null>(null); } | null>(null);
const [cleaning, setCleaning] = React.useState(false); const [cleaning, setCleaning] = React.useState(false);
const [fetchError, setFetchError] = React.useState(false);
const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>( const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>(
DEFAULT_NOTIFICATION_PREFS DEFAULT_NOTIFICATION_PREFS
); );
@ -78,14 +79,20 @@ export default function GeneralSettingsPage() {
setImageRetention(data.imageRetentionDays || 180); setImageRetention(data.imageRetentionDays || 180);
setLoading(false); setLoading(false);
}) })
.catch(() => setLoading(false)); .catch(() => {
setFetchError(true);
toast.error("Failed to load settings");
setLoading(false);
});
fetch("/api/cleanup") fetch("/api/cleanup")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
if (data.sourcesEligible !== undefined) setCleanupStatus(data); if (data.sourcesEligible !== undefined) setCleanupStatus(data);
}) })
.catch(() => {}); .catch(() => {
toast.error("Failed to load cleanup status");
});
}, []); }, []);
const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => { const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => {
@ -265,8 +272,11 @@ export default function GeneralSettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
<div className="flex justify-end"> <div className="flex items-center justify-end gap-3">
<Button onClick={handleSave} disabled={saving}> {fetchError && (
<p className="text-sm text-destructive">Settings failed to load saving is disabled</p>
)}
<Button onClick={handleSave} disabled={saving || fetchError}>
{saving ? ( {saving ? (
<Loader2 className="mr-2 size-4 animate-spin" /> <Loader2 className="mr-2 size-4 animate-spin" />
) : ( ) : (

View file

@ -10,6 +10,7 @@ import {
Users, Users,
MapPin, MapPin,
Building2, Building2,
FileText,
} from "lucide-react"; } from "lucide-react";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -22,6 +23,7 @@ const SECTIONS = [
{ href: "/settings/users", label: "Team Members", icon: Users }, { href: "/settings/users", label: "Team Members", icon: Users },
{ href: "/settings/locations", label: "Locations", icon: MapPin }, { href: "/settings/locations", label: "Locations", icon: MapPin },
{ href: "/settings/organization", label: "Organization", icon: Building2 }, { href: "/settings/organization", label: "Organization", icon: Building2 },
{ href: "/settings/forms", label: "Form Templates", icon: FileText },
]; ];
export default function SettingsLayout({ export default function SettingsLayout({

View file

@ -3,6 +3,8 @@ import { generateText } from "ai";
import { gateway } from "@ai-sdk/gateway"; import { gateway } from "@ai-sdk/gateway";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai"; import type { LanguageModel } from "ai";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
function getTestModel(provider: string, modelId: string, ollamaUrl: string): LanguageModel { function getTestModel(provider: string, modelId: string, ollamaUrl: string): LanguageModel {
if (provider === "ollama") { if (provider === "ollama") {
@ -16,10 +18,13 @@ function getTestModel(provider: string, modelId: string, ollamaUrl: string): Lan
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
await requireApiAuth();
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const provider = body.provider || "gateway"; const provider = body.provider || "gateway";
const modelId = body.model || ""; const modelId = body.model || "";
const ollamaUrl = body.ollamaUrl || "";
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
const ollamaUrl = settings?.ollamaUrl || "";
const model = getTestModel(provider, modelId, ollamaUrl); const model = getTestModel(provider, modelId, ollamaUrl);
@ -30,9 +35,7 @@ export async function POST(request: NextRequest) {
}); });
return NextResponse.json({ ok: true, response: text.trim() }); return NextResponse.json({ ok: true, response: text.trim() });
} catch (err) { } catch (error) {
const message = err instanceof Error ? err.message : "Unknown error"; return handleApiError(error);
console.error("[ai-test]", message);
return NextResponse.json({ error: message }, { status: 500 });
} }
} }

View file

@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { prisma } from "@/lib/db";
import { sendEmail } from "@/lib/email-sender";
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
const BRAND = {
dark: "#2d2b28",
accent: "#3d3a36",
muted: "#8a8580",
bg: "#f7f5f2",
cardBg: "#ffffff",
border: "#e8e4df",
cta: "#2d2b28",
ctaText: "#fafaf8",
link: "#5c5650",
font: "'Quicksand', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
};
function resetEmailHtml(resetUrl: string) {
return `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<title>Reset your password</title></head>
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:${BRAND.font};-webkit-font-smoothing:antialiased;">
<div style="display:none;max-height:0;overflow:hidden;">Reset your Echo password&#847;&zwnj;&nbsp;</div>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:${BRAND.bg};">
<tr><td align="center" style="padding:40px 16px 32px;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
<tr><td align="center" style="padding-bottom:32px;">
<span style="font-family:${BRAND.font};font-size:20px;font-weight:700;color:${BRAND.dark};">Echo</span>
</td></tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;background-color:${BRAND.cardBg};border-radius:16px;border:1px solid ${BRAND.border};box-shadow:0 1px 3px rgba(0,0,0,0.04);">
<tr><td style="padding:48px 40px 44px;">
<h1 style="margin:0 0 16px;font-family:${BRAND.font};font-size:24px;font-weight:700;color:${BRAND.dark};line-height:1.3;">Reset your password</h1>
<div style="margin:0 0 32px;font-family:${BRAND.font};font-size:15px;color:${BRAND.accent};line-height:1.65;">
<p style="margin:0 0 16px;">We received a request to reset the password for your Echo account. Click the button below to choose a new password.</p>
<p style="margin:0;">If you didn't request this, you can safely ignore this email.</p>
</div>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%"><tr><td>
<a href="${resetUrl}" target="_blank" style="display:inline-block;padding:14px 32px;background-color:${BRAND.cta};color:${BRAND.ctaText};font-family:${BRAND.font};font-size:15px;font-weight:600;text-decoration:none;border-radius:10px;">Reset Password</a>
</td></tr></table>
<p style="margin:28px 0 0;font-family:${BRAND.font};font-size:13px;color:${BRAND.muted};line-height:1.55;">This link expires in 1 hour. If you didn't request a password reset, no action is needed.</p>
</td></tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
<tr><td align="center" style="padding:28px 0 8px;">
<p style="margin:0;font-family:${BRAND.font};font-size:12px;color:${BRAND.muted};">AI-powered response card scanning for churches</p>
<p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;"><a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;">echoocr.com</a></p>
</td></tr>
</table>
</td></tr></table>
</body></html>`;
}
export async function POST(req: NextRequest) {
try {
const { email } = await req.json();
if (!email || typeof email !== "string") {
return NextResponse.json({ ok: true });
}
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase().trim() } });
if (user) {
await prisma.passwordResetToken.deleteMany({ where: { email: user.email } });
const token = crypto.randomUUID();
await prisma.passwordResetToken.create({
data: {
email: user.email,
token,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const resetUrl = `${SITE_URL}/reset-password?token=${token}`;
await sendEmail(user.email, "Reset your password — Echo", resetEmailHtml(resetUrl));
}
return NextResponse.json({ ok: true });
} catch (error) {
console.error("[forgot-password] Error:", error);
return NextResponse.json({ ok: true });
}
}

View file

@ -1,4 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { getOrCreateUser, type AppUser } from "@/lib/auth"; import { getOrCreateUser, type AppUser } from "@/lib/auth";
export type AuthentikUser = { export type AuthentikUser = {
@ -74,3 +76,22 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ authenticated: true, user, dbUser }); return NextResponse.json({ authenticated: true, user, dbUser });
} }
export async function PUT(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { displayName, avatarUrl } = await req.json();
const data: Record<string, string> = {};
if (typeof displayName === "string") data.displayName = displayName;
if (typeof avatarUrl === "string") data.avatarUrl = avatarUrl;
await prisma.user.update({
where: { id: session.user.id },
data,
});
return NextResponse.json({ success: true });
}

View file

@ -53,6 +53,12 @@ export async function POST(req: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
if (invitation.email.toLowerCase() !== email.toLowerCase()) {
return NextResponse.json(
{ error: "Email does not match invitation" },
{ status: 400 }
);
}
inviteRole = invitation.role; inviteRole = invitation.role;
inviteOrgId = invitation.organizationId; inviteOrgId = invitation.organizationId;

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
export async function POST(req: NextRequest) {
try {
const { token, password } = await req.json();
if (!token || !password || typeof password !== "string" || password.length < 8) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token },
});
if (!resetToken || resetToken.expiresAt < new Date()) {
return NextResponse.json(
{ error: "This reset link has expired or is invalid. Please request a new one." },
{ status: 400 }
);
}
const hashedPassword = await bcrypt.hash(password, 12);
await prisma.user.update({
where: { email: resetToken.email },
data: { hashedPassword },
});
await prisma.passwordResetToken.delete({ where: { id: resetToken.id } });
return NextResponse.json({ ok: true });
} catch (error) {
console.error("[reset-password] Error:", error);
return NextResponse.json({ error: "Something went wrong" }, { status: 500 });
}
}

View file

@ -1,19 +1,24 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCardActivity } from "@/lib/activity-log"; import { getCardActivity } from "@/lib/activity-log";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const session = await requireApiAuthWithOrg();
const { id } = await params; const { id } = await params;
const card = await prisma.responseCard.findUnique({ where: { id }, select: { organizationId: true } });
if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
const activity = await getCardActivity(id); const activity = await getCardActivity(id);
return NextResponse.json(activity); return NextResponse.json(activity);
} catch (error) { } catch (error) {
console.error("[cards/[id]/activity GET]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to fetch activity" },
{ status: 500 }
);
} }
} }

View file

@ -1,18 +1,20 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { fireIntegrationEvent } from "@/lib/integrations"; import { fireIntegrationEvent } from "@/lib/integrations";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST( export async function POST(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const session = await requireApiAuthWithOrg();
const { id } = await params; const { id } = await params;
const card = await prisma.responseCard.findUnique({ const card = await prisma.responseCard.findUnique({
where: { id }, where: { id },
}); });
if (!card) { if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 }); return NextResponse.json({ error: "Card not found" }, { status: 404 });
} }
@ -27,10 +29,6 @@ export async function POST(
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error("[cards/[id]/export POST]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to mark card as exported" },
{ status: 500 }
);
} }
} }

View file

@ -1,16 +1,18 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { reprocessCard } from "@/lib/ocr"; import { reprocessCard } from "@/lib/ocr";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST( export async function POST(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const session = await requireApiAuthWithOrg();
const { id } = await params; const { id } = await params;
const card = await prisma.responseCard.findUnique({ where: { id } }); const card = await prisma.responseCard.findUnique({ where: { id } });
if (!card) { if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 }); return NextResponse.json({ error: "Card not found" }, { status: 404 });
} }
@ -27,10 +29,6 @@ export async function POST(
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("[cards/[id]/reprocess POST]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to start reprocessing" },
{ status: 500 }
);
} }
} }

View file

@ -3,19 +3,26 @@ import { prisma } from "@/lib/db";
import { deleteObject } from "@/lib/storage"; import { deleteObject } from "@/lib/storage";
import { fireIntegrationEvent } from "@/lib/integrations"; import { fireIntegrationEvent } from "@/lib/integrations";
import { logActivity, diffCardFields } from "@/lib/activity-log"; import { logActivity, diffCardFields } from "@/lib/activity-log";
import { getOrCreateUser, RoleError } from "@/lib/auth"; import { RoleError } from "@/lib/auth";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const session = await requireApiAuthWithOrg();
const { id } = await params; const { id } = await params;
const card = await prisma.responseCard.findUnique({ const card = await prisma.responseCard.findUnique({
where: { id }, where: { id },
include: {
formTemplate: {
include: { fields: { orderBy: { sortOrder: "asc" } } },
},
},
}); });
if (!card) { if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 }); return NextResponse.json({ error: "Card not found" }, { status: 404 });
} }
@ -32,11 +39,7 @@ export async function GET(
backImageUrl, backImageUrl,
}); });
} catch (error) { } catch (error) {
console.error("[cards/[id] GET]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to fetch card" },
{ status: 500 }
);
} }
} }
@ -45,31 +48,32 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const user = await getOrCreateUser(request.headers); const session = await requireApiAuthWithOrg();
const user = session.user;
const { id } = await params; const { id } = await params;
const card = await prisma.responseCard.findUnique({ const card = await prisma.responseCard.findUnique({
where: { id }, where: { id },
}); });
if (!card) { if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 }); return NextResponse.json({ error: "Card not found" }, { status: 404 });
} }
if (user) {
if (user.role === "viewer") { if (user.role === "viewer") {
return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 }); return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
} }
if (user.role === "reviewer" && card.assignedToId !== user.id) { if (user.role === "reviewer" && card.assignedToId !== user.id) {
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 }); return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
} }
}
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
const stringFields = [ const stringFields = [
"name", "name",
"firstName",
"lastName",
"gender", "gender",
"dateOfBirth", "dateOfBirth",
"maritalStatus", "maritalStatus",
@ -118,6 +122,7 @@ export async function PUT(
if (body.campusPreference != null) data.campusPreference = body.campusPreference; if (body.campusPreference != null) data.campusPreference = body.campusPreference;
if (body.howHeard != null) data.howHeard = body.howHeard; if (body.howHeard != null) data.howHeard = body.howHeard;
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse; if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
if (body.fieldData !== undefined) data.fieldData = body.fieldData;
for (const assignField of ["assignedToId", "assignedById", "reviewedById"] as const) { for (const assignField of ["assignedToId", "assignedById", "reviewedById"] as const) {
if (body[assignField] !== undefined) { if (body[assignField] !== undefined) {
@ -129,7 +134,7 @@ export async function PUT(
data.reviewStatus = "in_review"; data.reviewStatus = "in_review";
} }
if (body.reviewStatus === "reviewed" && user) { if (body.reviewStatus === "reviewed") {
data.reviewedById = user.id; data.reviewedById = user.id;
data.reviewedAt = new Date(); data.reviewedAt = new Date();
} }
@ -150,7 +155,7 @@ export async function PUT(
"user", "user",
`${changes.length} field(s) updated manually`, `${changes.length} field(s) updated manually`,
changes, changes,
user?.id user.id
).catch(() => {}); ).catch(() => {});
} }
@ -169,11 +174,7 @@ export async function PUT(
if (error instanceof RoleError) { if (error instanceof RoleError) {
return NextResponse.json({ error: error.message }, { status: 403 }); return NextResponse.json({ error: error.message }, { status: 403 });
} }
console.error("[cards/[id] PUT]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to update card" },
{ status: 500 }
);
} }
} }
@ -182,8 +183,8 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const user = await getOrCreateUser(request.headers); const session = await requireApiAuthWithOrg();
if (user && user.role !== "admin" && user.role !== "owner") { if (session.user.role !== "admin" && session.user.role !== "owner") {
return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 }); return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 });
} }
@ -192,7 +193,7 @@ export async function DELETE(
where: { id }, where: { id },
}); });
if (!card) { if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 }); return NextResponse.json({ error: "Card not found" }, { status: 404 });
} }
@ -209,10 +210,6 @@ export async function DELETE(
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("[cards/[id] DELETE]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to delete card" },
{ status: 500 }
);
} }
} }

View file

@ -1,13 +1,17 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOrCreateUser, requireRole, RoleError } from "@/lib/auth";
import { createNotification } from "@/lib/notifications"; import { createNotification } from "@/lib/notifications";
import { logActivity } from "@/lib/activity-log"; import { logActivity } from "@/lib/activity-log";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const user = await getOrCreateUser(req.headers); const session = await requireApiAuthWithOrg();
requireRole(user, "admin"); const role = session.user.role;
if (!role || !["admin", "owner"].includes(role)) {
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
const user = await prisma.user.findUniqueOrThrow({ where: { id: session.user.id } });
const body = await req.json(); const body = await req.json();
const { cardIds, assignToUserId } = body as { const { cardIds, assignToUserId } = body as {
@ -30,10 +34,10 @@ export async function POST(req: NextRequest) {
} }
await prisma.responseCard.updateMany({ await prisma.responseCard.updateMany({
where: { id: { in: cardIds } }, where: { id: { in: cardIds }, organizationId: session.user.orgId },
data: { data: {
assignedToId: assignToUserId, assignedToId: assignToUserId,
assignedById: user!.id, assignedById: user.id,
assignedAt: new Date(), assignedAt: new Date(),
reviewStatus: "assigned", reviewStatus: "assigned",
}, },
@ -44,16 +48,16 @@ export async function POST(req: NextRequest) {
cardId, cardId,
"assignment", "assignment",
"user", "user",
`Assigned to ${assignee.displayName} by ${user!.displayName}`, `Assigned to ${assignee.displayName} by ${user.displayName}`,
[{ field: "assignedToId", from: null, to: assignToUserId }], [{ field: "assignedToId", from: null, to: assignToUserId }],
user!.id user.id
).catch(() => {}); ).catch(() => {});
} }
await createNotification({ await createNotification({
type: "card_assigned", type: "card_assigned",
title: "Cards Assigned to You", title: "Cards Assigned to You",
message: `${cardIds.length} card(s) assigned by ${user!.displayName}`, message: `${cardIds.length} card(s) assigned by ${user.displayName}`,
actionUrl: "/?assignedToId=me", actionUrl: "/?assignedToId=me",
userId: assignToUserId, userId: assignToUserId,
}); });
@ -64,10 +68,6 @@ export async function POST(req: NextRequest) {
assignee: assignee.displayName, assignee: assignee.displayName,
}); });
} catch (error) { } catch (error) {
if (error instanceof RoleError) { return handleApiError(error);
return NextResponse.json({ error: error.message }, { status: 403 });
}
console.error("[cards/assign POST]", error);
return NextResponse.json({ error: "Assignment failed" }, { status: 500 });
} }
} }

View file

@ -3,20 +3,27 @@ import { prisma } from "@/lib/db";
import { getBuffer, uploadBuffer } from "@/lib/storage"; import { getBuffer, uploadBuffer } from "@/lib/storage";
import { pdfToImages, imageToBase64 } from "@/lib/pdf"; import { pdfToImages, imageToBase64 } from "@/lib/pdf";
import { ocrImage } from "@/lib/ai-ocr"; import { ocrImage } from "@/lib/ai-ocr";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
const DELAY_BETWEEN_OCR_MS = 3_000; const DELAY_BETWEEN_OCR_MS = 3_000;
export async function GET() { export async function GET() {
try {
const session = await requireApiAuthWithOrg();
const missing = await prisma.responseCard.count({ const missing = await prisma.responseCard.count({
where: { frontImagePath: null, backImagePath: { not: null } }, where: { frontImagePath: null, backImagePath: { not: null }, organizationId: session.user.orgId },
}); });
return NextResponse.json({ missingCount: missing }); return NextResponse.json({ missingCount: missing });
} catch (error) {
return handleApiError(error);
}
} }
export async function POST() { export async function POST() {
try { try {
const session = await requireApiAuthWithOrg();
const cards = await prisma.responseCard.findMany({ const cards = await prisma.responseCard.findMany({
where: { frontImagePath: null, backImagePath: { not: null } }, where: { frontImagePath: null, backImagePath: { not: null }, organizationId: session.user.orgId },
select: { id: true, sourceFile: true, name: true }, select: { id: true, sourceFile: true, name: true },
}); });
@ -134,8 +141,7 @@ export async function POST() {
message: `Recovery started for ${cards.length} card(s) from ${sourceFiles.length} PDF(s)`, message: `Recovery started for ${cards.length} card(s) from ${sourceFiles.length} PDF(s)`,
}); });
} catch (error) { } catch (error) {
console.error("[recover-survey POST]", error); return handleApiError(error);
return NextResponse.json({ error: "Recovery failed" }, { status: 500 });
} }
} }

View file

@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { reprocessCard } from "@/lib/ocr"; import { reprocessCard } from "@/lib/ocr";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
const DELAY_BETWEEN_CARDS_MS = 3_000; const DELAY_BETWEEN_CARDS_MS = 3_000;
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const session = await requireApiAuthWithOrg();
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const ids = body.ids as string[] | undefined; const ids = body.ids as string[] | undefined;
@ -14,7 +16,7 @@ export async function POST(request: NextRequest) {
} }
const cards = await prisma.responseCard.findMany({ const cards = await prisma.responseCard.findMany({
where: { id: { in: ids } }, where: { id: { in: ids }, organizationId: session.user.orgId },
select: { id: true, ocrStatus: true, backImagePath: true, frontImagePath: true }, select: { id: true, ocrStatus: true, backImagePath: true, frontImagePath: true },
}); });
@ -30,7 +32,7 @@ export async function POST(request: NextRequest) {
} }
await prisma.responseCard.updateMany({ await prisma.responseCard.updateMany({
where: { id: { in: eligible.map((c) => c.id) } }, where: { id: { in: eligible.map((c) => c.id) }, organizationId: session.user.orgId },
data: { ocrStatus: "processing", ocrError: null }, data: { ocrStatus: "processing", ocrError: null },
}); });
@ -56,7 +58,6 @@ export async function POST(request: NextRequest) {
skipped: ids.length - eligible.length, skipped: ids.length - eligible.length,
}); });
} catch (error) { } catch (error) {
console.error("[reprocess-batch POST]", error); return handleApiError(error);
return NextResponse.json({ error: "Failed to start batch reprocessing" }, { status: 500 });
} }
} }

View file

@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOrCreateUser } from "@/lib/auth"; import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const session = await requireApiAuthWithOrg();
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10)); 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 limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
@ -17,8 +19,6 @@ export async function GET(request: NextRequest) {
const sortBy = searchParams.get("sortBy") ?? "createdAt"; const sortBy = searchParams.get("sortBy") ?? "createdAt";
const sortOrder = searchParams.get("sortOrder") ?? "desc"; const sortOrder = searchParams.get("sortOrder") ?? "desc";
const user = await getOrCreateUser(request.headers);
const validSortFields = [ const validSortFields = [
"createdAt", "createdAt",
"updatedAt", "updatedAt",
@ -33,7 +33,9 @@ export async function GET(request: NextRequest) {
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt"; const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
const order = sortOrder === "asc" ? "asc" : "desc"; const order = sortOrder === "asc" ? "asc" : "desc";
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {
organizationId: session.user.orgId,
};
if (ocrStatus) where.ocrStatus = ocrStatus; if (ocrStatus) where.ocrStatus = ocrStatus;
if (reviewStatus) where.reviewStatus = reviewStatus; if (reviewStatus) where.reviewStatus = reviewStatus;
@ -41,8 +43,8 @@ export async function GET(request: NextRequest) {
if (visitType) where.visitType = visitType; if (visitType) where.visitType = visitType;
if (serviceAttended) where.serviceAttended = serviceAttended; if (serviceAttended) where.serviceAttended = serviceAttended;
if (assignedToId === "me" && user) { if (assignedToId === "me") {
where.assignedToId = user.id; where.assignedToId = session.user.id;
} else if (assignedToId === "unassigned") { } else if (assignedToId === "unassigned") {
where.assignedToId = null; where.assignedToId = null;
} else if (assignedToId) { } else if (assignedToId) {
@ -74,18 +76,18 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ cards, total, page, limit }); return NextResponse.json({ cards, total, page, limit });
} catch (error) { } catch (error) {
console.error("[cards GET]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to fetch cards" },
{ status: 500 }
);
} }
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const session = await requireApiAuthWithOrg();
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {
organizationId: session.user.orgId,
};
const stringFields = [ const stringFields = [
"name", "name",
@ -134,10 +136,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json(card, { status: 201 }); return NextResponse.json(card, { status: 201 });
} catch (error) { } catch (error) {
console.error("[cards POST]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to create card" },
{ status: 500 }
);
} }
} }

View file

@ -1,9 +1,12 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { deleteObject, listObjects } from "@/lib/storage"; import { deleteObject, listObjects } from "@/lib/storage";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const session = await requireApiAuthWithOrg();
const orgId = session.user.orgId;
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const dryRun = body.dryRun === true; const dryRun = body.dryRun === true;
@ -31,7 +34,7 @@ export async function POST(request: NextRequest) {
// 1. Purge source PDFs older than sourceRetentionDays // 1. Purge source PDFs older than sourceRetentionDays
const sourceKeys = await listObjects("sources/"); const sourceKeys = await listObjects("sources/");
const jobsWithSources = await prisma.processingJob.findMany({ const jobsWithSources = await prisma.processingJob.findMany({
where: { createdAt: { lt: sourcesCutoff }, status: "complete" }, where: { createdAt: { lt: sourcesCutoff }, status: "complete", organizationId: orgId },
select: { id: true }, select: { id: true },
}); });
const oldJobIds = new Set(jobsWithSources.map((j) => j.id)); const oldJobIds = new Set(jobsWithSources.map((j) => j.id));
@ -46,7 +49,7 @@ export async function POST(request: NextRequest) {
// 2. Purge card images older than imageRetentionDays // 2. Purge card images older than imageRetentionDays
const oldCards = await prisma.responseCard.findMany({ const oldCards = await prisma.responseCard.findMany({
where: { createdAt: { lt: imagesCutoff } }, where: { createdAt: { lt: imagesCutoff }, organizationId: orgId },
select: { id: true, frontImagePath: true, backImagePath: true }, select: { id: true, frontImagePath: true, backImagePath: true },
}); });
@ -71,27 +74,25 @@ export async function POST(request: NextRequest) {
// 3. Purge completed processing jobs older than sourceRetentionDays // 3. Purge completed processing jobs older than sourceRetentionDays
if (!dryRun) { if (!dryRun) {
const deleted = await prisma.processingJob.deleteMany({ const deleted = await prisma.processingJob.deleteMany({
where: { createdAt: { lt: sourcesCutoff }, status: { in: ["complete", "error"] } }, where: { createdAt: { lt: sourcesCutoff }, status: { in: ["complete", "error"] }, organizationId: orgId },
}); });
results.jobsPurged = deleted.count; results.jobsPurged = deleted.count;
} else { } else {
results.jobsPurged = await prisma.processingJob.count({ results.jobsPurged = await prisma.processingJob.count({
where: { createdAt: { lt: sourcesCutoff }, status: { in: ["complete", "error"] } }, where: { createdAt: { lt: sourcesCutoff }, status: { in: ["complete", "error"] }, organizationId: orgId },
}); });
} }
return NextResponse.json(results); return NextResponse.json(results);
} catch (error) { } catch (error) {
console.error("[cleanup POST]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Cleanup failed" },
{ status: 500 }
);
} }
} }
export async function GET() { export async function GET() {
try { try {
const session = await requireApiAuthWithOrg();
const orgId = session.user.orgId;
let settings = await prisma.appSettings.findUnique({ let settings = await prisma.appSettings.findUnique({
where: { id: "singleton" }, where: { id: "singleton" },
}); });
@ -106,11 +107,12 @@ export async function GET() {
const imagesCutoff = new Date(now.getTime() - settings.imageRetentionDays * 86400000); const imagesCutoff = new Date(now.getTime() - settings.imageRetentionDays * 86400000);
const sourcesEligible = await prisma.processingJob.count({ const sourcesEligible = await prisma.processingJob.count({
where: { createdAt: { lt: sourcesCutoff }, status: "complete" }, where: { createdAt: { lt: sourcesCutoff }, status: "complete", organizationId: orgId },
}); });
const imagesEligible = await prisma.responseCard.count({ const imagesEligible = await prisma.responseCard.count({
where: { where: {
createdAt: { lt: imagesCutoff }, createdAt: { lt: imagesCutoff },
organizationId: orgId,
OR: [ OR: [
{ frontImagePath: { not: null } }, { frontImagePath: { not: null } },
{ backImagePath: { not: null } }, { backImagePath: { not: null } },
@ -125,10 +127,6 @@ export async function GET() {
imagesEligible, imagesEligible,
}); });
} catch (error) { } catch (error) {
console.error("[cleanup GET]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to check cleanup status" },
{ status: 500 }
);
} }
} }

View file

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { PermissionError } from "@/lib/permissions";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await requireAuth();
const { id } = await params;
const collectionDay = await prisma.collectionDay.findFirst({
where: {
id,
location: { organizationId: user.orgId },
},
include: {
location: { select: { id: true, name: true } },
_count: { select: { cards: true } },
},
});
if (!collectionDay) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(collectionDay);
} catch (error) {
if (error instanceof PermissionError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
return NextResponse.json({ error: "Failed to fetch collection day" }, { status: 500 });
}
}

View file

@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { scanInbox } from "@/lib/email-watcher"; import { scanInbox } from "@/lib/email-watcher";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
if (!process.env.CRON_SECRET) {
return NextResponse.json({ error: "CRON_SECRET not configured" }, { status: 500 });
}
const authHeader = request.headers.get("authorization"); const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { scanInbox } from "@/lib/email-watcher"; import { scanInbox } from "@/lib/email-watcher";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
await requireApiAuth();
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const action = body.action as string | undefined; const action = body.action as string | undefined;
@ -55,9 +57,6 @@ export async function POST(request: NextRequest) {
const updated = await prisma.appSettings.findUnique({ where: { id: "singleton" } }); const updated = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
return NextResponse.json({ emailWatching: updated?.emailWatching ?? false }); return NextResponse.json({ emailWatching: updated?.emailWatching ?? false });
} catch (error) { } catch (error) {
console.error("[email-watch POST]", error); return handleApiError(error);
const message =
error instanceof Error ? error.message : "Failed to update email watch";
return NextResponse.json({ error: message }, { status: 500 });
} }
} }

View file

@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { testEmailConnection } from "@/lib/email-watcher"; import { testEmailConnection } from "@/lib/email-watcher";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
await requireApiAuth();
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const host = String(body.host || ""); const host = String(body.host || "");
@ -33,9 +35,6 @@ export async function POST(request: NextRequest) {
); );
} }
} catch (error) { } catch (error) {
console.error("[email-watch/test POST]", error); return handleApiError(error);
const message =
error instanceof Error ? error.message : "Connection test failed";
return NextResponse.json({ ok: false, error: message }, { status: 500 });
} }
} }

View file

@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
type RouteContext = { params: Promise<{ id: string; fieldId: string }> };
async function getOwnedField(templateId: string, fieldId: string, orgId: string) {
const template = await prisma.formTemplate.findUnique({
where: { id: templateId },
select: { organizationId: true },
});
if (!template || template.organizationId !== orgId) return null;
const field = await prisma.formField.findUnique({
where: { id: fieldId },
});
if (!field || field.formTemplateId !== templateId) return null;
return field;
}
export async function PUT(request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const { id, fieldId } = await ctx.params;
const field = await getOwnedField(id, fieldId, session.user.orgId!);
if (!field) {
return NextResponse.json(
{ error: "Field not found" },
{ status: 404 }
);
}
const body = await request.json();
const {
label, type, section, required, removable, options,
placeholder, helpText, validation, visibleOnCard, visibleOnSurvey,
} = body as {
label?: string;
type?: string;
section?: string;
required?: boolean;
removable?: boolean;
options?: unknown;
placeholder?: string;
helpText?: string;
validation?: unknown;
visibleOnCard?: boolean;
visibleOnSurvey?: boolean;
};
const data: Record<string, unknown> = {};
if (label !== undefined) data.label = label.trim();
if (type !== undefined) data.type = type.trim();
if (section !== undefined) data.section = section.trim();
if (required !== undefined) data.required = Boolean(required);
if (removable !== undefined) data.removable = Boolean(removable);
if (options !== undefined) data.options = options;
if (placeholder !== undefined) data.placeholder = placeholder?.trim() || null;
if (helpText !== undefined) data.helpText = helpText?.trim() || null;
if (validation !== undefined) data.validation = validation;
if (visibleOnCard !== undefined) data.visibleOnCard = Boolean(visibleOnCard);
if (visibleOnSurvey !== undefined) data.visibleOnSurvey = Boolean(visibleOnSurvey);
const updated = await prisma.formField.update({
where: { id: fieldId },
data,
});
return NextResponse.json(updated);
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(_request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const { id, fieldId } = await ctx.params;
const field = await getOwnedField(id, fieldId, session.user.orgId!);
if (!field) {
return NextResponse.json(
{ error: "Field not found" },
{ status: 404 }
);
}
if (field.isCore) {
return NextResponse.json(
{ error: "Core fields cannot be deleted" },
{ status: 403 }
);
}
await prisma.formField.delete({ where: { id: fieldId } });
return NextResponse.json({ deleted: true });
} catch (error) {
return handleApiError(error);
}
}

View file

@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
type RouteContext = { params: Promise<{ id: string }> };
async function verifyTemplateOwnership(templateId: string, orgId: string) {
const template = await prisma.formTemplate.findUnique({
where: { id: templateId },
select: { id: true, organizationId: true },
});
if (!template || template.organizationId !== orgId) return null;
return template;
}
export async function GET(_request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {
return NextResponse.json(
{ error: "Template not found" },
{ status: 404 }
);
}
const fields = await prisma.formField.findMany({
where: { formTemplateId: id },
orderBy: { sortOrder: "asc" },
});
return NextResponse.json({ fields });
} catch (error) {
return handleApiError(error);
}
}
export async function POST(request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {
return NextResponse.json(
{ error: "Template not found" },
{ status: 404 }
);
}
const body = await request.json();
const { key, label, type, section, options, required, placeholder, helpText, validation, visibleOnCard, visibleOnSurvey } = body as {
key?: string;
label?: string;
type?: string;
section?: string;
options?: unknown;
required?: boolean;
placeholder?: string;
helpText?: string;
validation?: unknown;
visibleOnCard?: boolean;
visibleOnSurvey?: boolean;
};
if (!key?.trim() || !label?.trim() || !type?.trim()) {
return NextResponse.json(
{ error: "key, label, and type are required" },
{ status: 400 }
);
}
const maxSort = await prisma.formField.aggregate({
where: { formTemplateId: id },
_max: { sortOrder: true },
});
const nextSort = (maxSort._max.sortOrder ?? -1) + 1;
const field = await prisma.formField.create({
data: {
formTemplateId: id,
key: key.trim(),
label: label.trim(),
type: type.trim(),
section: section?.trim() || "personal",
required: required ?? false,
sortOrder: nextSort,
options: options ?? Prisma.JsonNull,
placeholder: placeholder?.trim() || null,
helpText: helpText?.trim() || null,
validation: validation ?? Prisma.JsonNull,
visibleOnCard: visibleOnCard ?? true,
visibleOnSurvey: visibleOnSurvey ?? true,
},
});
return NextResponse.json(field, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}
export async function PUT(request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
if (!(await verifyTemplateOwnership(id, session.user.orgId!))) {
return NextResponse.json(
{ error: "Template not found" },
{ status: 404 }
);
}
const body = await request.json();
const reorders = body as Array<{ id: string; sortOrder: number }>;
if (!Array.isArray(reorders) || reorders.length === 0) {
return NextResponse.json(
{ error: "Expected array of { id, sortOrder }" },
{ status: 400 }
);
}
await prisma.$transaction(
reorders.map((item) =>
prisma.formField.update({
where: { id: item.id },
data: { sortOrder: item.sortOrder },
})
)
);
const fields = await prisma.formField.findMany({
where: { formTemplateId: id },
orderBy: { sortOrder: "asc" },
});
return NextResponse.json({ fields });
} catch (error) {
return handleApiError(error);
}
}

View file

@ -0,0 +1,46 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import QRCode from "qrcode";
type RouteContext = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
const template = await prisma.formTemplate.findUnique({
where: { id },
include: {
organization: { select: { slug: true } },
},
});
if (!template || template.organizationId !== session.user.orgId) {
return new Response("Not found", { status: 404 });
}
const baseUrl =
process.env.NEXTAUTH_URL ||
process.env.AUTH_URL ||
"https://echoocr.app";
const surveyUrl = `${baseUrl}/s/${template.organization.slug}/${template.slug}`;
const pngBuffer = await QRCode.toBuffer(surveyUrl, {
type: "png",
width: 400,
margin: 2,
});
return new Response(new Uint8Array(pngBuffer), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600",
},
});
} catch (error) {
return handleApiError(error);
}
}

View file

@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
type RouteContext = { params: Promise<{ id: string }> };
async function getOwnedTemplate(templateId: string, orgId: string) {
const template = await prisma.formTemplate.findUnique({
where: { id: templateId },
include: {
fields: { orderBy: { sortOrder: "asc" } },
_count: { select: { cards: true } },
},
});
if (!template || template.organizationId !== orgId) return null;
return template;
}
export async function GET(_request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
const template = await getOwnedTemplate(id, session.user.orgId!);
if (!template) {
return NextResponse.json(
{ error: "Template not found" },
{ status: 404 }
);
}
return NextResponse.json(template);
} catch (error) {
return handleApiError(error);
}
}
export async function PUT(request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
const existing = await getOwnedTemplate(id, session.user.orgId!);
if (!existing) {
return NextResponse.json(
{ error: "Template not found" },
{ status: 404 }
);
}
const body = await request.json();
const { name, description, isActive, branding, settings } = body as {
name?: string;
description?: string;
isActive?: boolean;
branding?: Record<string, unknown>;
settings?: Record<string, unknown>;
};
const data: Record<string, unknown> = {};
if (name !== undefined) data.name = name.trim();
if (description !== undefined) data.description = description?.trim() || null;
if (isActive !== undefined) data.isActive = Boolean(isActive);
if (branding !== undefined) data.branding = branding;
if (settings !== undefined) data.settings = settings;
const updated = await prisma.formTemplate.update({
where: { id },
data,
include: {
fields: { orderBy: { sortOrder: "asc" } },
_count: { select: { cards: true } },
},
});
return NextResponse.json(updated);
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(_request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
const existing = await getOwnedTemplate(id, session.user.orgId!);
if (!existing) {
return NextResponse.json(
{ error: "Template not found" },
{ status: 404 }
);
}
if (existing._count.cards > 0) {
const deactivated = await prisma.formTemplate.update({
where: { id },
data: { isActive: false },
});
return NextResponse.json({
...deactivated,
_deactivated: true,
_reason: "Template has associated cards and was deactivated instead of deleted",
});
}
await prisma.formTemplate.delete({ where: { id } });
return NextResponse.json({ deleted: true });
} catch (error) {
return handleApiError(error);
}
}

View file

@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@/generated/prisma/client";
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);
if (searchParams.get("default") === "true") {
const template = await prisma.formTemplate.findFirst({
where: {
organizationId: session.user.orgId,
isDefault: true,
isActive: true,
},
include: { fields: { orderBy: { sortOrder: "asc" } } },
});
return NextResponse.json({ template });
}
const templates = await prisma.formTemplate.findMany({
where: { organizationId: session.user.orgId },
orderBy: { createdAt: "desc" },
include: {
_count: { select: { fields: true, cards: true } },
},
});
return NextResponse.json({ templates });
} catch (error) {
return handleApiError(error);
}
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const body = await request.json();
const { name, description, duplicateFrom } = body as {
name?: string;
description?: string;
duplicateFrom?: string;
};
if (!name?.trim()) {
return NextResponse.json(
{ error: "Name is required" },
{ status: 400 }
);
}
const baseSlug = slugify(name);
let slug = baseSlug;
let suffix = 1;
while (
await prisma.formTemplate.findUnique({
where: {
organizationId_slug: {
organizationId: session.user.orgId!,
slug,
},
},
})
) {
slug = `${baseSlug}-${suffix++}`;
}
let fieldsToCreate: Prisma.FormFieldCreateWithoutFormTemplateInput[] = [];
if (duplicateFrom) {
const source = await prisma.formTemplate.findFirst({
where: { id: duplicateFrom, organizationId: session.user.orgId },
include: { fields: { orderBy: { sortOrder: "asc" } } },
});
if (!source) {
return NextResponse.json(
{ error: "Source template not found" },
{ status: 404 }
);
}
fieldsToCreate = source.fields.map((f) => ({
key: f.key,
label: f.label,
type: f.type,
section: f.section,
required: f.required,
removable: f.removable,
isCore: f.isCore,
sortOrder: f.sortOrder,
options: (f.options ?? Prisma.JsonNull) as Prisma.InputJsonValue,
placeholder: f.placeholder,
helpText: f.helpText,
validation: (f.validation ?? Prisma.JsonNull) as Prisma.InputJsonValue,
visibleOnCard: f.visibleOnCard,
visibleOnSurvey: f.visibleOnSurvey,
}));
}
const template = await prisma.formTemplate.create({
data: {
organizationId: session.user.orgId!,
name: name.trim(),
slug,
description: description?.trim() || null,
isDefault: false,
isActive: true,
...(fieldsToCreate.length > 0 && {
fields: { create: fieldsToCreate },
}),
},
include: {
fields: { orderBy: { sortOrder: "asc" } },
_count: { select: { cards: true } },
},
});
return NextResponse.json(template, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View file

@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { pollFtp } from "@/lib/ftp-watcher"; import { pollFtp } from "@/lib/ftp-watcher";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
if (!process.env.CRON_SECRET) {
return NextResponse.json({ error: "CRON_SECRET not configured" }, { status: 500 });
}
const authHeader = request.headers.get("authorization"); const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { pollFtp } from "@/lib/ftp-watcher"; import { pollFtp } from "@/lib/ftp-watcher";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
await requireApiAuth();
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const action = body.action as string | undefined; const action = body.action as string | undefined;
@ -55,9 +57,6 @@ export async function POST(request: NextRequest) {
const updated = await prisma.appSettings.findUnique({ where: { id: "singleton" } }); const updated = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
return NextResponse.json({ ftpEnabled: updated?.ftpEnabled ?? false }); return NextResponse.json({ ftpEnabled: updated?.ftpEnabled ?? false });
} catch (error) { } catch (error) {
console.error("[ftp-watch POST]", error); return handleApiError(error);
const message =
error instanceof Error ? error.message : "Failed to update FTP watch";
return NextResponse.json({ error: message }, { status: 500 });
} }
} }

View file

@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { testFtpConnection } from "@/lib/ftp-watcher"; import { testFtpConnection } from "@/lib/ftp-watcher";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
await requireApiAuth();
const body = await request.json(); const body = await request.json();
const result = await testFtpConnection({ const result = await testFtpConnection({
host: body.host, host: body.host,
@ -14,10 +16,6 @@ export async function POST(request: NextRequest) {
}); });
return NextResponse.json(result); return NextResponse.json(result);
} catch (error) { } catch (error) {
console.error("[ftp-watch/test POST]", error); return handleApiError(error);
return NextResponse.json(
{ ok: false, error: "Test failed" },
{ status: 500 }
);
} }
} }

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getBuffer } from "@/lib/storage"; import { getBuffer } from "@/lib/storage";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
const EXT_TO_CONTENT_TYPE: Record<string, string> = { const EXT_TO_CONTENT_TYPE: Record<string, string> = {
jpg: "image/jpeg", jpg: "image/jpeg",
@ -15,6 +16,7 @@ export async function GET(
{ params }: { params: Promise<{ path: string[] }> } { params }: { params: Promise<{ path: string[] }> }
) { ) {
try { try {
await requireApiAuth();
const { path: pathSegments } = await params; const { path: pathSegments } = await params;
const key = pathSegments.join("/"); const key = pathSegments.join("/");
@ -34,10 +36,6 @@ export async function GET(
}, },
}); });
} catch (error) { } catch (error) {
console.error("[images GET]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to get image" },
{ status: 500 }
);
} }
} }

View file

@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { fetchBoardColumns } from "@/lib/monday"; import { fetchBoardColumns } from "@/lib/monday";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
await requireApiAuth();
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const token = String(body.token || "");
const boardId = String(body.boardId || ""); const boardId = String(body.boardId || "");
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
const token = settings?.mondayApiToken || "";
if (!token || !boardId) { if (!token || !boardId) {
return NextResponse.json( return NextResponse.json(
{ error: "API token and board ID are required" }, { error: "API token and board ID are required" },
@ -17,8 +22,6 @@ export async function POST(request: NextRequest) {
const columns = await fetchBoardColumns(token, boardId); const columns = await fetchBoardColumns(token, boardId);
return NextResponse.json({ columns }); return NextResponse.json({ columns });
} catch (error) { } catch (error) {
console.error("[monday/columns POST]", error); return handleApiError(error);
const message = error instanceof Error ? error.message : "Failed to fetch columns";
return NextResponse.json({ error: message }, { status: 500 });
} }
} }

View file

@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { pushCardToMonday } from "@/lib/integrations"; import { pushCardToMonday } from "@/lib/integrations";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST( export async function POST(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
await requireApiAuth();
const { id } = await params; const { id } = await params;
const result = await pushCardToMonday(id); const result = await pushCardToMonday(id);
return NextResponse.json({ return NextResponse.json({
@ -14,8 +16,6 @@ export async function POST(
mondayItemId: result.mondayItemId, mondayItemId: result.mondayItemId,
}); });
} catch (error) { } catch (error) {
console.error("[monday/push POST]", error); return handleApiError(error);
const message = error instanceof Error ? error.message : "Push failed";
return NextResponse.json({ error: message }, { status: 500 });
} }
} }

View file

@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { createWebhookSubscription, deleteWebhookSubscription } from "@/lib/monday"; import { createWebhookSubscription, deleteWebhookSubscription } from "@/lib/monday";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
await requireApiAuth();
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const action = body.action as string; const action = body.action as string;
@ -61,8 +63,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid action" }, { status: 400 }); return NextResponse.json({ error: "Invalid action" }, { status: 400 });
} catch (error) { } catch (error) {
console.error("[monday/subscribe POST]", error); return handleApiError(error);
const message = error instanceof Error ? error.message : "Failed";
return NextResponse.json({ error: message }, { status: 500 });
} }
} }

View file

@ -2,9 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { pushCardToMonday } from "@/lib/integrations"; import { pushCardToMonday } from "@/lib/integrations";
import { createNotification } from "@/lib/notifications"; import { createNotification } from "@/lib/notifications";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
await requireApiAuth();
const settings = await prisma.appSettings.findUnique({ const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" }, where: { id: "singleton" },
}); });
@ -80,8 +82,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: true, synced, created, updated, failed, total: cards.length, errors: errors.slice(0, 10) }); return NextResponse.json({ ok: true, synced, created, updated, failed, total: cards.length, errors: errors.slice(0, 10) });
} catch (error) { } catch (error) {
console.error("[monday/sync-all POST]", error); return handleApiError(error);
const message = error instanceof Error ? error.message : "Sync failed";
return NextResponse.json({ error: message }, { status: 500 });
} }
} }

View file

@ -1,9 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { sendWebhook } from "@/lib/webhook"; import { sendWebhook } from "@/lib/webhook";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST() { export async function POST() {
try { try {
await requireApiAuth();
const settings = await prisma.appSettings.findUnique({ const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" }, where: { id: "singleton" },
}); });
@ -40,8 +42,6 @@ export async function POST() {
); );
} }
} catch (error) { } catch (error) {
console.error("[webhook/test POST]", error); return handleApiError(error);
const message = error instanceof Error ? error.message : "Test failed";
return NextResponse.json({ ok: false, error: message }, { status: 500 });
} }
} }

View file

@ -1,14 +1,17 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const session = await requireApiAuthWithOrg();
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10)); 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 limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
const status = searchParams.get("status") || undefined; const status = searchParams.get("status") || undefined;
const where = status ? { status } : {}; const where: Record<string, unknown> = { organizationId: session.user.orgId };
if (status) where.status = status;
const [jobs, total] = await Promise.all([ const [jobs, total] = await Promise.all([
prisma.processingJob.findMany({ prisma.processingJob.findMany({
@ -22,10 +25,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ jobs, total, page, limit }); return NextResponse.json({ jobs, total, page, limit });
} catch (error) { } catch (error) {
console.error("[jobs GET]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to fetch jobs" },
{ status: 500 }
);
} }
} }

View file

@ -6,50 +6,47 @@ import {
markAllRead, markAllRead,
dismissNotification, dismissNotification,
} from "@/lib/notifications"; } from "@/lib/notifications";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const session = await requireApiAuth();
const userId = session.user.id!;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const unreadOnly = searchParams.get("unreadOnly") === "true"; const unreadOnly = searchParams.get("unreadOnly") === "true";
const limit = Math.min(100, parseInt(searchParams.get("limit") || "50")); const limit = Math.min(100, parseInt(searchParams.get("limit") || "50"));
const [notifications, unreadCount] = await Promise.all([ const [notifications, unreadCount] = await Promise.all([
getNotifications({ unreadOnly, limit }), getNotifications({ userId, unreadOnly, limit }),
getUnreadCount(), getUnreadCount(userId),
]); ]);
return NextResponse.json({ notifications, unreadCount }); return NextResponse.json({ notifications, unreadCount });
} catch (error) { } catch (error) {
console.error("[notifications GET]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to fetch notifications" },
{ status: 500 }
);
} }
} }
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const session = await requireApiAuth();
const userId = session.user.id!;
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const action = body.action as string; const action = body.action as string;
if (action === "mark_read" && body.id) { if (action === "mark_read" && body.id) {
await markRead(body.id); await markRead(body.id, userId);
} else if (action === "mark_all_read") { } else if (action === "mark_all_read") {
await markAllRead(); await markAllRead(userId);
} else if (action === "dismiss" && body.id) { } else if (action === "dismiss" && body.id) {
await dismissNotification(body.id); await dismissNotification(body.id, userId);
} else { } else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 }); return NextResponse.json({ error: "Invalid action" }, { status: 400 });
} }
const unreadCount = await getUnreadCount(); const unreadCount = await getUnreadCount(userId);
return NextResponse.json({ ok: true, unreadCount }); return NextResponse.json({ ok: true, unreadCount });
} catch (error) { } catch (error) {
console.error("[notifications PUT]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to update notification" },
{ status: 500 }
);
} }
} }

View file

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
type Ctx = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, ctx: Ctx) {
try {
const session = await requireApiAuthWithOrg();
const orgId = session.user.orgId!;
const { id: sourceId } = await ctx.params;
const body = await request.json().catch(() => ({}));
const { targetPersonId } = body;
if (!targetPersonId) {
return NextResponse.json({ error: "targetPersonId is required" }, { status: 400 });
}
if (sourceId === targetPersonId) {
return NextResponse.json({ error: "Cannot merge a person into themselves" }, { status: 400 });
}
const [source, target] = await Promise.all([
prisma.person.findUnique({ where: { id: sourceId } }),
prisma.person.findUnique({ where: { id: targetPersonId } }),
]);
if (!source || source.organizationId !== orgId) {
return NextResponse.json({ error: "Source person not found" }, { status: 404 });
}
if (!target || target.organizationId !== orgId) {
return NextResponse.json({ error: "Target person not found" }, { status: 404 });
}
// 1. Move all cards from source to target
await prisma.responseCard.updateMany({
where: { personId: sourceId },
data: { personId: targetPersonId },
});
// 2. Merge fieldData: target values take priority, fill gaps from source
const mergedFieldData = {
...((source.fieldData as object) ?? {}),
...((target.fieldData as object) ?? {}),
};
// 3. Update target's core fields if target's are null but source's are not
const updates: Record<string, unknown> = {
fieldData: Object.keys(mergedFieldData).length > 0 ? mergedFieldData : null,
};
if (!target.email && source.email) updates.email = source.email;
if (!target.cellPhone && source.cellPhone) updates.cellPhone = source.cellPhone;
const targetPerson = await prisma.person.update({
where: { id: targetPersonId },
data: updates,
include: { _count: { select: { cards: true } } },
});
// 4. Mark source as merged
await prisma.person.update({
where: { id: sourceId },
data: { mergedIntoId: targetPersonId },
});
return NextResponse.json({ success: true, targetPerson });
} catch (error) {
return handleApiError(error);
}
}

View file

@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, ctx: Ctx) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
const person = await prisma.person.findUnique({
where: { id },
include: {
cards: {
take: 20,
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
ocrStatus: true,
reviewStatus: true,
createdAt: true,
},
},
_count: { select: { cards: true } },
},
});
if (!person || person.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Person not found" }, { status: 404 });
}
return NextResponse.json(person);
} catch (error) {
return handleApiError(error);
}
}
export async function PUT(request: NextRequest, ctx: Ctx) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
const existing = await prisma.person.findUnique({ where: { id } });
if (!existing || existing.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Person not found" }, { status: 404 });
}
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
if (body.firstName != null) data.firstName = String(body.firstName).trim();
if (body.lastName != null) data.lastName = String(body.lastName).trim();
if (body.email !== undefined) {
data.email = body.email ? String(body.email).trim().toLowerCase() : null;
}
if (body.cellPhone !== undefined) {
data.cellPhone = body.cellPhone ? String(body.cellPhone).trim() : null;
}
if (body.fieldData != null) {
data.fieldData = { ...((existing.fieldData as object) ?? {}), ...body.fieldData };
}
const updated = await prisma.person.update({ where: { id }, data });
return NextResponse.json(updated);
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(_request: NextRequest, ctx: Ctx) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
const existing = await prisma.person.findUnique({ where: { id } });
if (!existing || existing.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Person not found" }, { status: 404 });
}
await prisma.responseCard.updateMany({
where: { personId: id },
data: { personId: null },
});
await prisma.person.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error);
}
}

View file

@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import { linkCardToPerson } from "@/lib/person-linker";
const BATCH_LIMIT = 100;
export async function POST() {
try {
const session = await requireApiAuthWithOrg();
const orgId = session.user.orgId!;
const unlinkedCards = await prisma.responseCard.findMany({
where: {
organizationId: orgId,
personId: null,
ocrStatus: "complete",
},
take: BATCH_LIMIT,
select: { id: true },
});
let linked = 0;
let created = 0;
for (const card of unlinkedCards) {
const personCountBefore = await prisma.person.count({ where: { organizationId: orgId } });
const result = await linkCardToPerson(card.id, orgId);
if (result) {
const personCountAfter = await prisma.person.count({ where: { organizationId: orgId } });
if (personCountAfter > personCountBefore) {
created++;
} else {
linked++;
}
}
}
return NextResponse.json({
linked,
created,
total: unlinkedCards.length,
});
} catch (error) {
return handleApiError(error);
}
}

View file

@ -0,0 +1,81 @@
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 orgId = session.user.orgId!;
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 search = searchParams.get("search")?.trim() || undefined;
const sortBy = searchParams.get("sortBy") ?? "createdAt";
const sortOrder = searchParams.get("sortOrder") ?? "desc";
const validSortFields = ["createdAt", "updatedAt", "firstName", "lastName", "email"];
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
const order = sortOrder === "asc" ? "asc" : "desc";
const where: Record<string, unknown> = {
organizationId: orgId,
mergedIntoId: null,
};
if (search) {
where.OR = [
{ firstName: { contains: search, mode: "insensitive" } },
{ lastName: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
];
}
const [people, total] = await Promise.all([
prisma.person.findMany({
where,
orderBy: { [orderByField]: order },
skip: (page - 1) * limit,
take: limit,
include: { _count: { select: { cards: true } } },
}),
prisma.person.count({ where }),
]);
return NextResponse.json({ people, total, page, limit });
} catch (error) {
return handleApiError(error);
}
}
export async function POST(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const orgId = session.user.orgId!;
const body = await request.json().catch(() => ({}));
const { firstName, lastName, email, cellPhone, fieldData } = body;
if (!firstName || !lastName) {
return NextResponse.json(
{ error: "firstName and lastName are required" },
{ status: 400 }
);
}
const person = await prisma.person.create({
data: {
organizationId: orgId,
firstName: String(firstName).trim(),
lastName: String(lastName).trim(),
email: email ? String(email).trim().toLowerCase() : null,
cellPhone: cellPhone ? String(cellPhone).trim() : null,
fieldData: fieldData ?? null,
},
});
return NextResponse.json(person, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View file

@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function GET() { export async function GET() {
try { try {
await requireApiAuth();
const settings = await prisma.appSettings.findUnique({ const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" }, where: { id: "singleton" },
}); });
@ -14,18 +16,20 @@ export async function GET() {
return NextResponse.json(created); return NextResponse.json(created);
} }
return NextResponse.json(settings); const masked = { ...settings } as Record<string, unknown>;
if (masked.mondayApiToken) masked.mondayApiToken = "••••" + (masked.mondayApiToken as string).slice(-4);
if (masked.webhookSecret) masked.webhookSecret = "••••" + (masked.webhookSecret as string).slice(-4);
if (masked.emailImapPass) masked.emailImapPass = "••••••••";
if (masked.ftpPass) masked.ftpPass = "••••••••";
return NextResponse.json(masked);
} catch (error) { } catch (error) {
console.error("[settings GET]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to fetch settings" },
{ status: 500 }
);
} }
} }
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
await requireApiAuth();
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
@ -81,10 +85,6 @@ export async function PUT(request: NextRequest) {
return NextResponse.json(settings); return NextResponse.json(settings);
} catch (error) { } catch (error) {
console.error("[settings PUT]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to update settings" },
{ status: 500 }
);
} }
} }

View file

@ -1,32 +1,36 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOrCreateUser } from "@/lib/auth"; import { recoverStuckJobs } from "@/lib/job-recovery";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
recoverStuckJobs().catch(() => {});
try { try {
const user = await getOrCreateUser(request.headers); const session = await requireApiAuthWithOrg();
const orgId = session.user.orgId;
const userId = request.nextUrl.searchParams.get("userId") || undefined; const userId = request.nextUrl.searchParams.get("userId") || undefined;
const effectiveUserId = userId === "me" && user ? user.id : userId; const effectiveUserId = userId === "me" ? session.user.id : userId;
const [total, byOcrStatus, byReviewStatus, myCards] = await Promise.all([ const [total, byOcrStatus, byReviewStatus, myCards] = await Promise.all([
prisma.responseCard.count(), prisma.responseCard.count({ where: { organizationId: orgId } }),
prisma.responseCard.groupBy({ prisma.responseCard.groupBy({
by: ["ocrStatus"], by: ["ocrStatus"],
where: { organizationId: orgId },
_count: { id: true }, _count: { id: true },
}), }),
prisma.responseCard.groupBy({ prisma.responseCard.groupBy({
by: ["reviewStatus"], by: ["reviewStatus"],
where: { organizationId: orgId },
_count: { id: true }, _count: { id: true },
}), }),
effectiveUserId effectiveUserId
? prisma.responseCard.count({ ? prisma.responseCard.count({
where: { assignedToId: effectiveUserId }, where: { assignedToId: effectiveUserId, organizationId: orgId },
}) })
: user : prisma.responseCard.count({
? prisma.responseCard.count({ where: { assignedToId: session.user.id, organizationId: orgId },
where: { assignedToId: user.id }, }),
})
: Promise.resolve(0),
]); ]);
const ocrStatusCounts = Object.fromEntries( const ocrStatusCounts = Object.fromEntries(
@ -43,10 +47,6 @@ export async function GET(request: NextRequest) {
myCards, myCards,
}); });
} catch (error) { } catch (error) {
console.error("[stats GET]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to fetch stats" },
{ status: 500 }
);
} }
} }

View file

@ -0,0 +1,197 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
const VALID_PERIODS = new Set(["7", "30", "90", "365"]);
const VALID_GROUP_BY = new Set(["day", "week", "month"]);
export async function GET(request: NextRequest) {
try {
const session = await requireApiAuthWithOrg();
const orgId = session.user.orgId!;
const params = request.nextUrl.searchParams;
const period = VALID_PERIODS.has(params.get("period") ?? "")
? Number(params.get("period"))
: 30;
const groupBy = VALID_GROUP_BY.has(params.get("groupBy") ?? "")
? (params.get("groupBy") as "day" | "week" | "month")
: "day";
const since = new Date();
since.setDate(since.getDate() - period);
const [series, pipeline, teamActivity, avgReview, peopleCount, newPeople] =
await Promise.all([
buildTimeSeries(orgId, since, groupBy),
buildPipeline(orgId),
buildTeamActivity(orgId, since),
buildAvgReviewTime(orgId, since),
prisma.person.count({ where: { organizationId: orgId } }),
prisma.person.count({
where: { organizationId: orgId, createdAt: { gte: since } },
}),
]);
return NextResponse.json({
series,
pipeline,
teamActivity,
avgReviewTimeHours: avgReview,
peopleCount,
newPeopleThisPeriod: newPeople,
});
} catch (error) {
return handleApiError(error);
}
}
async function buildTimeSeries(
orgId: string,
since: Date,
groupBy: "day" | "week" | "month"
) {
const cards = await prisma.responseCard.findMany({
where: { organizationId: orgId, createdAt: { gte: since } },
select: { createdAt: true, submissionSource: true },
orderBy: { createdAt: "asc" },
});
const buckets = new Map<string, { total: number; survey: number }>();
for (const card of cards) {
const key = bucketKey(card.createdAt, groupBy);
const entry = buckets.get(key) ?? { total: 0, survey: 0 };
entry.total++;
if (card.submissionSource === "survey") entry.survey++;
buckets.set(key, entry);
}
return Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, counts]) => ({
date,
total: counts.total,
scanned: counts.total - counts.survey,
survey: counts.survey,
}));
}
function bucketKey(date: Date, groupBy: "day" | "week" | "month"): string {
const d = new Date(date);
if (groupBy === "month") {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`;
}
if (groupBy === "week") {
const day = d.getDay();
d.setDate(d.getDate() - day);
return d.toISOString().split("T")[0];
}
return d.toISOString().split("T")[0];
}
async function buildPipeline(orgId: string) {
const [byOcr, byReview] = await Promise.all([
prisma.responseCard.groupBy({
by: ["ocrStatus"],
where: { organizationId: orgId },
_count: { id: true },
}),
prisma.responseCard.groupBy({
by: ["reviewStatus"],
where: { organizationId: orgId },
_count: { id: true },
}),
]);
const ocrMap = Object.fromEntries(
byOcr.map((r) => [r.ocrStatus, r._count.id])
);
const reviewMap = Object.fromEntries(
byReview.map((r) => [r.reviewStatus, r._count.id])
);
return {
pending: ocrMap["pending"] ?? 0,
processing: ocrMap["processing"] ?? 0,
complete: ocrMap["complete"] ?? 0,
error: ocrMap["error"] ?? 0,
unreviewed: reviewMap["unreviewed"] ?? 0,
reviewed: reviewMap["reviewed"] ?? 0,
exported: 0,
};
}
async function buildTeamActivity(orgId: string, since: Date) {
const [reviewedCards, assignedCards] = await Promise.all([
prisma.responseCard.findMany({
where: {
organizationId: orgId,
reviewedById: { not: null },
reviewedAt: { gte: since },
},
select: {
reviewedById: true,
reviewedBy: { select: { id: true, displayName: true, username: true, email: true } },
},
}),
prisma.responseCard.findMany({
where: { organizationId: orgId, assignedToId: { not: null } },
select: {
assignedToId: true,
assignedTo: { select: { id: true, displayName: true, username: true, email: true } },
},
}),
]);
const userMap = new Map<string, { name: string; reviewed: number; assigned: number }>();
for (const card of reviewedCards) {
const u = card.reviewedBy!;
const entry = userMap.get(u.id) ?? {
name: u.displayName || u.username || u.email,
reviewed: 0,
assigned: 0,
};
entry.reviewed++;
userMap.set(u.id, entry);
}
for (const card of assignedCards) {
const u = card.assignedTo!;
const existing = userMap.get(u.id);
if (existing) {
existing.assigned++;
} else {
userMap.set(u.id, {
name: u.displayName || u.username || u.email,
reviewed: 0,
assigned: 1,
});
}
}
return Array.from(userMap.entries())
.map(([userId, data]) => ({ userId, ...data }))
.sort((a, b) => b.reviewed - a.reviewed);
}
async function buildAvgReviewTime(orgId: string, since: Date) {
const cards = await prisma.responseCard.findMany({
where: {
organizationId: orgId,
reviewedAt: { not: null },
createdAt: { gte: since },
},
select: { createdAt: true, reviewedAt: true },
});
if (cards.length === 0) return null;
const totalMs = cards.reduce((sum, c) => {
return sum + (c.reviewedAt!.getTime() - c.createdAt.getTime());
}, 0);
const avgHours = totalMs / cards.length / (1000 * 60 * 60);
return Math.round(avgHours * 10) / 10;
}

View file

@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { formTemplateId, organizationId, firstName, lastName, fieldData } =
body as {
formTemplateId?: string;
organizationId?: string;
firstName?: string;
lastName?: string;
fieldData?: Record<string, string>;
};
if (!formTemplateId || !organizationId) {
return NextResponse.json(
{ error: "formTemplateId and organizationId are required" },
{ status: 400 }
);
}
if (!firstName?.trim() || !lastName?.trim()) {
return NextResponse.json(
{ error: "firstName and lastName are required" },
{ status: 400 }
);
}
const template = await prisma.formTemplate.findUnique({
where: { id: formTemplateId },
});
if (
!template ||
!template.isActive ||
template.organizationId !== organizationId
) {
return NextResponse.json(
{ error: "Form template not found or inactive" },
{ status: 404 }
);
}
const trimmedFirst = firstName.trim();
const trimmedLast = lastName.trim();
const card = await prisma.responseCard.create({
data: {
firstName: trimmedFirst,
lastName: trimmedLast,
name: `${trimmedFirst} ${trimmedLast}`,
email: fieldData?.email || null,
cellPhone: fieldData?.cellPhone || fieldData?.phone || null,
fieldData: fieldData ?? {},
formTemplateId,
organizationId,
ocrStatus: "complete",
reviewStatus: "unreviewed",
submissionSource: "survey",
},
});
// TODO: Rate limiting — add IP-based or fingerprint-based rate limiting
return NextResponse.json({ success: true, cardId: card.id });
} catch (error) {
console.error("[Survey Submit Error]", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View file

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { uploadBuffer } from "@/lib/storage"; import { uploadBuffer } from "@/lib/storage";
import { enqueueProcessing } from "@/lib/processing-queue"; import { enqueueProcessing } from "@/lib/processing-queue";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50 MB const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50 MB
@ -15,6 +16,7 @@ const ALLOWED_TYPES = [
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const session = await requireApiAuthWithOrg();
const formData = await request.formData(); const formData = await request.formData();
const files = [ const files = [
...(formData.getAll("files") as File[]), ...(formData.getAll("files") as File[]),
@ -56,6 +58,7 @@ export async function POST(request: NextRequest) {
fileName, fileName,
filePath: `sources/${fileName}`, filePath: `sources/${fileName}`,
status: "queued", status: "queued",
organizationId: session.user.orgId,
}, },
}); });
@ -68,10 +71,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ jobIds }); return NextResponse.json({ jobIds });
} catch (error) { } catch (error) {
console.error("[upload POST]", error); return handleApiError(error);
return NextResponse.json(
{ error: "Failed to upload files" },
{ status: 500 }
);
} }
} }

View file

@ -1,20 +1,21 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getOrCreateUser } from "@/lib/auth"; import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const user = await getOrCreateUser(req.headers); const session = await requireApiAuthWithOrg();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const role = req.nextUrl.searchParams.get("role") || undefined; const role = req.nextUrl.searchParams.get("role") || undefined;
const where: Record<string, unknown> = {};
if (role) where.role = role;
const users = await prisma.user.findMany({ const where: Record<string, unknown> = {
organizationId: session.user.orgId,
};
if (role) where.user = { role };
const members = await prisma.orgMember.findMany({
where, where,
include: {
user: {
select: { select: {
id: true, id: true,
username: true, username: true,
@ -23,12 +24,15 @@ export async function GET(req: NextRequest) {
avatarUrl: true, avatarUrl: true,
role: true, role: true,
}, },
orderBy: { displayName: "asc" }, },
},
orderBy: { user: { displayName: "asc" } },
}); });
const users = members.map((m) => m.user);
return NextResponse.json({ users }); return NextResponse.json({ users });
} catch (error) { } catch (error) {
console.error("[users GET]", error); return handleApiError(error);
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 });
} }
} }

View file

@ -0,0 +1,84 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { SurveyForm } from "@/components/survey/survey-form";
type PageProps = {
params: Promise<{ orgSlug: string; formSlug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export default async function PublicSurveyPage({
params,
searchParams,
}: PageProps) {
const { orgSlug, formSlug } = await params;
const query = await searchParams;
const org = await prisma.organization.findUnique({
where: { slug: orgSlug },
select: { id: true, name: true },
});
if (!org) notFound();
const template = await prisma.formTemplate.findUnique({
where: {
organizationId_slug: {
organizationId: org.id,
slug: formSlug,
},
},
include: {
fields: {
where: { visibleOnSurvey: true },
orderBy: { sortOrder: "asc" },
},
},
});
if (!template || !template.isActive) notFound();
const branding = template.branding as {
primaryColor?: string;
logoUrl?: string;
} | null;
const prefill: Record<string, string> = {};
for (const [key, val] of Object.entries(query)) {
if (typeof val === "string") prefill[key] = val;
}
return (
<div className="space-y-6">
<div className="text-center">
{branding?.logoUrl && (
<img
src={branding.logoUrl}
alt={org.name}
className="mx-auto mb-4 h-16 w-auto object-contain"
/>
)}
<h1
className="text-2xl font-bold tracking-tight"
style={branding?.primaryColor ? { color: branding.primaryColor } : undefined}
>
{template.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">{org.name}</p>
{template.description && (
<p className="mt-2 text-sm text-muted-foreground">
{template.description}
</p>
)}
</div>
<SurveyForm
formTemplateId={template.id}
organizationId={org.id}
fields={template.fields}
branding={branding}
prefill={prefill}
/>
</div>
);
}

12
src/app/s/layout.tsx Normal file
View file

@ -0,0 +1,12 @@
export default function SurveyLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative flex min-h-screen items-center justify-center px-4 py-8">
<div className="gradient-mesh pointer-events-none fixed inset-0 z-0" />
<div className="relative z-10 w-full max-w-2xl">{children}</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { StatCards, type StatFilter } from "./stat-cards"; import { StatCards, type StatFilter } from "./stat-cards";
@ -27,9 +27,18 @@ const ATTENDANCE_OPTIONS = [
const SERVICE_OPTIONS = ["A", "B", "C", "D"]; const SERVICE_OPTIONS = ["A", "B", "C", "D"];
function deriveStatFilter(params: URLSearchParams): StatFilter {
if (params.get("assignedToId") === "me") return "my_cards";
if (params.get("ocrStatus") === "complete") return "complete";
if (params.get("ocrStatus") === "error") return "error";
if (params.get("reviewStatus") === "unreviewed") return "unreviewed";
return null;
}
export function DashboardContent() { export function DashboardContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const pathname = usePathname();
const { role } = useUserProfile(); const { role } = useUserProfile();
const isAdmin = role === "admin" || role === "owner"; const isAdmin = role === "admin" || role === "owner";
@ -45,8 +54,9 @@ export function DashboardContent() {
const [selectedIds, setSelectedIds] = React.useState<string[]>([]); const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [uploadModalOpen, setUploadModalOpen] = React.useState(false); const [uploadModalOpen, setUploadModalOpen] = React.useState(false);
const [uploadingRows, setUploadingRows] = React.useState<UploadingFile[]>([]); const [uploadingRows, setUploadingRows] = React.useState<UploadingFile[]>([]);
const [activeStatFilter, setActiveStatFilter] = React.useState<StatFilter>(null); const [activeStatFilter, setActiveStatFilter] = React.useState<StatFilter>(() => deriveStatFilter(searchParams));
const [globalDragOver, setGlobalDragOver] = React.useState(false); const [globalDragOver, setGlobalDragOver] = React.useState(false);
const [initialFiles, setInitialFiles] = React.useState<File[]>([]);
const fetchCards = React.useCallback(async () => { const fetchCards = React.useCallback(async () => {
setLoading(true); setLoading(true);
@ -83,6 +93,10 @@ export function DashboardContent() {
fetchCards(); fetchCards();
}, [fetchCards]); }, [fetchCards]);
React.useEffect(() => {
setActiveStatFilter(deriveStatFilter(searchParams));
}, [searchParams]);
const updateUrl = React.useCallback( const updateUrl = React.useCallback(
(updates: Record<string, string | number | undefined>) => { (updates: Record<string, string | number | undefined>) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@ -93,9 +107,9 @@ export function DashboardContent() {
params.set(key, String(value)); params.set(key, String(value));
} }
} }
router.push(`/?${params.toString()}`); router.push(`${pathname}?${params.toString()}`);
}, },
[router, searchParams] [router, searchParams, pathname]
); );
const handleStatFilterChange = React.useCallback( const handleStatFilterChange = React.useCallback(
@ -118,9 +132,9 @@ export function DashboardContent() {
} }
params.set("page", "1"); params.set("page", "1");
router.push(`/?${params.toString()}`); router.push(`${pathname}?${params.toString()}`);
}, },
[router, searchParams] [router, searchParams, pathname]
); );
const handleBulkAction = async ( const handleBulkAction = async (
@ -398,6 +412,7 @@ export function DashboardContent() {
e.preventDefault(); e.preventDefault();
setGlobalDragOver(false); setGlobalDragOver(false);
if (e.dataTransfer?.files.length) { if (e.dataTransfer?.files.length) {
setInitialFiles(Array.from(e.dataTransfer.files));
setUploadModalOpen(true); setUploadModalOpen(true);
} }
}; };
@ -417,6 +432,12 @@ export function DashboardContent() {
setUploadModalOpen(true); setUploadModalOpen(true);
}, []); }, []);
const FILTER_PARAM_KEYS = ["search", "ocrStatus", "reviewStatus", "visitType", "assignedToId", "attendanceDuration", "serviceAttended", "eventId", "locationId"];
const hasActiveFilters = FILTER_PARAM_KEYS.some((k) => searchParams.has(k));
const clearFilters = React.useCallback(() => {
router.push(pathname);
}, [router, pathname]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Global drag overlay */} {/* Global drag overlay */}
@ -476,6 +497,9 @@ export function DashboardContent() {
onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })} onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })}
uploadingRows={uploadingRows.filter((f) => f.status === "uploading")} uploadingRows={uploadingRows.filter((f) => f.status === "uploading")}
onSelectionChange={setSelectedIds} onSelectionChange={setSelectedIds}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
onUploadClick={isAdmin ? openUpload : undefined}
/> />
</div> </div>
@ -495,7 +519,11 @@ export function DashboardContent() {
{/* Upload modal */} {/* Upload modal */}
<UploadModal <UploadModal
open={uploadModalOpen} open={uploadModalOpen}
onOpenChange={setUploadModalOpen} onOpenChange={(open) => {
setUploadModalOpen(open);
if (!open) setInitialFiles([]);
}}
initialFiles={initialFiles}
onUploadStart={handleUploadStart} onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress} onUploadProgress={handleUploadProgress}
onUploadComplete={handleUploadComplete} onUploadComplete={handleUploadComplete}

View file

@ -18,6 +18,9 @@ import {
ChevronRight, ChevronRight,
Loader2, Loader2,
Copy, Copy,
Columns3,
Upload,
SearchX,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -32,6 +35,8 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ResponseCard } from "./columns"; import type { ResponseCard } from "./columns";
import type { UploadingFile } from "./upload-modal"; import type { UploadingFile } from "./upload-modal";
@ -133,6 +138,9 @@ type DataTableProps<TData> = {
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void; onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
uploadingRows?: UploadingFile[]; uploadingRows?: UploadingFile[];
onSelectionChange?: (selectedIds: string[]) => void; onSelectionChange?: (selectedIds: string[]) => void;
hasActiveFilters?: boolean;
onClearFilters?: () => void;
onUploadClick?: () => void;
}; };
const PAGE_SIZES = [10, 20, 50, 100]; const PAGE_SIZES = [10, 20, 50, 100];
@ -158,6 +166,9 @@ export function DataTable<TData extends ResponseCard>({
onSortChange, onSortChange,
uploadingRows = [], uploadingRows = [],
onSelectionChange, onSelectionChange,
hasActiveFilters,
onClearFilters,
onUploadClick,
}: DataTableProps<TData>) { }: DataTableProps<TData>) {
const router = useRouter(); const router = useRouter();
@ -333,6 +344,54 @@ export function DataTable<TData extends ResponseCard>({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-end">
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
size="sm"
className="rounded-xl"
aria-label="Toggle column visibility"
/>
}
>
<Columns3 className="size-4 mr-1.5" />
<span className="hidden sm:inline">Columns</span>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={4} className="w-56 p-0">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border/50">
Toggle Columns
</div>
<ScrollArea className="max-h-80">
<div className="p-1">
{COLUMN_GROUPS.map((group) => (
<React.Fragment key={group.label}>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
{group.label}
</div>
{group.columns.map((col) => (
<label
key={col.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
>
<Checkbox
checked={isColumnVisible(col.id)}
onCheckedChange={(checked) =>
toggleColumn(col.id, checked !== false)
}
/>
{col.label}
</label>
))}
</React.Fragment>
))}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</div>
{/* Desktop table */} {/* Desktop table */}
<div className="glass-card overflow-hidden rounded-2xl hidden md:block"> <div className="glass-card overflow-hidden rounded-2xl hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -455,9 +514,31 @@ export function DataTable<TData extends ResponseCard>({
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={columns.length} colSpan={columns.length}
className="h-24 text-center text-muted-foreground" className="h-40 text-center"
> >
No results. {hasActiveFilters ? (
<div className="flex flex-col items-center gap-2">
<SearchX className="size-8 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">No matching cards</p>
<p className="text-xs text-muted-foreground/70">Try adjusting your filters or search terms</p>
{onClearFilters && (
<Button variant="outline" size="sm" className="mt-1 rounded-xl" onClick={onClearFilters}>
Clear filters
</Button>
)}
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload className="size-8 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">No cards yet</p>
<p className="text-xs text-muted-foreground/70">Upload your first response card to get started</p>
{onUploadClick && (
<Button size="sm" className="mt-1 rounded-xl" onClick={onUploadClick}>
<Upload className="mr-1.5 size-3.5" /> Upload
</Button>
)}
</div>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
) )
@ -557,8 +638,30 @@ export function DataTable<TData extends ResponseCard>({
)) ))
) : ( ) : (
uploadingRows.length === 0 && ( uploadingRows.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground"> <div className="flex flex-col items-center py-12 text-center">
No results. {hasActiveFilters ? (
<>
<SearchX className="size-8 text-muted-foreground/30 mb-2" />
<p className="text-sm font-medium text-muted-foreground">No matching cards</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">Try adjusting your filters or search terms</p>
{onClearFilters && (
<Button variant="outline" size="sm" className="mt-3 rounded-xl" onClick={onClearFilters}>
Clear filters
</Button>
)}
</>
) : (
<>
<Upload className="size-8 text-muted-foreground/30 mb-2" />
<p className="text-sm font-medium text-muted-foreground">No cards yet</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">Upload your first response card to get started</p>
{onUploadClick && (
<Button size="sm" className="mt-3 rounded-xl" onClick={onUploadClick}>
<Upload className="mr-1.5 size-3.5" /> Upload
</Button>
)}
</>
)}
</div> </div>
) )
)} )}

View file

@ -0,0 +1,191 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
type FormFieldDef = {
id: string;
key: string;
label: string;
type: string;
section: string;
required: boolean;
options?: string[] | null;
placeholder?: string | null;
helpText?: string | null;
isCore: boolean;
};
interface DynamicFieldProps {
field: FormFieldDef;
value: unknown;
onChange: (value: unknown) => void;
readOnly: boolean;
}
export type { FormFieldDef };
export function DynamicField({ field, value, onChange, readOnly }: DynamicFieldProps) {
const opts = Array.isArray(field.options) ? (field.options as string[]) : [];
const renderInput = () => {
switch (field.type) {
case "text":
case "email":
case "phone":
case "url":
case "number": {
const inputType = field.type === "phone" ? "tel" : field.type;
return (
<Input
type={inputType}
value={String(value ?? "")}
onChange={(e) =>
onChange(
field.type === "number"
? e.target.value === ""
? null
: Number(e.target.value)
: e.target.value
)
}
readOnly={readOnly}
placeholder={field.placeholder || undefined}
className={readOnly ? "opacity-70 cursor-default" : ""}
/>
);
}
case "textarea":
return (
<Textarea
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
rows={3}
readOnly={readOnly}
placeholder={field.placeholder || undefined}
className={readOnly ? "opacity-70 cursor-default" : ""}
/>
);
case "date":
return (
<Input
type="date"
value={value ? String(value).slice(0, 10) : ""}
onChange={(e) => onChange(e.target.value || null)}
readOnly={readOnly}
className={readOnly ? "opacity-70 cursor-default" : ""}
/>
);
case "select":
case "radio": {
if (readOnly) {
return (
<Input
value={value ? String(value) : "—"}
readOnly
className="opacity-70 cursor-default"
/>
);
}
return (
<select
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value || null)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30"
>
<option value=""></option>
{opts.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
);
}
case "multiselect": {
const selected = new Set(
Array.isArray(value) ? (value as string[]) : []
);
const toggle = (opt: string) => {
if (readOnly) return;
const next = new Set(selected);
if (next.has(opt)) next.delete(opt);
else next.add(opt);
onChange(Array.from(next));
};
return (
<div className="flex flex-wrap gap-1.5">
{opts.map((opt) => {
const isOn = selected.has(opt);
return (
<button
key={opt}
type="button"
onClick={() => toggle(opt)}
disabled={readOnly}
className={cn(
"inline-flex items-center rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors",
isOn
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20"
: "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground",
readOnly && "cursor-default opacity-70"
)}
>
{opt}
</button>
);
})}
</div>
);
}
case "checkbox":
return (
<div className="flex items-center gap-2 pt-1">
<Checkbox
checked={Boolean(value)}
onCheckedChange={
readOnly ? undefined : (checked: boolean) => onChange(checked)
}
disabled={readOnly}
/>
</div>
);
default:
return (
<Input
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
readOnly={readOnly}
placeholder={field.placeholder || undefined}
className={readOnly ? "opacity-70 cursor-default" : ""}
/>
);
}
};
return (
<div className={field.type === "textarea" ? "sm:col-span-2" : ""}>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
{field.label}
{field.required && (
<span className="text-destructive ml-0.5">*</span>
)}
</Label>
{renderInput()}
{field.helpText && (
<p className="mt-1 text-[11px] text-muted-foreground/70">
{field.helpText}
</p>
)}
</div>
);
}

View file

@ -8,7 +8,9 @@ import {
Clock, Clock,
UserCheck, UserCheck,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
type Stats = { type Stats = {
total: number; total: number;
@ -36,11 +38,14 @@ interface StatCardsProps {
export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) { export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
const [stats, setStats] = React.useState<Stats | null>(null); const [stats, setStats] = React.useState<Stats | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => { React.useEffect(() => {
fetch("/api/stats") fetch("/api/stats")
.then((r) => r.json()) .then((r) => r.json())
.then(setStats) .then(setStats)
.catch(() => {}); .catch(() => { toast.error("Failed to load card stats"); })
.finally(() => setLoading(false));
}, []); }, []);
const cards: StatCardData[] = React.useMemo(() => { const cards: StatCardData[] = React.useMemo(() => {
@ -105,8 +110,18 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
} }
}; };
if (loading) {
return ( return (
<div className="grid grid-cols-3 gap-2 sm:gap-3 md:grid-cols-5"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-[88px] rounded-xl sm:rounded-2xl" />
))}
</div>
);
}
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-5">
{cards.map((card) => { {cards.map((card) => {
const Icon = card.icon; const Icon = card.icon;
const isActive = activeFilter === card.filterKey; const isActive = activeFilter === card.filterKey;
@ -131,7 +146,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
</div> </div>
<div> <div>
<p className="text-lg font-bold tracking-tight sm:text-2xl"> <p className="text-lg font-bold tracking-tight sm:text-2xl">
{stats ? card.value.toLocaleString() : "—"} {card.value.toLocaleString()}
</p> </p>
<p className="text-[11px] text-muted-foreground sm:text-xs"> <p className="text-[11px] text-muted-foreground sm:text-xs">
{card.label} {card.label}

View file

@ -37,6 +37,7 @@ export type UploadingFile = {
interface UploadModalProps { interface UploadModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
initialFiles?: File[];
onUploadStart?: (files: UploadingFile[]) => void; onUploadStart?: (files: UploadingFile[]) => void;
onUploadProgress?: (fileId: string, progress: number) => void; onUploadProgress?: (fileId: string, progress: number) => void;
onUploadComplete?: (fileId: string) => void; onUploadComplete?: (fileId: string) => void;
@ -46,6 +47,7 @@ interface UploadModalProps {
export function UploadModal({ export function UploadModal({
open, open,
onOpenChange, onOpenChange,
initialFiles,
onUploadStart, onUploadStart,
onUploadProgress, onUploadProgress,
onUploadComplete, onUploadComplete,
@ -56,6 +58,17 @@ export function UploadModal({
const [dragOver, setDragOver] = React.useState(false); const [dragOver, setDragOver] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null); const fileInputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (initialFiles && initialFiles.length > 0) {
const valid = initialFiles.filter(
(f) => f.type === "application/pdf" || f.type.startsWith("image/")
);
if (valid.length) {
setStagedFiles((prev) => [...prev, ...valid]);
}
}
}, [initialFiles]);
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -95,6 +108,9 @@ export function UploadModal({
const filesToUpload = [...stagedFiles]; const filesToUpload = [...stagedFiles];
setStagedFiles([]); setStagedFiles([]);
let successCount = 0;
let failCount = 0;
for (let i = 0; i < filesToUpload.length; i++) { for (let i = 0; i < filesToUpload.length; i++) {
const file = filesToUpload[i]; const file = filesToUpload[i];
const uploadEntry = newUploading[i]; const uploadEntry = newUploading[i];
@ -127,6 +143,7 @@ export function UploadModal({
) )
); );
onUploadComplete?.(uploadEntry.id); onUploadComplete?.(uploadEntry.id);
successCount++;
resolve(); resolve();
} else { } else {
const errMsg = "Upload failed"; const errMsg = "Upload failed";
@ -138,6 +155,7 @@ export function UploadModal({
) )
); );
onUploadError?.(uploadEntry.id, errMsg); onUploadError?.(uploadEntry.id, errMsg);
failCount++;
reject(new Error(errMsg)); reject(new Error(errMsg));
} }
}); });
@ -152,6 +170,7 @@ export function UploadModal({
) )
); );
onUploadError?.(uploadEntry.id, errMsg); onUploadError?.(uploadEntry.id, errMsg);
failCount++;
reject(new Error(errMsg)); reject(new Error(errMsg));
}); });
@ -163,8 +182,13 @@ export function UploadModal({
} }
} }
const completeCount = filesToUpload.length; if (failCount === 0) {
toast.success(`Uploaded ${completeCount} file(s). Processing started.`); toast.success(`Uploaded ${successCount} file(s). Processing started.`);
} else if (successCount === 0) {
toast.error(`All ${failCount} file(s) failed to upload`);
} else {
toast.warning(`Uploaded ${successCount} file(s), ${failCount} failed`);
}
}; };
const handleClose = () => { const handleClose = () => {

View file

@ -0,0 +1,148 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Command } from "cmdk";
import {
LayoutDashboard,
CreditCard,
Users,
CalendarDays,
BarChart3,
Settings,
Upload,
UserPlus,
FileText,
Search,
} from "lucide-react";
const navItems = [
{ label: "Dashboard", href: "/", icon: LayoutDashboard, group: "Navigate" },
{ label: "Response Cards", href: "/cards", icon: CreditCard, group: "Navigate" },
{ label: "People", href: "/people", icon: Users, group: "Navigate" },
{ label: "Collection Days", href: "/events", icon: CalendarDays, group: "Navigate" },
{ 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" },
];
const actionItems = [
{ label: "Upload Documents", icon: Upload, group: "Actions", action: "upload" },
{ label: "Create Person", icon: UserPlus, group: "Actions", action: "create-person" },
];
export function CommandPalette() {
const [open, setOpen] = useState(false);
const router = useRouter();
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
const runAction = useCallback(
(action: string) => {
setOpen(false);
if (action === "upload") {
window.dispatchEvent(new CustomEvent("open-upload-modal"));
} else if (action === "create-person") {
router.push("/people?new=true");
}
},
[router]
);
const navigate = useCallback(
(href: string) => {
setOpen(false);
router.push(href);
},
[router]
);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<Command
className="relative w-full max-w-lg overflow-hidden rounded-2xl border border-border/60 bg-background/95 shadow-2xl backdrop-blur-xl"
loop
>
<div className="flex items-center gap-2 border-b border-border/40 px-4">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Command.Input
placeholder="Type a command or search…"
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autoFocus
/>
<kbd className="hidden shrink-0 rounded-md border border-border/60 bg-muted/50 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline-block">
ESC
</kbd>
</div>
<Command.List className="max-h-80 overflow-y-auto p-2">
<Command.Empty className="px-4 py-8 text-center text-sm text-muted-foreground">
No results found.
</Command.Empty>
<Command.Group
heading="Navigate"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
>
{navItems.map((item) => (
<Command.Item
key={item.href}
value={item.label}
onSelect={() => navigate(item.href)}
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-foreground data-[selected=true]:bg-accent/60"
>
<item.icon className="size-4 text-muted-foreground" />
{item.label}
</Command.Item>
))}
</Command.Group>
<Command.Separator className="my-1 h-px bg-border/40" />
<Command.Group
heading="Actions"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
>
{actionItems.map((item) => (
<Command.Item
key={item.action}
value={item.label}
onSelect={() => runAction(item.action)}
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-foreground data-[selected=true]:bg-accent/60"
>
<item.icon className="size-4 text-muted-foreground" />
{item.label}
</Command.Item>
))}
</Command.Group>
</Command.List>
<div className="border-t border-border/40 px-4 py-2">
<p className="text-[11px] text-muted-foreground">
<kbd className="rounded border border-border/60 bg-muted/50 px-1 py-0.5 text-[10px] font-medium"></kbd>{" "}
navigate{" "}
<kbd className="ml-2 rounded border border-border/60 bg-muted/50 px-1 py-0.5 text-[10px] font-medium"></kbd>{" "}
select{" "}
<kbd className="ml-2 rounded border border-border/60 bg-muted/50 px-1 py-0.5 text-[10px] font-medium">esc</kbd>{" "}
close
</p>
</div>
</Command>
</div>
);
}

View file

@ -0,0 +1,146 @@
"use client";
import * as React from "react";
import { Copy, Check, Download, ExternalLink, QrCode } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type ShareSectionProps = {
templateId: string;
orgSlug: string;
formSlug: string;
};
export function ShareSection({
templateId,
orgSlug,
formSlug,
}: ShareSectionProps) {
const [copied, setCopied] = React.useState(false);
const baseUrl =
typeof window !== "undefined"
? window.location.origin
: "";
const surveyUrl = `${baseUrl}/s/${orgSlug}/${formSlug}`;
const qrEndpoint = `/api/form-templates/${templateId}/qr`;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(surveyUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const input = document.createElement("input");
input.value = surveyUrl;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleDownloadQR = async () => {
try {
const res = await fetch(qrEndpoint);
if (!res.ok) throw new Error("Failed to download");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `qr-${formSlug}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
console.error("Failed to download QR code");
}
};
return (
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<QrCode className="size-4" />
Share &amp; QR Code
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Public Survey URL
</label>
<div className="flex items-center gap-2">
<div className="glass-input flex-1 truncate rounded-lg px-3 py-2 text-sm text-muted-foreground">
{surveyUrl}
</div>
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="size-4 text-green-500" />
) : (
<Copy className="size-4" />
)}
{copied ? "Copied" : "Copy"}
</Button>
<Button
variant="outline"
size="sm"
render={
<a href={surveyUrl} target="_blank" rel="noopener noreferrer" />
}
className="shrink-0"
>
<ExternalLink className="size-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
QR Code
</label>
<div className="flex items-start gap-4">
<div className="overflow-hidden rounded-xl border border-border bg-white p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrEndpoint}
alt="Survey QR Code"
width={160}
height={160}
className="size-40"
/>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Scan this QR code to open the survey on a mobile device. Print
it on cards, posters, or slides.
</p>
<Button
variant="outline"
size="sm"
onClick={handleDownloadQR}
>
<Download className="size-4" />
Download QR
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -4,16 +4,25 @@ import { cn } from "@/lib/utils";
import { TopBar } from "@/components/layout/top-bar"; import { TopBar } from "@/components/layout/top-bar";
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar"; import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner"; import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
import { CommandPalette } from "@/components/command-palette";
function ShellContent({ children }: { children: React.ReactNode }) { function ShellContent({ children }: { children: React.ReactNode }) {
const { collapsed } = useSidebar(); const { collapsed } = useSidebar();
return ( return (
<div className="relative min-h-screen"> <div className="relative min-h-screen">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:rounded-lg focus:bg-background focus:px-4 focus:py-2 focus:text-foreground focus:shadow-lg focus:ring-2 focus:ring-primary"
>
Skip to main content
</a>
<div className="gradient-mesh pointer-events-none fixed inset-0 z-0" /> <div className="gradient-mesh pointer-events-none fixed inset-0 z-0" />
<TopBar /> <TopBar />
<Sidebar /> <Sidebar />
<CommandPalette />
<main <main
id="main-content"
className={cn( className={cn(
"relative z-0 pt-16 transition-[margin-left] duration-200 ease-in-out", "relative z-0 pt-16 transition-[margin-left] duration-200 ease-in-out",
collapsed ? "ml-16" : "ml-60" collapsed ? "ml-16" : "ml-60"

View file

@ -2,7 +2,8 @@
import * as React from "react"; import * as React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check, ChevronsUpDown, Plus } from "lucide-react"; import { Check, ChevronsUpDown, Plus, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useUserProfile } from "@/lib/user-profile"; import { useUserProfile } from "@/lib/user-profile";
import { import {
@ -31,15 +32,21 @@ export function OrgSwitcher({ collapsed }: { collapsed: boolean }) {
const router = useRouter(); const router = useRouter();
const [orgs, setOrgs] = React.useState<OrgListItem[]>([]); const [orgs, setOrgs] = React.useState<OrgListItem[]>([]);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [orgsLoading, setOrgsLoading] = React.useState(false);
const fetchOrgs = React.useCallback(async () => { const fetchOrgs = React.useCallback(async () => {
setOrgsLoading(true);
try { try {
const res = await fetch("/api/org/list"); const res = await fetch("/api/org/list");
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setOrgs(data.orgs ?? []); setOrgs(data.orgs ?? []);
} }
} catch {} } catch {
toast.error("Failed to load workspaces");
} finally {
setOrgsLoading(false);
}
}, []); }, []);
const initial = orgName const initial = orgName
@ -94,7 +101,11 @@ export function OrgSwitcher({ collapsed }: { collapsed: boolean }) {
sideOffset={collapsed ? 8 : 4} sideOffset={collapsed ? 8 : 4}
className="w-64" className="w-64"
> >
{orgs.map((org) => ( {orgsLoading ? (
<div className="flex items-center justify-center py-3">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : orgs.map((org) => (
<DropdownMenuItem <DropdownMenuItem
key={org.id} key={org.id}
className="flex items-center gap-2" className="flex items-center gap-2"

View file

@ -106,6 +106,7 @@ function NavItem({
const content = ( const content = (
<Link <Link
href={href} href={href}
aria-current={active ? "page" : undefined}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
active active
@ -139,6 +140,7 @@ export function Sidebar() {
return ( return (
<aside <aside
aria-label="Main navigation"
className={cn( className={cn(
"fixed left-0 top-16 z-30 flex h-[calc(100vh-4rem)] flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 ease-in-out", "fixed left-0 top-16 z-30 flex h-[calc(100vh-4rem)] flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 ease-in-out",
collapsed ? "w-16" : "w-60" collapsed ? "w-16" : "w-60"
@ -182,6 +184,7 @@ export function Sidebar() {
))} ))}
<button <button
onClick={toggle} onClick={toggle}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground/50 transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-foreground", "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground/50 transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
collapsed && "justify-center px-0" collapsed && "justify-center px-0"

View file

@ -58,7 +58,7 @@ export function TopBar() {
<div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-sm"> <div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-sm">
<ScanLine className="size-[18px] text-white" /> <ScanLine className="size-[18px] text-white" />
</div> </div>
<span className="text-base font-bold tracking-tight">Echo OCR</span> <span className="text-base font-bold tracking-tight">Echo</span>
</Link> </Link>
{process.env.NEXT_PUBLIC_ENV === "staging" && ( {process.env.NEXT_PUBLIC_ENV === "staging" && (
<span className="rounded-md bg-amber-500/15 px-2 py-0.5 text-xs font-semibold uppercase tracking-wider text-amber-600 ring-1 ring-amber-500/25 dark:text-amber-400"> <span className="rounded-md bg-amber-500/15 px-2 py-0.5 text-xs font-semibold uppercase tracking-wider text-amber-600 ring-1 ring-amber-500/25 dark:text-amber-400">
@ -93,6 +93,7 @@ export function TopBar() {
size="icon" size="icon"
className="size-9 rounded-xl sm:hidden" className="size-9 rounded-xl sm:hidden"
onClick={handleUploadClick} onClick={handleUploadClick}
aria-label="Upload documents"
/> />
} }
> >
@ -106,7 +107,7 @@ export function TopBar() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button variant="ghost" size="icon" className="size-9 rounded-xl" /> <Button variant="ghost" size="icon" className="size-9 rounded-xl" aria-label="User menu" />
} }
> >
<Avatar size="sm"> <Avatar size="sm">

View file

@ -7,11 +7,22 @@ import { useTheme } from "next-themes";
function ThemeToggle() { function ThemeToggle() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const cycleTheme = () => {
if (theme === "light") setTheme("dark");
else if (theme === "dark") setTheme("system");
else setTheme("light");
};
const themeLabel =
theme === "dark" ? "Dark" : theme === "light" ? "Light" : "System";
return ( return (
<button <button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} onClick={cycleTheme}
className="rounded-full p-2 text-muted-foreground hover:text-foreground transition-colors" className="rounded-full p-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Toggle theme" aria-label={`Theme: ${themeLabel}`}
title={`Theme: ${themeLabel}`}
> >
<Sun className="h-4 w-4 hidden dark:block" /> <Sun className="h-4 w-4 hidden dark:block" />
<Moon className="h-4 w-4 block dark:hidden" /> <Moon className="h-4 w-4 block dark:hidden" />

View file

@ -16,7 +16,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Notification = { type Notification = {
@ -34,6 +36,7 @@ export function NotificationCenter() {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [notifications, setNotifications] = React.useState<Notification[]>([]); const [notifications, setNotifications] = React.useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = React.useState(0); const [unreadCount, setUnreadCount] = React.useState(0);
const [listLoading, setListLoading] = React.useState(false);
const fetchUnreadCount = React.useCallback(async () => { const fetchUnreadCount = React.useCallback(async () => {
try { try {
@ -42,7 +45,9 @@ export function NotificationCenter() {
const data = await res.json(); const data = await res.json();
setUnreadCount(data.unreadCount ?? 0); setUnreadCount(data.unreadCount ?? 0);
} }
} catch { /* ignore */ } } catch {
// Polling failure — don't toast on every interval tick
}
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
@ -52,6 +57,7 @@ export function NotificationCenter() {
}, [fetchUnreadCount]); }, [fetchUnreadCount]);
const fetchNotifications = React.useCallback(async () => { const fetchNotifications = React.useCallback(async () => {
setListLoading(true);
try { try {
const res = await fetch("/api/notifications?limit=30"); const res = await fetch("/api/notifications?limit=30");
if (res.ok) { if (res.ok) {
@ -59,7 +65,11 @@ export function NotificationCenter() {
setNotifications(data.notifications ?? []); setNotifications(data.notifications ?? []);
setUnreadCount(data.unreadCount ?? 0); setUnreadCount(data.unreadCount ?? 0);
} }
} catch { /* ignore */ } } catch {
toast.error("Failed to load notifications");
} finally {
setListLoading(false);
}
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
@ -75,7 +85,9 @@ export function NotificationCenter() {
}); });
setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(0); setUnreadCount(0);
} catch { /* ignore */ } } catch {
toast.error("Failed to mark notifications as read");
}
}; };
const markRead = async (id: string) => { const markRead = async (id: string) => {
@ -87,7 +99,9 @@ export function NotificationCenter() {
}); });
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n)); setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
setUnreadCount((c) => Math.max(0, c - 1)); setUnreadCount((c) => Math.max(0, c - 1));
} catch { /* ignore */ } } catch {
toast.error("Failed to update notification");
}
}; };
return ( return (
@ -115,7 +129,19 @@ export function NotificationCenter() {
)} )}
</div> </div>
<div className="max-h-80 overflow-y-auto"> <div className="max-h-80 overflow-y-auto">
{notifications.length === 0 ? ( {listLoading ? (
<div className="space-y-1 p-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-3 py-2">
<Skeleton className="size-7 shrink-0 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-3/4 rounded" />
<Skeleton className="h-3 w-1/2 rounded" />
</div>
</div>
))}
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Bell className="size-8 mb-2 opacity-20" /> <Bell className="size-8 mb-2 opacity-20" />
<p className="text-sm">No notifications</p> <p className="text-sm">No notifications</p>
@ -158,18 +184,12 @@ function NotificationItem({
if (n.actionUrl) onClose(); if (n.actionUrl) onClose();
}; };
const content = ( const inner = (
<div <>
className={cn(
"flex gap-3 px-3 py-2.5 border-b border-border/30 transition-colors hover:bg-muted/50 cursor-pointer",
!n.read && "bg-primary/[0.03]"
)}
onClick={handleClick}
>
<div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full mt-0.5", color)}> <div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full mt-0.5", color)}>
{icon} {icon}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className={cn("text-xs font-medium truncate", !n.read && "text-foreground")}>{n.title}</span> <span className={cn("text-xs font-medium truncate", !n.read && "text-foreground")}>{n.title}</span>
{!n.read && <span className="size-1.5 shrink-0 rounded-full bg-primary" />} {!n.read && <span className="size-1.5 shrink-0 rounded-full bg-primary" />}
@ -180,13 +200,27 @@ function NotificationItem({
{formatTimeAgo(n.createdAt)} {formatTimeAgo(n.createdAt)}
</span> </span>
</div> </div>
</div> </>
);
const sharedClasses = cn(
"flex w-full gap-3 px-3 py-2.5 border-b border-border/30 transition-colors hover:bg-muted/50 cursor-pointer",
!n.read && "bg-primary/[0.03]"
); );
if (n.actionUrl) { if (n.actionUrl) {
return <Link href={n.actionUrl}>{content}</Link>; return (
<Link href={n.actionUrl} className={sharedClasses} onClick={handleClick}>
{inner}
</Link>
);
} }
return content;
return (
<button type="button" className={sharedClasses} onClick={handleClick}>
{inner}
</button>
);
} }
function formatTimeAgo(dateStr: string): string { function formatTimeAgo(dateStr: string): string {

View file

@ -0,0 +1,373 @@
"use client";
import * as React from "react";
import { CheckCircle2, Loader2 } from "lucide-react";
type FormField = {
id: string;
key: string;
label: string;
type: string;
section: string;
required: boolean;
sortOrder: number;
options: unknown;
placeholder: string | null;
helpText: string | null;
isCore: boolean;
};
type SurveyFormProps = {
formTemplateId: string;
organizationId: string;
fields: FormField[];
branding: { primaryColor?: string; logoUrl?: string } | null;
prefill: Record<string, string>;
};
export function SurveyForm({
formTemplateId,
organizationId,
fields,
branding,
prefill,
}: SurveyFormProps) {
const [values, setValues] = React.useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
for (const field of fields) {
init[field.key] = prefill[field.key] ?? "";
}
init.firstName = prefill.firstName ?? "";
init.lastName = prefill.lastName ?? "";
return init;
});
const [submitting, setSubmitting] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const primaryColor = branding?.primaryColor || undefined;
const handleChange = (key: string, value: string) => {
setValues((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!values.firstName?.trim() || !values.lastName?.trim()) {
setError("First name and last name are required.");
return;
}
setSubmitting(true);
try {
const { firstName, lastName, ...rest } = values;
const res = await fetch("/api/survey/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
formTemplateId,
organizationId,
firstName: firstName.trim(),
lastName: lastName.trim(),
fieldData: rest,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || "Submission failed");
}
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setSubmitting(false);
}
};
if (submitted) {
return (
<div className="glass-card flex flex-col items-center gap-4 rounded-2xl p-10 text-center">
<div
className={`flex size-14 items-center justify-center rounded-full ${
primaryColor ? "" : "bg-green-100 dark:bg-green-900/30"
}`}
style={primaryColor ? { backgroundColor: `${primaryColor}20` } : undefined}
>
<CheckCircle2
className={`size-7 ${primaryColor ? "" : "text-green-600 dark:text-green-400"}`}
style={primaryColor ? { color: primaryColor } : undefined}
/>
</div>
<div>
<h2 className="text-xl font-semibold">Thank you!</h2>
<p className="mt-1 text-sm text-muted-foreground">
Your response has been submitted successfully.
</p>
</div>
</div>
);
}
const nonCoreFields = fields.filter(
(f) => f.key !== "firstName" && f.key !== "lastName"
);
return (
<form onSubmit={handleSubmit} className="glass-card space-y-6 rounded-2xl p-6 sm:p-8">
<div className="grid gap-4 sm:grid-cols-2">
<FieldInput
label="First Name"
value={values.firstName}
onChange={(v) => handleChange("firstName", v)}
required
placeholder="First name"
primaryColor={primaryColor}
/>
<FieldInput
label="Last Name"
value={values.lastName}
onChange={(v) => handleChange("lastName", v)}
required
placeholder="Last name"
primaryColor={primaryColor}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{nonCoreFields.map((field) => (
<div
key={field.id}
className={
field.type === "textarea" || field.type === "multiline"
? "sm:col-span-2"
: ""
}
>
<FieldRenderer
field={field}
value={values[field.key] ?? ""}
onChange={(v) => handleChange(field.key, v)}
primaryColor={primaryColor}
/>
</div>
))}
</div>
{error && (
<p className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
{error}
</p>
)}
<button
type="submit"
disabled={submitting}
className="flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold text-white transition-opacity disabled:opacity-50"
style={{ backgroundColor: primaryColor || "oklch(0.20 0.005 90)" }}
>
{submitting && <Loader2 className="size-4 animate-spin" />}
{submitting ? "Submitting..." : "Submit"}
</button>
</form>
);
}
function FieldInput({
label,
value,
onChange,
required,
placeholder,
type = "text",
primaryColor,
}: {
label: string;
value: string;
onChange: (v: string) => void;
required?: boolean;
placeholder?: string;
type?: string;
primaryColor?: string;
}) {
return (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">
{label}
{required && <span className="ml-0.5 text-red-500">*</span>}
</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
placeholder={placeholder || undefined}
className="glass-input w-full rounded-lg px-3 py-2 text-sm outline-none transition-shadow focus:ring-2"
style={primaryColor ? { "--tw-ring-color": primaryColor } as React.CSSProperties : undefined}
/>
</div>
);
}
function FieldRenderer({
field,
value,
onChange,
primaryColor,
}: {
field: FormField;
value: string;
onChange: (v: string) => void;
primaryColor?: string;
}) {
const options = Array.isArray(field.options) ? field.options as string[] : [];
switch (field.type) {
case "select":
case "dropdown":
return (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">
{field.label}
{field.required && <span className="ml-0.5 text-red-500">*</span>}
</label>
{field.helpText && (
<p className="text-xs text-muted-foreground">{field.helpText}</p>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
required={field.required}
className="glass-input w-full rounded-lg px-3 py-2 text-sm outline-none transition-shadow focus:ring-2"
style={primaryColor ? { "--tw-ring-color": primaryColor } as React.CSSProperties : undefined}
>
<option value="">{field.placeholder || "Select..."}</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
);
case "checkbox":
case "multi-select":
return (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">
{field.label}
{field.required && <span className="ml-0.5 text-red-500">*</span>}
</label>
{field.helpText && (
<p className="text-xs text-muted-foreground">{field.helpText}</p>
)}
<div className="flex flex-wrap gap-2">
{options.map((opt) => {
const selected = value
.split(",")
.map((s) => s.trim())
.includes(opt);
return (
<button
key={opt}
type="button"
onClick={() => {
const current = value
? value.split(",").map((s) => s.trim())
: [];
const next = selected
? current.filter((v) => v !== opt)
: [...current, opt];
onChange(next.join(","));
}}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
selected
? "border-transparent text-white"
: "border-border bg-background text-foreground hover:bg-muted"
}`}
style={
selected
? { backgroundColor: primaryColor || "oklch(0.20 0.005 90)" }
: undefined
}
>
{opt}
</button>
);
})}
</div>
</div>
);
case "textarea":
case "multiline":
return (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">
{field.label}
{field.required && <span className="ml-0.5 text-red-500">*</span>}
</label>
{field.helpText && (
<p className="text-xs text-muted-foreground">{field.helpText}</p>
)}
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
required={field.required}
placeholder={field.placeholder || undefined}
rows={3}
className="glass-input w-full rounded-lg px-3 py-2 text-sm outline-none transition-shadow focus:ring-2"
style={primaryColor ? { "--tw-ring-color": primaryColor } as React.CSSProperties : undefined}
/>
</div>
);
case "boolean":
case "toggle":
return (
<div className="flex items-center gap-3 pt-5">
<button
type="button"
role="switch"
aria-checked={value === "true"}
onClick={() => onChange(value === "true" ? "false" : "true")}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
value === "true" ? "" : "bg-muted"
}`}
style={
value === "true"
? { backgroundColor: primaryColor || "oklch(0.20 0.005 90)" }
: undefined
}
>
<span
className={`absolute left-0.5 top-0.5 size-5 rounded-full bg-white shadow-sm transition-transform ${
value === "true" ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
<label className="text-sm font-medium text-foreground">
{field.label}
</label>
</div>
);
default:
return (
<FieldInput
label={field.label}
value={value}
onChange={onChange}
required={field.required}
placeholder={field.placeholder || undefined}
type={field.type === "email" ? "email" : field.type === "phone" || field.type === "tel" ? "tel" : "text"}
primaryColor={primaryColor}
/>
);
}
}

View file

@ -4,6 +4,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "./db"; import { prisma } from "./db";
import type { LanguageModel } from "ai"; import type { LanguageModel } from "ai";
import type { FormField as PrismaFormField } from "@/generated/prisma/client";
const DEFAULT_GATEWAY_MODEL = "openai/gpt-4o-mini"; const DEFAULT_GATEWAY_MODEL = "openai/gpt-4o-mini";
const DEFAULT_OLLAMA_MODEL = "llava:7b"; const DEFAULT_OLLAMA_MODEL = "llava:7b";
@ -107,6 +108,79 @@ SPECIAL ATTENTION for "Next Step" question (question 2): It has exactly 2 checkb
Be precise: return null for fields you cannot read.`; Be precise: return null for fields you cannot read.`;
type FormFieldDef = Pick<
PrismaFormField,
"key" | "label" | "type" | "options" | "visibleOnCard" | "visibleOnSurvey"
>;
export function buildSchemaFromFields(fields: FormFieldDef[]) {
const shape: Record<string, z.ZodTypeAny> = {};
for (const field of fields) {
const opts = Array.isArray(field.options)
? (field.options as string[])
: [];
switch (field.type) {
case "text":
case "email":
case "phone":
case "textarea":
case "url":
shape[field.key] = z.string().nullable().describe(field.label);
break;
case "select":
case "radio":
shape[field.key] = z
.string()
.nullable()
.describe(
opts.length > 0
? `${field.label}. Options: ${opts.join(", ")}`
: field.label
);
break;
case "multiselect":
shape[field.key] = z
.array(z.string())
.describe(
opts.length > 0
? `ONLY checked items for ${field.label}. Options: ${opts.join(", ")}`
: `Checked items for ${field.label}`
);
break;
case "checkbox":
shape[field.key] = z
.boolean()
.describe(`Whether ${field.label} checkbox is checked`);
break;
case "date":
shape[field.key] = z
.string()
.nullable()
.describe(`${field.label} as written`);
break;
case "number":
shape[field.key] = z.number().nullable().describe(field.label);
break;
default:
shape[field.key] = z.string().nullable().describe(field.label);
}
}
shape.confidence = z
.number()
.min(0)
.max(100)
.describe("Your confidence in the OCR accuracy, 0-100");
return z.object(shape);
}
export function buildPromptFromFields(
fields: FormFieldDef[],
templateName: string
): string {
const fieldList = fields.map((f) => `- ${f.label} (${f.type})`).join("\n");
return `You are analyzing a scanned ${templateName}. Extract ALL of the following fields from the image:\n\n${fieldList}\n\nCHECKBOX RULES - be very strict:\n- A checkbox is CHECKED only if it has a visible mark inside it: an X, a checkmark, a filled square, or pen/pencil marks inside the box.\n- An EMPTY box means UNCHECKED.\n- When in doubt, treat a checkbox as UNCHECKED.\n\nFor handwritten text, read it as accurately as possible.\nBe precise: return null for fields you cannot read.`;
}
export interface OcrResult { export interface OcrResult {
data: Record<string, unknown>; data: Record<string, unknown>;
confidence: number; confidence: number;
@ -165,7 +239,8 @@ async function extractStructured<T extends Record<string, unknown>>(
export async function ocrImage( export async function ocrImage(
imageBase64: string, imageBase64: string,
side: "response" | "survey" side: "response" | "survey",
templateFields?: { fields: FormFieldDef[]; templateName: string }
): Promise<OcrResult> { ): Promise<OcrResult> {
const settings = await getSettings(); const settings = await getSettings();
const provider = settings.aiProvider || "gateway"; const provider = settings.aiProvider || "gateway";
@ -174,7 +249,17 @@ export async function ocrImage(
let output: Record<string, unknown>; let output: Record<string, unknown>;
if (side === "response") { if (templateFields) {
const relevantFields = templateFields.fields.filter((f) =>
side === "response" ? f.visibleOnCard : f.visibleOnSurvey
);
const dynamicSchema = buildSchemaFromFields(relevantFields);
const dynamicPrompt = buildPromptFromFields(
relevantFields,
templateFields.templateName
);
output = await extractStructured(model, dynamicSchema, dynamicPrompt, imageBase64);
} else if (side === "response") {
output = await extractStructured(model, responseCardSchema, RESPONSE_SYSTEM_PROMPT, imageBase64); output = await extractStructured(model, responseCardSchema, RESPONSE_SYSTEM_PROMPT, imageBase64);
} else { } else {
output = await extractStructured(model, surveySchema, SURVEY_SYSTEM_PROMPT, imageBase64); output = await extractStructured(model, surveySchema, SURVEY_SYSTEM_PROMPT, imageBase64);

33
src/lib/api-auth.ts Normal file
View file

@ -0,0 +1,33 @@
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export class ApiAuthError extends Error {
constructor(message = "Unauthorized") {
super(message);
this.name = "ApiAuthError";
}
}
export async function requireApiAuth() {
const session = await auth();
if (!session?.user?.id) {
throw new ApiAuthError();
}
return session;
}
export async function requireApiAuthWithOrg() {
const session = await auth();
if (!session?.user?.id || !session.user.orgId) {
throw new ApiAuthError();
}
return session;
}
export function handleApiError(error: unknown) {
if (error instanceof ApiAuthError) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.error("[API Error]", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}

View file

@ -64,8 +64,6 @@ async function handleMessage(client: ImapFlow, uid: number, config: EmailConfig)
continue; continue;
} }
const isPdf = att.contentType?.toLowerCase() === "application/pdf";
try { try {
const job = await prisma.processingJob.create({ const job = await prisma.processingJob.create({
data: { data: {
@ -76,8 +74,7 @@ async function handleMessage(client: ImapFlow, uid: number, config: EmailConfig)
}); });
const sourceKey = `sources/${job.id}/${fileName}`; const sourceKey = `sources/${job.id}/${fileName}`;
const contentType = isPdf ? "application/pdf" : "image/jpeg"; await uploadBuffer(sourceKey, buffer, att.contentType || "application/octet-stream");
await uploadBuffer(sourceKey, buffer, contentType);
await enqueueProcessing(job.id); await enqueueProcessing(job.id);
log(`Queued: ${fileName} (job ${job.id})`); log(`Queued: ${fileName} (job ${job.id})`);

65
src/lib/form-templates.ts Normal file
View file

@ -0,0 +1,65 @@
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "./db";
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 },
];
export async function seedDefaultTemplate(organizationId: string) {
const existing = await prisma.formTemplate.findFirst({
where: { organizationId, isDefault: true },
});
if (existing) return existing;
const template = await prisma.formTemplate.create({
data: {
organizationId,
name: "Connect Card",
slug: "connect-card",
description: "Default connect card template",
isDefault: true,
isActive: true,
fields: {
create: DEFAULT_FIELDS.map((f) => ({
key: f.key,
label: f.label,
type: f.type,
section: f.section,
required: f.required ?? false,
removable: f.removable ?? true,
isCore: f.isCore ?? false,
sortOrder: f.sortOrder,
options: f.options ?? Prisma.JsonNull,
})),
},
},
include: { fields: true },
});
return template;
}
export { DEFAULT_FIELDS };

16
src/lib/job-recovery.ts Normal file
View file

@ -0,0 +1,16 @@
import { prisma } from "./db";
export async function recoverStuckJobs() {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const stuck = await prisma.processingJob.updateMany({
where: {
status: "processing",
updatedAt: { lt: tenMinutesAgo },
},
data: { status: "queued" },
});
if (stuck.count > 0) {
console.log(`[job-recovery] Reset ${stuck.count} stuck job(s) to queued`);
}
return stuck.count;
}

View file

@ -31,10 +31,11 @@ export async function createNotification(input: CreateNotificationInput) {
} }
export async function getNotifications({ export async function getNotifications({
userId,
unreadOnly = false, unreadOnly = false,
limit = 50, limit = 50,
}: { unreadOnly?: boolean; limit?: number } = {}) { }: { userId: string; unreadOnly?: boolean; limit?: number }) {
const where: Record<string, unknown> = { dismissed: false }; const where: Record<string, unknown> = { dismissed: false, userId };
if (unreadOnly) where.read = false; if (unreadOnly) where.read = false;
return prisma.notification.findMany({ return prisma.notification.findMany({
@ -44,29 +45,29 @@ export async function getNotifications({
}); });
} }
export async function getUnreadCount() { export async function getUnreadCount(userId: string) {
return prisma.notification.count({ return prisma.notification.count({
where: { read: false, dismissed: false }, where: { read: false, dismissed: false, userId },
}); });
} }
export async function markRead(id: string) { export async function markRead(id: string, userId: string) {
return prisma.notification.update({ return prisma.notification.update({
where: { id }, where: { id, userId },
data: { read: true }, data: { read: true },
}); });
} }
export async function markAllRead() { export async function markAllRead(userId: string) {
return prisma.notification.updateMany({ return prisma.notification.updateMany({
where: { read: false, dismissed: false }, where: { read: false, dismissed: false, userId },
data: { read: true }, data: { read: true },
}); });
} }
export async function dismissNotification(id: string) { export async function dismissNotification(id: string, userId: string) {
return prisma.notification.update({ return prisma.notification.update({
where: { id }, where: { id, userId },
data: { dismissed: true }, data: { dismissed: true },
}); });
} }

View file

@ -4,6 +4,7 @@ import { uploadBuffer, getBuffer, deleteObject } from "./storage";
import { ocrImage } from "./ai-ocr"; import { ocrImage } from "./ai-ocr";
import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf"; import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf";
import { fireIntegrationEvent } from "./integrations"; import { fireIntegrationEvent } from "./integrations";
import { linkCardToPerson } from "./person-linker";
const DELAY_BETWEEN_OCR_CALLS_MS = 2_000; const DELAY_BETWEEN_OCR_CALLS_MS = 2_000;
function ocrDelay() { function ocrDelay() {
@ -19,7 +20,7 @@ export async function processFile(
const cardIds: string[] = []; const cardIds: string[] = [];
try { try {
await prisma.processingJob.update({ const job = await prisma.processingJob.update({
where: { id: jobId }, where: { id: jobId },
data: { status: "processing" }, data: { status: "processing" },
}); });
@ -70,6 +71,7 @@ export async function processFile(
data: { data: {
sourceFile: sourceKey, sourceFile: sourceKey,
ocrStatus: "processing", ocrStatus: "processing",
organizationId: job.organizationId,
}, },
}); });
cardIds.push(card.id); cardIds.push(card.id);
@ -156,6 +158,12 @@ export async function processFile(
}); });
fireIntegrationEvent("ocr_complete", card.id).catch(() => {}); fireIntegrationEvent("ocr_complete", card.id).catch(() => {});
if (job.organizationId) {
linkCardToPerson(card.id, job.organizationId).catch((err) => {
console.error("[person-linker] Failed to link card:", err);
});
}
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unknown OCR error"; const message = err instanceof Error ? err.message : "Unknown OCR error";
await prisma.responseCard.update({ await prisma.responseCard.update({
@ -268,6 +276,12 @@ export async function reprocessCard(cardId: string): Promise<void> {
rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })), rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })),
}, },
}); });
if (card.organizationId) {
linkCardToPerson(card.id, card.organizationId).catch((err) => {
console.error("[person-linker] Failed to link card:", err);
});
}
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unknown OCR error"; const message = err instanceof Error ? err.message : "Unknown OCR error";
await prisma.responseCard.update({ await prisma.responseCard.update({

53
src/lib/person-linker.ts Normal file
View file

@ -0,0 +1,53 @@
import { prisma } from "./db";
export async function linkCardToPerson(cardId: string, organizationId: string) {
const card = await prisma.responseCard.findUnique({ where: { id: cardId } });
if (!card || card.personId) return null;
const firstName = card.firstName || card.name?.split(" ")[0] || "";
const lastName = card.lastName || card.name?.split(" ").slice(1).join(" ") || "";
const email = card.email;
if (!firstName && !lastName && !email) return null;
// Try email match first
if (email) {
const match = await prisma.person.findFirst({
where: { organizationId, email: email.toLowerCase(), mergedIntoId: null },
});
if (match) {
await prisma.responseCard.update({ where: { id: cardId }, data: { personId: match.id } });
return match;
}
}
// Try name match
if (firstName && lastName) {
const match = await prisma.person.findFirst({
where: {
organizationId,
firstName: { equals: firstName, mode: "insensitive" },
lastName: { equals: lastName, mode: "insensitive" },
mergedIntoId: null,
},
});
if (match) {
await prisma.responseCard.update({ where: { id: cardId }, data: { personId: match.id } });
return match;
}
}
// Create new person
const person = await prisma.person.create({
data: {
organizationId,
firstName: firstName || "Unknown",
lastName: lastName || "",
email: email?.toLowerCase() || null,
cellPhone: card.cellPhone || null,
fieldData: (card.fieldData as object) || null,
},
});
await prisma.responseCard.update({ where: { id: cardId }, data: { personId: person.id } });
return person;
}

View file

@ -28,7 +28,7 @@ const STORAGE_KEY = "echo-ocr-user-profile";
type UserProfileContextValue = { type UserProfileContextValue = {
profile: UserProfile; profile: UserProfile;
updateProfile: (updates: Partial<UserProfile>) => void; updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
initials: string; initials: string;
userId?: string; userId?: string;
role: UserRole; role: UserRole;
@ -96,12 +96,26 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
}; };
}, [session, localOverrides]); }, [session, localOverrides]);
const updateProfile = React.useCallback((updates: Partial<UserProfile>) => { const updateProfile = React.useCallback(async (updates: Partial<UserProfile>) => {
setLocalOverrides((prev) => { setLocalOverrides((prev) => {
const next = { ...prev, ...updates }; const next = { ...prev, ...updates };
saveLocalProfile(next); saveLocalProfile(next);
return next; return next;
}); });
const apiPayload: Record<string, string> = {};
if (typeof updates.displayName === "string") apiPayload.displayName = updates.displayName;
if (typeof updates.avatarUrl === "string") apiPayload.avatarUrl = updates.avatarUrl;
if (Object.keys(apiPayload).length > 0) {
try {
await fetch("/api/auth/me", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(apiPayload),
});
} catch {}
}
}, []); }, []);
const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]); const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]);
@ -144,7 +158,7 @@ export function useUserProfile() {
if (!ctx) { if (!ctx) {
return { return {
profile: DEFAULT_PROFILE, profile: DEFAULT_PROFILE,
updateProfile: () => {}, updateProfile: async () => {},
initials: "", initials: "",
userId: undefined, userId: undefined,
role: "viewer" as UserRole, role: "viewer" as UserRole,

View file

@ -51,11 +51,10 @@ function isOnboardingExempt(pathname: string) {
export async function middleware(req: NextRequest) { export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl; const { pathname } = req.nextUrl;
if ( const isStaticFile = pathname.startsWith("/_next/") ||
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") || pathname.startsWith("/favicon") ||
pathname.includes(".") /^\/(.*\.(ico|svg|png|jpg|jpeg|gif|webp|woff2?|ttf|css|js|map))$/.test(pathname);
) { if (isStaticFile) {
return NextResponse.next(); return NextResponse.next();
} }