diff --git a/package-lock.json b/package-lock.json
index e2df600..2c181b0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3",
+ "@types/qrcode": "^1.5.6",
"@upstash/qstash": "^2.10.1",
"ai": "^6.0.116",
"basic-ftp": "^5.3.0",
@@ -39,8 +40,10 @@
"pdfjs-dist": "^5.6.205",
"pg": "^8.20.0",
"prisma": "^7.4.2",
+ "qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "recharts": "^3.8.1",
"rrule": "^2.8.1",
"shadcn": "^4.0.2",
"sharp": "^0.34.5",
@@ -3969,6 +3972,42 @@
}
}
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
+ "version": "11.1.4",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
+ "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -4745,6 +4784,12 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -5151,6 +5196,69 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -5200,7 +5308,6 @@
"version": "25.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -5218,6 +5325,15 @@
"pg-types": "^2.2.0"
}
},
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -5243,6 +5359,12 @@
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
"license": "MIT"
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@types/validate-npm-package-name": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -6551,6 +6673,15 @@
"node": ">=6"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001777",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
@@ -6944,6 +7075,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -7041,6 +7293,21 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -7210,6 +7477,12 @@
"node": ">=0.3.1"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -7595,6 +7868,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es-toolkit": {
+ "version": "1.45.1",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
+ "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -8075,6 +8358,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -9093,6 +9382,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/immer": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -9140,6 +9439,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -11287,6 +11595,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/package-manager-detector": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
@@ -11373,7 +11690,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -11626,6 +11942,15 @@
"pathe": "^2.0.3"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -11933,6 +12258,168 @@
],
"license": "MIT"
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/qrcode/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/qrcode/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/qrcode/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@@ -12033,9 +12520,31 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -12130,6 +12639,51 @@
"node": ">= 4"
}
},
+ "node_modules/recharts": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
+ "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
+ "license": "MIT",
+ "workspaces": [
+ "www"
+ ],
+ "dependencies": {
+ "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^10.1.1",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.1.1",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -12207,6 +12761,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@@ -12511,6 +13071,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -13661,7 +14227,6 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
@@ -13868,6 +14433,28 @@
"node": ">= 0.8"
}
},
+ "node_modules/victory-vendor": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -13959,6 +14546,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
diff --git a/package.json b/package.json
index c393149..9ec1497 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3",
+ "@types/qrcode": "^1.5.6",
"@upstash/qstash": "^2.10.1",
"ai": "^6.0.116",
"basic-ftp": "^5.3.0",
@@ -43,8 +44,10 @@
"pdfjs-dist": "^5.6.205",
"pg": "^8.20.0",
"prisma": "^7.4.2",
+ "qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "recharts": "^3.8.1",
"rrule": "^2.8.1",
"shadcn": "^4.0.2",
"sharp": "^0.34.5",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 09b781d..accdc75 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -84,13 +84,15 @@ model Organization {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- locations Location[]
- members OrgMember[]
- invitations Invitation[]
- apiKeys ApiKey[]
- cards ResponseCard[]
- jobs ProcessingJob[]
- integrations Integration[]
+ locations Location[]
+ members OrgMember[]
+ invitations Invitation[]
+ apiKeys ApiKey[]
+ cards ResponseCard[]
+ jobs ProcessingJob[]
+ integrations Integration[]
+ formTemplates FormTemplate[]
+ persons Person[]
}
model OrgMember {
@@ -137,6 +139,81 @@ model CollectionDay {
updatedAt DateTime @updatedAt
}
+// ─── Form Templates (Dynamic Fields) ────────────────────────
+
+model FormTemplate {
+ id String @id @default(cuid())
+ organizationId String
+ name String
+ slug String
+ description String?
+ isDefault Boolean @default(false)
+ isActive Boolean @default(true)
+ version Int @default(1)
+ branding Json?
+ settings Json?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ fields FormField[]
+ cards ResponseCard[]
+
+ @@unique([organizationId, slug])
+ @@index([organizationId])
+}
+
+model FormField {
+ id String @id @default(cuid())
+ formTemplateId String
+ key String
+ label String
+ type String
+ section String @default("personal")
+ required Boolean @default(false)
+ removable Boolean @default(true)
+ sortOrder Int @default(0)
+ options Json?
+ placeholder String?
+ helpText String?
+ validation Json?
+ visibleOnCard Boolean @default(true)
+ visibleOnSurvey Boolean @default(true)
+ isCore Boolean @default(false)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ formTemplate FormTemplate @relation(fields: [formTemplateId], references: [id], onDelete: Cascade)
+
+ @@unique([formTemplateId, key])
+ @@index([formTemplateId, sortOrder])
+}
+
+// ─── People (Contact Directory) ─────────────────────────────
+
+model Person {
+ id String @id @default(cuid())
+ organizationId String
+ firstName String
+ lastName String
+ email String?
+ cellPhone String?
+ fieldData Json?
+ mergedIntoId String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ cards ResponseCard[]
+ mergedInto Person? @relation("PersonMerge", fields: [mergedIntoId], references: [id])
+ mergedFrom Person[] @relation("PersonMerge")
+
+ @@index([organizationId])
+ @@index([email])
+ @@index([organizationId, firstName, lastName])
+ @@index([mergedIntoId])
+}
+
// ─── Integrations ────────────────────────────────────────────
model Integration {
@@ -174,6 +251,18 @@ model Invitation {
organization Organization @relation(fields: [organizationId], references: [id])
}
+// ─── Password Reset ─────────────────────────────────────────
+
+model PasswordResetToken {
+ id String @id @default(cuid())
+ email String
+ token String @unique
+ expiresAt DateTime
+ createdAt DateTime @default(now())
+
+ @@index([email])
+}
+
// ─── System config ───────────────────────────────────────────
model SystemConfig {
@@ -191,6 +280,8 @@ model ResponseCard {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ firstName String?
+ lastName String?
name String?
gender String?
dateOfBirth String?
@@ -250,6 +341,11 @@ model ResponseCard {
mondayItemId String?
+ fieldData Json?
+ formTemplateId String?
+ personId String?
+ submissionSource String?
+
organizationId String?
locationId String?
collectionDayId String?
@@ -258,10 +354,13 @@ model ResponseCard {
organization Organization? @relation(fields: [organizationId], references: [id])
location Location? @relation(fields: [locationId], references: [id])
collectionDay CollectionDay? @relation(fields: [collectionDayId], references: [id])
+ formTemplate FormTemplate? @relation(fields: [formTemplateId], references: [id])
+ person Person? @relation(fields: [personId], references: [id])
@@index([ocrStatus])
@@index([reviewStatus])
@@index([name])
+ @@index([firstName, lastName])
@@index([createdAt])
@@index([mondayItemId])
@@index([assignedToId])
@@ -269,6 +368,8 @@ model ResponseCard {
@@index([organizationId])
@@index([locationId])
@@index([collectionDayId])
+ @@index([formTemplateId])
+ @@index([personId])
}
model ProcessingJob {
diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx
new file mode 100644
index 0000000..35e001c
--- /dev/null
+++ b/src/app/(auth)/forgot-password/page.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { ScanLine, Mail, Loader2, ArrowLeft } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+export default function ForgotPasswordPage() {
+ const [email, setEmail] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [sent, setSent] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setLoading(true);
+
+ await fetch("/api/auth/forgot-password", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ });
+
+ setSent(true);
+ setLoading(false);
+ }
+
+ return (
+
+
+
+
+
+
Reset your password
+
+ Enter your email and we'll send you a reset link
+
+
+
+ {sent ? (
+
+
+ If an account with that email exists, we've sent a password
+ reset link. Check your inbox.
+
+
+
+ Back to login
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
index 8f55335..c532932 100644
--- a/src/app/(auth)/login/page.tsx
+++ b/src/app/(auth)/login/page.tsx
@@ -60,7 +60,7 @@ function LoginForm() {
Welcome back
- Sign in to your Echo OCR account
+ Sign in to your Echo account
@@ -89,7 +89,12 @@ function LoginForm() {
-
Password
+
+ Password
+
+ Forgot password?
+
+
@@ -587,7 +587,7 @@ export default function OnboardingPage() {
Upload Source
- How will response cards get into Echo OCR?
+ How will response cards get into Echo?
@@ -746,7 +746,7 @@ export default function OnboardingPage() {
AI Provider
- Choose how Echo OCR processes your scanned cards.
+ Choose how Echo processes your scanned cards.
diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx
new file mode 100644
index 0000000..b7dde50
--- /dev/null
+++ b/src/app/(auth)/reset-password/page.tsx
@@ -0,0 +1,168 @@
+"use client";
+
+import { Suspense, useState } from "react";
+import { useSearchParams, useRouter } from "next/navigation";
+import Link from "next/link";
+import { ScanLine, Lock, Loader2, ArrowLeft, CheckCircle2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+export default function ResetPasswordPage() {
+ return (
+
+
+
+ );
+}
+
+function ResetForm() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const token = searchParams.get("token");
+
+ const [password, setPassword] = useState("");
+ const [confirm, setConfirm] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError("");
+
+ if (password.length < 8) {
+ setError("Password must be at least 8 characters");
+ return;
+ }
+ if (password !== confirm) {
+ setError("Passwords do not match");
+ return;
+ }
+
+ setLoading(true);
+
+ const res = await fetch("/api/auth/reset-password", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, password }),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ setError(data.error || "Something went wrong. Please try again.");
+ setLoading(false);
+ return;
+ }
+
+ setSuccess(true);
+ setLoading(false);
+ }
+
+ if (!token) {
+ return (
+
+
+ Invalid or missing reset token.
+
+
+ Request a new reset link
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
Choose a new password
+
+ Enter your new password below
+
+
+
+ {success ? (
+
+
+
+
Password updated successfully!
+
+
router.push("/login")}
+ >
+ Sign in with new password
+
+
+ ) : (
+ <>
+ {error && (
+
+ {error}
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/app/(auth)/setup/page.tsx b/src/app/(auth)/setup/page.tsx
index a89d0cd..8ce40fe 100644
--- a/src/app/(auth)/setup/page.tsx
+++ b/src/app/(auth)/setup/page.tsx
@@ -122,7 +122,7 @@ export default function SetupPage() {
-
Set up Echo OCR
+
Set up Echo
Configure your instance in a few steps
diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx
index 89741b1..3dc1445 100644
--- a/src/app/(auth)/signup/page.tsx
+++ b/src/app/(auth)/signup/page.tsx
@@ -90,7 +90,7 @@ function SignupForm() {
{token
? "Complete your account setup"
- : "Get started with Echo OCR"}
+ : "Get started with Echo"}
diff --git a/src/app/(dashboard)/cards/[id]/page.tsx b/src/app/(dashboard)/cards/[id]/page.tsx
index c91e03c..04c4540 100644
--- a/src/app/(dashboard)/cards/[id]/page.tsx
+++ b/src/app/(dashboard)/cards/[id]/page.tsx
@@ -47,6 +47,27 @@ import {
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useUserProfile } from "@/lib/user-profile";
+import { DynamicField, type FormFieldDef } from "@/components/cards/dynamic-field";
+
+const SECTION_LABELS: Record = {
+ personal: "Personal Information",
+ contact: "Contact Details",
+ address: "Address",
+ survey: "Survey Responses",
+ followup: "Follow-Up",
+ custom: "Custom Fields",
+};
+
+const SECTION_ORDER = ["personal", "contact", "address", "survey", "followup", "custom"];
+
+const SECTION_ICONS: Record = {
+ personal: "user",
+ contact: "user",
+ address: "user",
+ survey: "clipboardList",
+ followup: "clipboardCheck",
+ custom: "clipboardList",
+};
const MESSAGE_TOPIC_OPTIONS = [
"Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt",
@@ -64,8 +85,16 @@ const HOW_HEARD_OPTIONS = [
"Social Media", "Google", "Personal Invite",
];
+type TemplateData = {
+ id: string;
+ name: string;
+ fields: FormFieldDef[];
+};
+
type CardData = {
id: string;
+ firstName: string | null;
+ lastName: string | null;
name: string | null;
gender: string | null;
dateOfBirth: string | null;
@@ -114,6 +143,9 @@ type CardData = {
mondayItemId: string | null;
frontImageUrl: string | null;
backImageUrl: string | null;
+ fieldData: Record | null;
+ formTemplateId: string | null;
+ formTemplate?: TemplateData | null;
};
type ActivityEntry = {
@@ -137,6 +169,7 @@ export default function CardDetailPage() {
const isViewer = role === "viewer";
const [card, setCard] = React.useState(null);
+ const [loadError, setLoadError] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [reprocessing, setReprocessing] = React.useState(false);
@@ -149,6 +182,8 @@ export default function CardDetailPage() {
const [pushingToMonday, setPushingToMonday] = React.useState(false);
const [users, setUsers] = React.useState([]);
const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null });
+ const [templateFields, setTemplateFields] = React.useState(null);
+ const [fieldDataEdits, setFieldDataEdits] = React.useState>({});
const isAssignedToMe = card?.assignedToId && userId === card.assignedToId;
const canEdit = isAdmin || (isReviewer && isAssignedToMe);
@@ -156,13 +191,27 @@ export default function CardDetailPage() {
const fetchCard = React.useCallback(async () => {
setLoading(true);
+ setLoadError(false);
try {
const res = await fetch(`/api/cards/${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
setCard(data);
setEdits({});
+ setFieldDataEdits({});
+
+ if (data.formTemplate?.fields) {
+ setTemplateFields(data.formTemplate.fields);
+ } else if (!data.formTemplateId) {
+ fetch("/api/form-templates?default=true")
+ .then((r) => r.json())
+ .then((d) => {
+ if (d.template?.fields) setTemplateFields(d.template.fields);
+ })
+ .catch(() => {});
+ }
} catch {
+ setLoadError(true);
toast.error("Failed to load card");
} finally {
setLoading(false);
@@ -253,11 +302,62 @@ export default function CardDetailPage() {
setEdits((prev) => ({ ...prev, [field]: value }));
};
+ const getDynamicValue = React.useCallback(
+ (field: FormFieldDef): unknown => {
+ if (field.isCore) {
+ const key = field.key;
+ if (key in edits) return edits[key];
+ return (card as unknown as Record)?.[key] ?? null;
+ }
+ if (field.key in fieldDataEdits) return fieldDataEdits[field.key];
+ const fd = card?.fieldData as Record | null;
+ if (fd && field.key in fd) return fd[field.key];
+ if (card && field.key in (card as unknown as Record)) {
+ return (card as unknown as Record)[field.key];
+ }
+ return null;
+ },
+ [card, edits, fieldDataEdits]
+ );
+
+ const setDynamicValue = React.useCallback(
+ (field: FormFieldDef, value: unknown) => {
+ if (field.isCore) {
+ setEdits((prev) => ({ ...prev, [field.key]: value }));
+ } else {
+ setFieldDataEdits((prev) => ({ ...prev, [field.key]: value }));
+ }
+ },
+ []
+ );
+
+ const dynamicSections = React.useMemo(() => {
+ if (!templateFields) return null;
+ const grouped: Record = {};
+ for (const field of templateFields) {
+ if (!grouped[field.section]) grouped[field.section] = [];
+ grouped[field.section].push(field);
+ }
+ return SECTION_ORDER
+ .filter((s) => grouped[s]?.length > 0)
+ .map((s) => ({
+ key: s,
+ label: SECTION_LABELS[s] || s,
+ iconKey: SECTION_ICONS[s] || "clipboardList",
+ fields: grouped[s],
+ }));
+ }, [templateFields]);
+
const handleSave = async () => {
- if (Object.keys(edits).length === 0) return;
+ const hasFieldDataChanges = Object.keys(fieldDataEdits).length > 0;
+ if (Object.keys(edits).length === 0 && !hasFieldDataChanges) return;
setSaving(true);
try {
const payload: Record = { ...edits };
+ if (hasFieldDataChanges) {
+ const currentFieldData = (card?.fieldData as Record) || {};
+ payload.fieldData = { ...currentFieldData, ...fieldDataEdits };
+ }
if (card?.reviewStatus === "assigned") {
payload.reviewStatus = "in_review";
}
@@ -286,6 +386,10 @@ export default function CardDetailPage() {
...edits,
reviewStatus: "reviewed",
};
+ if (Object.keys(fieldDataEdits).length > 0) {
+ const currentFieldData = (card?.fieldData as Record) || {};
+ payload.fieldData = { ...currentFieldData, ...fieldDataEdits };
+ }
const res = await fetch(`/api/cards/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -399,10 +503,28 @@ export default function CardDetailPage() {
if (!card) {
return (
-
-
router.push("/")}>
- Back to Dashboard
-
+
+
+
+
+ {loadError ? "Something went wrong" : "This card doesn't exist"}
+
+
+ {loadError
+ ? "We couldn't load this card. Check your connection and try again."
+ : "The card you're looking for may have been deleted or the link is incorrect."}
+
+
+ {loadError && (
+
+ Retry
+
+ )}
+
router.push("/cards")}>
+ Go back to cards
+
+
+
);
}
@@ -410,7 +532,7 @@ export default function CardDetailPage() {
const confidence = card.ocrConfidence;
const ocrStatus = card.ocrStatus;
const reviewStatus = card.reviewStatus;
- const hasEdits = Object.keys(edits).length > 0;
+ const hasEdits = Object.keys(edits).length > 0 || Object.keys(fieldDataEdits).length > 0;
return (
@@ -552,92 +674,130 @@ export default function CardDetailPage() {
-
-
-
-
- Personal Information
-
-
-
-
- setField("name", v)} readOnly={!canEdit} />
- setField("email", v)} readOnly={!canEdit} />
- setField("cellPhone", v)} readOnly={!canEdit} />
- setField("homePhone", v)} readOnly={!canEdit} />
- setField("gender", v)} readOnly={!canEdit} />
- setField("dateOfBirth", v)} readOnly={!canEdit} />
- setField("maritalStatus", v)} readOnly={!canEdit} />
- setField("visitType", v)} readOnly={!canEdit} />
-
-
-
- setField("address", v)} readOnly={!canEdit} />
- setField("aptNumber", v)} readOnly={!canEdit} />
- setField("city", v)} readOnly={!canEdit} />
- setField("state", v)} readOnly={!canEdit} />
- setField("zip", v)} readOnly={!canEdit} />
-
-
-
- Prayer Requests
-
-
- setField("prayerForTeam", v)} readOnly={!canEdit} />
- setField("prayerConfidential", v)} readOnly={!canEdit} />
-
-
-
+ {dynamicSections ? (
+ dynamicSections.map((section) => {
+ const IconComp =
+ section.iconKey === "user" ? User :
+ section.iconKey === "clipboardCheck" ? ClipboardCheck :
+ ClipboardList;
+ const isWide = section.key === "survey" || section.key === "custom";
+ return (
+
+
+
+
+ {section.label}
+
+
+
+
+ {section.fields.map((field) => (
+ setDynamicValue(field, v)}
+ readOnly={!canEdit}
+ />
+ ))}
+
+
+
+ );
+ })
+ ) : (
+ <>
+
+
+
+
+ Personal Information
+
+
+
+
+ setField("name", v)} readOnly={!canEdit} />
+ setField("email", v)} readOnly={!canEdit} />
+ setField("cellPhone", v)} readOnly={!canEdit} />
+ setField("homePhone", v)} readOnly={!canEdit} />
+ setField("gender", v)} readOnly={!canEdit} />
+ setField("dateOfBirth", v)} readOnly={!canEdit} />
+ setField("maritalStatus", v)} readOnly={!canEdit} />
+ setField("visitType", v)} readOnly={!canEdit} />
+
+
+
+ setField("address", v)} readOnly={!canEdit} />
+ setField("aptNumber", v)} readOnly={!canEdit} />
+ setField("city", v)} readOnly={!canEdit} />
+ setField("state", v)} readOnly={!canEdit} />
+ setField("zip", v)} readOnly={!canEdit} />
+
+
+
+ Prayer Requests
+
+
+ setField("prayerForTeam", v)} readOnly={!canEdit} />
+ setField("prayerConfidential", v)} readOnly={!canEdit} />
+
+
+
-
-
-
-
- Survey Responses
-
-
-
-
- setField("messageTopics", v)}
- readOnly={!canEdit}
- />
- setField("nextStep", v)}
- readOnly={!canEdit}
- />
- setField("attendanceDuration", v)} readOnly={!canEdit} />
- setField("campusPreference", v)}
- readOnly={!canEdit}
- />
- setField("howHeard", v)}
- readOnly={!canEdit}
- />
- setField("serviceAttended", v)} readOnly={!canEdit} />
-
-
-
+
+
+
+
+ Survey Responses
+
+
+
+
+ setField("messageTopics", v)}
+ readOnly={!canEdit}
+ />
+ setField("nextStep", v)}
+ readOnly={!canEdit}
+ />
+ setField("attendanceDuration", v)} readOnly={!canEdit} />
+ setField("campusPreference", v)}
+ readOnly={!canEdit}
+ />
+ setField("howHeard", v)}
+ readOnly={!canEdit}
+ />
+ setField("serviceAttended", v)} readOnly={!canEdit} />
+
+
+
+ >
+ )}
diff --git a/src/app/(dashboard)/events/[id]/page.tsx b/src/app/(dashboard)/events/[id]/page.tsx
index 1b36d59..d759a90 100644
--- a/src/app/(dashboard)/events/[id]/page.tsx
+++ b/src/app/(dashboard)/events/[id]/page.tsx
@@ -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 { 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() {
+ const { id } = useParams<{ id: string }>();
+ const [day, setDay] = useState(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 (
+
+
+
+ );
+ }
+
+ if (notFound || !day) {
+ return (
+
+
+
} variant="outline" size="sm" className="rounded-xl">
+
+ Back to Collection Days
+
+
+ );
+ }
+
return (
-
- Event detail view coming soon.
+ >
+
} variant="outline" size="sm" className="rounded-xl">
+
+ All Collection Days
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {day.isActive ? "Active" : "Inactive"}
+
+ {day.isRecurring && (
+
+
+ Recurring
+
+ )}
+ {day.rrule && (
+
+ Rule: {day.rrule}
+
+ )}
+
);
}
+
+function InfoCard({
+ icon: Icon,
+ label,
+ value,
+}: {
+ icon: React.ComponentType<{ className?: string }>;
+ label: string;
+ value: string;
+}) {
+ return (
+
+
+
+ {label}
+
+
{value}
+
+ );
+}
diff --git a/src/app/(dashboard)/events/page.tsx b/src/app/(dashboard)/events/page.tsx
index a8c4584..777d3a0 100644
--- a/src/app/(dashboard)/events/page.tsx
+++ b/src/app/(dashboard)/events/page.tsx
@@ -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 { 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() {
+ const [days, setDays] = useState
([]);
+ 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 (
-
-
-
No collection days yet
-
- Collection days will be available once organizations and locations are
- configured. Check back after setup is complete.
-
-
+ >
+
} size="sm" className="rounded-xl">
+
+ Create Collection Day
+
+
+
+ {loading ? (
+
+
+
+ ) : days.length === 0 ? (
+
+
+
No collection days configured
+
+ Set up your locations and service schedule to start organizing
+ response cards by collection day.
+
+
}
+ size="sm"
+ className="mt-6 rounded-xl"
+ >
+
+ Configure Locations
+
+
+ ) : (
+
+ {days.map((day) => (
+
+
+
+ {day.name}
+
+
+ {day.isActive ? "Active" : "Inactive"}
+
+
+
+ {day.description && (
+
+ {day.description}
+
+ )}
+
+
+
+
+ {day.location.name}
+
+ {day.dayOfWeek != null && (
+
+
+ {DAY_NAMES[day.dayOfWeek]}
+ {day.timeStart && ` at ${day.timeStart}`}
+
+ )}
+
+
+ {day._count.cards} card{day._count.cards !== 1 ? "s" : ""}
+
+
+
+ ))}
+
+ )}
);
}
diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx
index 9cc2e17..54664a3 100644
--- a/src/app/(dashboard)/page.tsx
+++ b/src/app/(dashboard)/page.tsx
@@ -15,6 +15,12 @@ import {
UserPlus,
X,
} from "lucide-react";
+import {
+ ResponsiveContainer,
+ AreaChart,
+ Area,
+} from "recharts";
+import { toast } from "sonner";
import { Header } from "@/components/layout/header";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
@@ -25,6 +31,14 @@ type Stats = {
byReviewStatus: Record;
};
+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";
function loadDismissedHints(): string[] {
@@ -49,6 +63,7 @@ function dismissHint(id: string) {
export default function DashboardHomePage() {
const [stats, setStats] = useState(null);
+ const [trends, setTrends] = useState(null);
const [loading, setLoading] = useState(true);
const [dismissedHints, setDismissedHints] = useState([]);
@@ -62,10 +77,17 @@ export default function DashboardHomePage() {
}, []);
useEffect(() => {
- fetch("/api/stats")
- .then((r) => r.json())
- .then(setStats)
- .catch(() => {})
+ Promise.all([
+ fetch("/api/stats").then((r) => r.json()),
+ fetch("/api/stats/trends?period=7&groupBy=day")
+ .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));
}, []);
@@ -74,30 +96,44 @@ export default function DashboardHomePage() {
const pending = (stats?.byOcrStatus?.pending ?? 0) + (stats?.byOcrStatus?.processing ?? 0);
const unreviewed = stats?.byReviewStatus?.unreviewed ?? 0;
+ const sparkData = trends?.series ?? [];
+
const summaryCards = [
{
label: "Total Cards",
value: stats?.total ?? 0,
icon: CreditCard,
href: "/cards",
+ sparkKey: "total" as const,
},
{
label: "OCR Complete",
value: completed,
icon: CheckCircle2,
href: "/cards?ocrStatus=complete",
+ sparkKey: "scanned" as const,
},
{
label: "Needs Review",
value: unreviewed,
icon: Clock,
href: "/cards?reviewStatus=unreviewed",
+ sparkKey: null,
},
{
label: "Errors",
value: errors,
icon: AlertCircle,
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}
/>
-
+
{summaryCards.map((card) =>
loading ? (
@@ -155,6 +191,30 @@ export default function DashboardHomePage() {
{card.value.toLocaleString()}
+ {"sub" in card && card.sub && (
+
{card.sub}
+ )}
+ {card.sparkKey && sparkData.length > 1 && (
+
+ )}
)
)}
diff --git a/src/app/(dashboard)/people/[id]/page.tsx b/src/app/(dashboard)/people/[id]/page.tsx
new file mode 100644
index 0000000..8d9036d
--- /dev/null
+++ b/src/app/(dashboard)/people/[id]/page.tsx
@@ -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
| 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(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([]);
+ const [mergeSearching, setMergeSearching] = React.useState(false);
+ const [mergeTarget, setMergeTarget] = React.useState(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 (
+
+ );
+ }
+
+ if (notFound || !person) {
+ return (
+
+
+
+ The person you're looking for doesn't exist or has been
+ removed.
+
+
router.push("/people")}
+ >
+ Back to People
+
+
+ );
+ }
+
+ const fieldDataEntries = person.fieldData
+ ? Object.entries(person.fieldData).filter(
+ ([, v]) => v !== null && v !== undefined && v !== ""
+ )
+ : [];
+
+ return (
+
+
router.push("/people")}
+ >
+ Back to People
+
+
+
+
+
+ {/* Contact info card */}
+
+
+
+
+
+ Contact Information
+
+ {!editing ? (
+
+
+
+ ) : (
+
+
+
+
+
+ {saving ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+ {editing ? (
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+ {fieldDataEntries.length > 0 && (
+ <>
+
+
+
+ Additional Data
+
+
+ {fieldDataEntries.map(([key, value]) => (
+ s.toUpperCase())
+ .trim()}
+ value={String(value)}
+ />
+ ))}
+
+
+ >
+ )}
+
+
+
+ Created {formatDate(person.createdAt)}
+ Updated {formatDate(person.updatedAt)}
+
+
+
+
+ {/* Linked cards */}
+
+
+
+
+ Linked Response Cards
+
+ {person._count.cards}
+
+
+
+
+ {person.cards.length === 0 ? (
+
+
+
No linked cards
+
+ Cards will appear here when linked to this person
+
+
+ ) : (
+
+ {person.cards.map((card) => (
+
router.push(`/cards/${card.id}`)}
+ >
+
+
+ {card.name || "Unnamed Card"}
+
+
+ {formatDate(card.createdAt)}
+
+
+
+
+ {card.ocrStatus}
+
+
+ {card.reviewStatus}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Merge dialog */}
+
+
+
+ Merge Person
+
+ Search for another person to merge{" "}
+
+ {person.firstName} {person.lastName}
+ {" "}
+ into. Cards and data will be moved to the target person.
+
+
+
+
+
+
+ {
+ setMergeSearch(e.target.value);
+ setMergeTarget(null);
+ }}
+ className="pl-9"
+ />
+
+
+ {mergeSearching && (
+
+
+
+ )}
+
+ {!mergeSearching && mergeResults.length > 0 && (
+
+ {mergeResults.map((p) => (
+
setMergeTarget(p)}
+ >
+
+
+ {p.firstName} {p.lastName}
+
+ {p.email && (
+
+ {p.email}
+
+ )}
+
+
+ {p._count.cards} cards
+
+
+ ))}
+
+ )}
+
+ {!mergeSearching &&
+ mergeSearch.trim() &&
+ mergeResults.length === 0 && (
+
+ No matching people found
+
+ )}
+
+
+
+ setMergeOpen(false)}
+ >
+ Cancel
+
+
+ {merging ? (
+ <>
+ Merging...
+ >
+ ) : (
+ <>
+ Merge into{" "}
+ {mergeTarget
+ ? `${mergeTarget.firstName} ${mergeTarget.lastName}`
+ : "..."}
+ >
+ )}
+
+
+
+
+
+ {/* Delete confirmation dialog */}
+
+
+
+ Delete Person
+
+ Are you sure you want to delete{" "}
+
+ {person.firstName} {person.lastName}
+
+ ? Their linked cards will be unlinked but not deleted.
+
+
+
+ setDeleteOpen(false)}
+ >
+ Cancel
+
+
+ {deleting ? (
+ <>
+ Deleting...
+ >
+ ) : (
+ <>
+ Delete
+ >
+ )}
+
+
+
+
+
+ );
+}
+
+function InfoRow({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | null | undefined;
+}) {
+ return (
+
+
{label}
+
{value || "—"}
+
+ );
+}
diff --git a/src/app/(dashboard)/people/page.tsx b/src/app/(dashboard)/people/page.tsx
index f1ebee6..9b74297 100644
--- a/src/app/(dashboard)/people/page.tsx
+++ b/src/app/(dashboard)/people/page.tsx
@@ -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 { 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() {
+ const router = useRouter();
+
+ const [data, setData] = React.useState(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(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 (
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ const people = data?.people ?? [];
+ const isEmpty = !loading && people.length === 0 && !debouncedSearch;
+ const noResults = !loading && people.length === 0 && !!debouncedSearch;
+
return (
-
-
-
People directory
-
- A deduplicated contact list aggregated from your response cards will
- appear here once the people module is built out.
-
-
+ >
+
+ {linking ? (
+ <>
+ Linking...
+ >
+ ) : (
+ <>
+ Link Unlinked Cards
+ >
+ )}
+
+
+
+ {isEmpty ? (
+
+
+
No people yet
+
+ People are automatically created when response cards are processed.
+
+
+ ) : (
+ <>
+
+
+ setSearch(e.target.value)}
+ className="pl-9"
+ />
+
+
+
+
+
+
+
+
+ {data?.total.toLocaleString() ?? "—"}
+
+
Total People
+
+
+
+
+
+
+ {newThisMonth}
+
+
New This Month
+
+
+
+ {noResults ? (
+
+
+
No results
+
+ No people match “{debouncedSearch}”
+
+
+ ) : (
+
+
+
+
+ Name
+
+ Email
+
+
+ Phone
+
+ Cards
+
+ Last Seen
+
+
+
+
+
+ {loading
+ ? Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))
+ : people.map((person) => {
+ const lastSeen =
+ person.cards?.[0]?.createdAt ?? person.updatedAt;
+ return (
+
+
+
+ router.push(`/people/${person.id}`)
+ }
+ >
+ {person.firstName} {person.lastName}
+
+
+
+ {person.email || "—"}
+
+
+ {person.cellPhone || "—"}
+
+
+
+ {person._count.cards}
+
+
+
+ {formatDate(lastSeen)}
+
+
+
+
+ }
+ >
+
+
+
+
+ router.push(`/people/${person.id}`)
+ }
+ >
+ View
+
+
+ router.push(
+ `/people/${person.id}?action=merge`
+ )
+ }
+ >
+ Merge
+
+
+ handleDelete(person.id)}
+ >
+ Delete
+
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {totalPages > 1 && (
+
+
+ Page {page} of {totalPages}
+
+
+ setPage((p) => p - 1)}
+ >
+ Previous
+
+ = totalPages}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ Next
+
+
+
+ )}
+ >
+ )}
);
}
diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx
index afeff6a..763d073 100644
--- a/src/app/(dashboard)/profile/page.tsx
+++ b/src/app/(dashboard)/profile/page.tsx
@@ -59,17 +59,26 @@ export default function ProfilePage() {
setDirty(true);
};
- const handleSave = () => {
- updateProfile({
- jobTitle: form.jobTitle,
- company: form.company,
- bio: form.bio,
- displayName: form.displayName,
- email: form.email,
- avatarUrl: form.avatarUrl,
- });
- setDirty(false);
- toast.success("Profile updated");
+ const [saving, setSaving] = React.useState(false);
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ await updateProfile({
+ jobTitle: form.jobTitle,
+ company: form.company,
+ bio: form.bio,
+ displayName: form.displayName,
+ email: form.email,
+ avatarUrl: form.avatarUrl,
+ });
+ setDirty(false);
+ toast.success("Profile updated");
+ } catch {
+ toast.error("Failed to save profile");
+ } finally {
+ setSaving(false);
+ }
};
const handleAvatarUpload = (e: React.ChangeEvent) => {
@@ -108,9 +117,9 @@ export default function ProfilePage() {
return (
-
-
- Save Changes
+
+ {saving ? : }
+ {saving ? "Saving…" : "Save Changes"}
diff --git a/src/app/(dashboard)/reports/page.tsx b/src/app/(dashboard)/reports/page.tsx
index b91ec4d..052982e 100644
--- a/src/app/(dashboard)/reports/page.tsx
+++ b/src/app/(dashboard)/reports/page.tsx
@@ -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 { 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() {
+ const [period, setPeriod] = useState
(30);
+ const [data, setData] = useState(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 (
-
+
-
-
-
Reports & Analytics
-
- Detailed reports, trends over time, and data exports will be available
- here in a future update.
-
+
+ {/* Period selector + export */}
+
+
+ {PERIOD_OPTIONS.map((opt) => (
+ 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}
+
+ ))}
+
+
+
+ Export CSV
+
+
+
+ {/* Summary stat cards */}
+
+ {loading ? (
+ Array.from({ length: 4 }).map((_, i) => (
+
+ ))
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ {/* Charts grid */}
+
+ {/* Cards Over Time */}
+
+
Cards Over Time
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ new Date(v + "T00:00:00").toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ })
+ }
+ />
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Processing Pipeline */}
+
+
Processing Pipeline
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Team Activity */}
+
+
Team Activity
+ {loading ? (
+
+ ) : data?.teamActivity.length === 0 ? (
+
+ No team activity yet
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
);
}
+
+function StatCard({
+ label,
+ value,
+ icon: Icon,
+ sub,
+}: {
+ label: string;
+ value: string | number;
+ icon: React.ComponentType<{ className?: string }>;
+ sub: string;
+}) {
+ return (
+
+
+ {label}
+
+
+
+ {typeof value === "number" ? value.toLocaleString() : value}
+
+
{sub}
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/forms/[id]/page.tsx b/src/app/(dashboard)/settings/forms/[id]/page.tsx
new file mode 100644
index 0000000..0e459ad
--- /dev/null
+++ b/src/app/(dashboard)/settings/forms/[id]/page.tsx
@@ -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
(null);
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState(null);
+ const [selectedFieldId, setSelectedFieldId] = React.useState(
+ 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 = {};
+ 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) => {
+ if (!selectedFieldId || !template) return;
+ setTemplate({
+ ...template,
+ fields: template.fields.map((f) =>
+ f.id === selectedFieldId ? { ...f, ...updates } : f
+ ),
+ });
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !template) {
+ return (
+
+
}
+ >
+
+ Back to templates
+
+
+ {error ?? "Template not found."}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Template Settings */}
+
+
+ Template Settings
+
+ Update the template name, description, and status.
+
+
+
+
+
+
+
Active
+
+ Inactive templates won't appear when creating new cards.
+
+
+
+ setTemplate({ ...template, isActive: !!checked })
+ }
+ />
+
+
+
+ {savingTemplate ? (
+
+ ) : (
+
+ )}
+ Save Settings
+
+
+
+
+
+
+
+ {/* Field Editor Layout */}
+
+ {/* Left Panel - Field List */}
+
+
+
Fields
+
setShowAddForm((v) => !v)}
+ >
+ {showAddForm ? (
+ <>
+
+ Cancel
+ >
+ ) : (
+ <>
+
+ Add Field
+ >
+ )}
+
+
+
+ {showAddForm && (
+
+
+
+
+
+ Type
+
+ setNewField({ ...newField, type: v as string })
+ }
+ >
+
+
+
+
+ {FIELD_TYPES.map((ft) => (
+
+ {ft.label}
+
+ ))}
+
+
+
+
+ Section
+
+ setNewField({ ...newField, section: v as string })
+ }
+ >
+
+
+
+
+ {SECTIONS.map((s) => (
+
+ {s.label}
+
+ ))}
+
+
+
+
+
+
+ {addingField && (
+
+ )}
+ Add Field
+
+
+
+
+ )}
+
+ {Object.keys(fieldsBySection).length === 0 ? (
+
+ No fields yet. Add your first field to get started.
+
+ ) : (
+
+ {SECTIONS.map((section) => {
+ const fields = fieldsBySection[section.value];
+ if (!fields || fields.length === 0) return null;
+ return (
+
+
+ {section.label}
+
+
+ {fields.map((field, idx) => (
+
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"
+ }`}
+ >
+
+
+ {field.label}
+
+
+ {field.isRequired && (
+
+ )}
+ {field.isCore && (
+
+ )}
+
+ {field.type}
+
+
+
+ {
+ e.stopPropagation();
+ handleReorder(field, "up");
+ }}
+ disabled={idx === 0}
+ className="size-5"
+ >
+
+
+ {
+ e.stopPropagation();
+ handleReorder(field, "down");
+ }}
+ disabled={idx === fields.length - 1}
+ className="size-5"
+ >
+
+
+
+
+ ))}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Right Panel - Field Editor */}
+
+ {selectedField ? (
+
handleDeleteField(selectedField)}
+ saving={savingField}
+ />
+ ) : (
+
+
+
+ Select a field to edit its properties
+
+
+ )}
+
+
+
+ );
+}
+
+function FieldEditor({
+ field,
+ isCore,
+ onChange,
+ onSave,
+ onDelete,
+ saving,
+}: {
+ field: TemplateField;
+ isCore: boolean;
+ onChange: (updates: Partial) => 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 (
+
+
+
+ Edit Field
+ {!isCore && (
+
+
+ Delete
+
+ )}
+
+
+ {isCore
+ ? "Core fields cannot be deleted or have their key changed."
+ : "Configure this field's properties."}
+
+
+
+
+ Label
+ onChange({ label: e.target.value })}
+ />
+
+
+
+
Key
+
!isCore && onChange({ key: e.target.value })} />
+ {isCore && (
+
+ Core field keys cannot be changed.
+
+ )}
+
+
+
+
+ Type
+ onChange({ type: v as string })}
+ >
+
+
+
+
+ {FIELD_TYPES.map((ft) => (
+
+ {ft.label}
+
+ ))}
+
+
+
+
+ Section
+ onChange({ section: v as string })}
+ >
+
+
+
+
+ {SECTIONS.map((s) => (
+
+ {s.label}
+
+ ))}
+
+
+
+
+
+
+ Placeholder
+
+ onChange({ placeholder: e.target.value || null })
+ }
+ placeholder="Optional placeholder text..."
+ />
+
+
+
+ Help Text
+
+
+ {HAS_OPTIONS.has(field.type) && (
+
+
Options
+ {field.options && field.options.length > 0 && (
+
+ {field.options.map((opt) => (
+
+ {opt}
+ removeOption(opt)}
+ className="rounded-full p-0.5 hover:bg-foreground/10"
+ >
+
+
+
+ ))}
+
+ )}
+
+ setOptionInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ addOption();
+ }
+ }}
+ />
+
+ Add
+
+
+
+ )}
+
+
+
+
+
+
+
Required
+
+ Field must be filled when submitting
+
+
+
+ onChange({ isRequired: !!checked })
+ }
+ />
+
+
+
+
Visible on Card
+
+ Show this field on the connect card
+
+
+
+ onChange({ visibleOnCard: !!checked })
+ }
+ />
+
+
+
+
Visible on Survey
+
+ Show this field on the digital survey
+
+
+
+ onChange({ visibleOnSurvey: !!checked })
+ }
+ />
+
+
+
+
+
+ {saving ? (
+
+ ) : (
+
+ )}
+ Save Field
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/forms/page.tsx b/src/app/(dashboard)/settings/forms/page.tsx
new file mode 100644
index 0000000..fc93402
--- /dev/null
+++ b/src/app/(dashboard)/settings/forms/page.tsx
@@ -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([]);
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState(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(null);
+ const [deletingId, setDeletingId] = React.useState(null);
+ const [duplicatingId, setDuplicatingId] = React.useState(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 (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Create Template
+
+ }
+ />
+
+
+ Create Template
+
+ Create a new form template with custom fields.
+
+
+
+
+ Name
+ setNewName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleCreate();
+ }
+ }}
+ />
+
+
+ Description
+
+
+
+
+ {creating && }
+ Create
+
+
+
+
+
+
+ {templates.length === 0 ? (
+
+
+
+
+
+
No templates yet
+
+ Create your first form template to get started.
+
+
+
setDialogOpen(true)}
+ >
+
+ Create Template
+
+
+ ) : (
+
+ {templates.map((template) => (
+
+
+
+
+
+ {template.name}
+ {template.isDefault && (
+
+ Default
+
+ )}
+
+ {template.description && (
+
+ {template.description}
+
+ )}
+
+
+ handleToggleActive(template)}
+ disabled={togglingId === template.id}
+ />
+
+
+
+
+
+
+
+ {template._count?.fields ?? 0} fields
+
+
+
+ {template._count?.cards ?? 0} cards
+
+
+
+
}
+ >
+
+ Edit
+
+
handleDuplicate(template)}
+ disabled={duplicatingId === template.id}
+ >
+ {duplicatingId === template.id ? (
+
+ ) : (
+
+ )}
+ Duplicate
+
+
handleDelete(template)}
+ disabled={
+ template.isDefault || deletingId === template.id
+ }
+ >
+ {deletingId === template.id ? (
+
+ ) : (
+
+ )}
+ Delete
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/general/page.tsx b/src/app/(dashboard)/settings/general/page.tsx
index 99960fa..e3fb29b 100644
--- a/src/app/(dashboard)/settings/general/page.tsx
+++ b/src/app/(dashboard)/settings/general/page.tsx
@@ -62,6 +62,7 @@ export default function GeneralSettingsPage() {
imagesEligible: number;
} | null>(null);
const [cleaning, setCleaning] = React.useState(false);
+ const [fetchError, setFetchError] = React.useState(false);
const [notifPrefs, setNotifPrefs] = React.useState(
DEFAULT_NOTIFICATION_PREFS
);
@@ -78,14 +79,20 @@ export default function GeneralSettingsPage() {
setImageRetention(data.imageRetentionDays || 180);
setLoading(false);
})
- .catch(() => setLoading(false));
+ .catch(() => {
+ setFetchError(true);
+ toast.error("Failed to load settings");
+ setLoading(false);
+ });
fetch("/api/cleanup")
.then((r) => r.json())
.then((data) => {
if (data.sourcesEligible !== undefined) setCleanupStatus(data);
})
- .catch(() => {});
+ .catch(() => {
+ toast.error("Failed to load cleanup status");
+ });
}, []);
const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => {
@@ -265,8 +272,11 @@ export default function GeneralSettingsPage() {
-
-
+
+ {fetchError && (
+
Settings failed to load — saving is disabled
+ )}
+
{saving ? (
) : (
diff --git a/src/app/(dashboard)/settings/layout.tsx b/src/app/(dashboard)/settings/layout.tsx
index 2189821..ebded27 100644
--- a/src/app/(dashboard)/settings/layout.tsx
+++ b/src/app/(dashboard)/settings/layout.tsx
@@ -10,6 +10,7 @@ import {
Users,
MapPin,
Building2,
+ FileText,
} from "lucide-react";
import { Header } from "@/components/layout/header";
import { cn } from "@/lib/utils";
@@ -22,6 +23,7 @@ const SECTIONS = [
{ href: "/settings/users", label: "Team Members", icon: Users },
{ href: "/settings/locations", label: "Locations", icon: MapPin },
{ href: "/settings/organization", label: "Organization", icon: Building2 },
+ { href: "/settings/forms", label: "Form Templates", icon: FileText },
];
export default function SettingsLayout({
diff --git a/src/app/api/ai-test/route.ts b/src/app/api/ai-test/route.ts
index f4c7f35..2da6dd2 100644
--- a/src/app/api/ai-test/route.ts
+++ b/src/app/api/ai-test/route.ts
@@ -3,6 +3,8 @@ import { generateText } from "ai";
import { gateway } from "@ai-sdk/gateway";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
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 {
if (provider === "ollama") {
@@ -16,10 +18,13 @@ function getTestModel(provider: string, modelId: string, ollamaUrl: string): Lan
export async function POST(request: NextRequest) {
try {
+ await requireApiAuth();
const body = await request.json().catch(() => ({}));
const provider = body.provider || "gateway";
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);
@@ -30,9 +35,7 @@ export async function POST(request: NextRequest) {
});
return NextResponse.json({ ok: true, response: text.trim() });
- } catch (err) {
- const message = err instanceof Error ? err.message : "Unknown error";
- console.error("[ai-test]", message);
- return NextResponse.json({ error: message }, { status: 500 });
+ } catch (error) {
+ return handleApiError(error);
}
}
diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts
new file mode 100644
index 0000000..6cf737a
--- /dev/null
+++ b/src/app/api/auth/forgot-password/route.ts
@@ -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 `
+
+
+Reset your password
+
+Reset your Echo password͏
+
+
+
+
+
+ Reset your password
+
+
We received a request to reset the password for your Echo account. Click the button below to choose a new password.
+
If you didn't request this, you can safely ignore this email.
+
+
+ This link expires in 1 hour. If you didn't request a password reset, no action is needed.
+
+
+
+
+ AI-powered response card scanning for churches
+ echoocr.com
+
+
+
+`;
+}
+
+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 });
+ }
+}
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index c1e25b2..82683bd 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -1,4 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/auth";
+import { prisma } from "@/lib/db";
import { getOrCreateUser, type AppUser } from "@/lib/auth";
export type AuthentikUser = {
@@ -74,3 +76,22 @@ export async function GET(req: NextRequest) {
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 = {};
+ 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 });
+}
diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
index faf9f6f..764e861 100644
--- a/src/app/api/auth/register/route.ts
+++ b/src/app/api/auth/register/route.ts
@@ -53,6 +53,12 @@ export async function POST(req: NextRequest) {
{ status: 400 }
);
}
+ if (invitation.email.toLowerCase() !== email.toLowerCase()) {
+ return NextResponse.json(
+ { error: "Email does not match invitation" },
+ { status: 400 }
+ );
+ }
inviteRole = invitation.role;
inviteOrgId = invitation.organizationId;
diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts
new file mode 100644
index 0000000..a0813e3
--- /dev/null
+++ b/src/app/api/auth/reset-password/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/cards/[id]/activity/route.ts b/src/app/api/cards/[id]/activity/route.ts
index 99dc938..86112e0 100644
--- a/src/app/api/cards/[id]/activity/route.ts
+++ b/src/app/api/cards/[id]/activity/route.ts
@@ -1,19 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "@/lib/db";
import { getCardActivity } from "@/lib/activity-log";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
+ const session = await requireApiAuthWithOrg();
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);
return NextResponse.json(activity);
} catch (error) {
- console.error("[cards/[id]/activity GET]", error);
- return NextResponse.json(
- { error: "Failed to fetch activity" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/cards/[id]/export/route.ts b/src/app/api/cards/[id]/export/route.ts
index c314cc7..c3af169 100644
--- a/src/app/api/cards/[id]/export/route.ts
+++ b/src/app/api/cards/[id]/export/route.ts
@@ -1,18 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { fireIntegrationEvent } from "@/lib/integrations";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
+ const session = await requireApiAuthWithOrg();
const { id } = await params;
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 });
}
@@ -27,10 +29,6 @@ export async function POST(
return NextResponse.json(updated);
} catch (error) {
- console.error("[cards/[id]/export POST]", error);
- return NextResponse.json(
- { error: "Failed to mark card as exported" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/cards/[id]/reprocess/route.ts b/src/app/api/cards/[id]/reprocess/route.ts
index cd96056..c66738a 100644
--- a/src/app/api/cards/[id]/reprocess/route.ts
+++ b/src/app/api/cards/[id]/reprocess/route.ts
@@ -1,16 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { reprocessCard } from "@/lib/ocr";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
+ const session = await requireApiAuthWithOrg();
const { id } = await params;
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 });
}
@@ -27,10 +29,6 @@ export async function POST(
return NextResponse.json({ success: true });
} catch (error) {
- console.error("[cards/[id]/reprocess POST]", error);
- return NextResponse.json(
- { error: "Failed to start reprocessing" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/cards/[id]/route.ts b/src/app/api/cards/[id]/route.ts
index 3fd724d..578a906 100644
--- a/src/app/api/cards/[id]/route.ts
+++ b/src/app/api/cards/[id]/route.ts
@@ -3,19 +3,26 @@ import { prisma } from "@/lib/db";
import { deleteObject } from "@/lib/storage";
import { fireIntegrationEvent } from "@/lib/integrations";
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(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
+ const session = await requireApiAuthWithOrg();
const { id } = await params;
const card = await prisma.responseCard.findUnique({
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 });
}
@@ -32,11 +39,7 @@ export async function GET(
backImageUrl,
});
} catch (error) {
- console.error("[cards/[id] GET]", error);
- return NextResponse.json(
- { error: "Failed to fetch card" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
@@ -45,24 +48,23 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }
) {
try {
- const user = await getOrCreateUser(request.headers);
+ const session = await requireApiAuthWithOrg();
+ const user = session.user;
const { id } = await params;
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 });
}
- if (user) {
- if (user.role === "viewer") {
- return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
- }
- if (user.role === "reviewer" && card.assignedToId !== user.id) {
- return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
- }
+ if (user.role === "viewer") {
+ return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
+ }
+ if (user.role === "reviewer" && card.assignedToId !== user.id) {
+ return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
@@ -70,6 +72,8 @@ export async function PUT(
const stringFields = [
"name",
+ "firstName",
+ "lastName",
"gender",
"dateOfBirth",
"maritalStatus",
@@ -118,6 +122,7 @@ export async function PUT(
if (body.campusPreference != null) data.campusPreference = body.campusPreference;
if (body.howHeard != null) data.howHeard = body.howHeard;
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) {
if (body[assignField] !== undefined) {
@@ -129,7 +134,7 @@ export async function PUT(
data.reviewStatus = "in_review";
}
- if (body.reviewStatus === "reviewed" && user) {
+ if (body.reviewStatus === "reviewed") {
data.reviewedById = user.id;
data.reviewedAt = new Date();
}
@@ -150,7 +155,7 @@ export async function PUT(
"user",
`${changes.length} field(s) updated manually`,
changes,
- user?.id
+ user.id
).catch(() => {});
}
@@ -169,11 +174,7 @@ export async function PUT(
if (error instanceof RoleError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
- console.error("[cards/[id] PUT]", error);
- return NextResponse.json(
- { error: "Failed to update card" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
@@ -182,8 +183,8 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
- const user = await getOrCreateUser(request.headers);
- if (user && user.role !== "admin" && user.role !== "owner") {
+ const session = await requireApiAuthWithOrg();
+ if (session.user.role !== "admin" && session.user.role !== "owner") {
return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 });
}
@@ -192,7 +193,7 @@ export async function DELETE(
where: { id },
});
- if (!card) {
+ if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
@@ -209,10 +210,6 @@ export async function DELETE(
return NextResponse.json({ success: true });
} catch (error) {
- console.error("[cards/[id] DELETE]", error);
- return NextResponse.json(
- { error: "Failed to delete card" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/cards/assign/route.ts b/src/app/api/cards/assign/route.ts
index 8590672..54641fd 100644
--- a/src/app/api/cards/assign/route.ts
+++ b/src/app/api/cards/assign/route.ts
@@ -1,13 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
-import { getOrCreateUser, requireRole, RoleError } from "@/lib/auth";
import { createNotification } from "@/lib/notifications";
import { logActivity } from "@/lib/activity-log";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(req: NextRequest) {
try {
- const user = await getOrCreateUser(req.headers);
- requireRole(user, "admin");
+ const session = await requireApiAuthWithOrg();
+ 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 { cardIds, assignToUserId } = body as {
@@ -30,10 +34,10 @@ export async function POST(req: NextRequest) {
}
await prisma.responseCard.updateMany({
- where: { id: { in: cardIds } },
+ where: { id: { in: cardIds }, organizationId: session.user.orgId },
data: {
assignedToId: assignToUserId,
- assignedById: user!.id,
+ assignedById: user.id,
assignedAt: new Date(),
reviewStatus: "assigned",
},
@@ -44,16 +48,16 @@ export async function POST(req: NextRequest) {
cardId,
"assignment",
"user",
- `Assigned to ${assignee.displayName} by ${user!.displayName}`,
+ `Assigned to ${assignee.displayName} by ${user.displayName}`,
[{ field: "assignedToId", from: null, to: assignToUserId }],
- user!.id
+ user.id
).catch(() => {});
}
await createNotification({
type: "card_assigned",
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",
userId: assignToUserId,
});
@@ -64,10 +68,6 @@ export async function POST(req: NextRequest) {
assignee: assignee.displayName,
});
} catch (error) {
- if (error instanceof RoleError) {
- return NextResponse.json({ error: error.message }, { status: 403 });
- }
- console.error("[cards/assign POST]", error);
- return NextResponse.json({ error: "Assignment failed" }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/cards/recover-survey/route.ts b/src/app/api/cards/recover-survey/route.ts
index 396946f..a2363e5 100644
--- a/src/app/api/cards/recover-survey/route.ts
+++ b/src/app/api/cards/recover-survey/route.ts
@@ -3,20 +3,27 @@ import { prisma } from "@/lib/db";
import { getBuffer, uploadBuffer } from "@/lib/storage";
import { pdfToImages, imageToBase64 } from "@/lib/pdf";
import { ocrImage } from "@/lib/ai-ocr";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
const DELAY_BETWEEN_OCR_MS = 3_000;
export async function GET() {
- const missing = await prisma.responseCard.count({
- where: { frontImagePath: null, backImagePath: { not: null } },
- });
- return NextResponse.json({ missingCount: missing });
+ try {
+ const session = await requireApiAuthWithOrg();
+ const missing = await prisma.responseCard.count({
+ where: { frontImagePath: null, backImagePath: { not: null }, organizationId: session.user.orgId },
+ });
+ return NextResponse.json({ missingCount: missing });
+ } catch (error) {
+ return handleApiError(error);
+ }
}
export async function POST() {
try {
+ const session = await requireApiAuthWithOrg();
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 },
});
@@ -134,8 +141,7 @@ export async function POST() {
message: `Recovery started for ${cards.length} card(s) from ${sourceFiles.length} PDF(s)`,
});
} catch (error) {
- console.error("[recover-survey POST]", error);
- return NextResponse.json({ error: "Recovery failed" }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/cards/reprocess-batch/route.ts b/src/app/api/cards/reprocess-batch/route.ts
index 12b0865..d131205 100644
--- a/src/app/api/cards/reprocess-batch/route.ts
+++ b/src/app/api/cards/reprocess-batch/route.ts
@@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { reprocessCard } from "@/lib/ocr";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
const DELAY_BETWEEN_CARDS_MS = 3_000;
export async function POST(request: NextRequest) {
try {
+ const session = await requireApiAuthWithOrg();
const body = await request.json().catch(() => ({}));
const ids = body.ids as string[] | undefined;
@@ -14,7 +16,7 @@ export async function POST(request: NextRequest) {
}
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 },
});
@@ -30,7 +32,7 @@ export async function POST(request: NextRequest) {
}
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 },
});
@@ -56,7 +58,6 @@ export async function POST(request: NextRequest) {
skipped: ids.length - eligible.length,
});
} catch (error) {
- console.error("[reprocess-batch POST]", error);
- return NextResponse.json({ error: "Failed to start batch reprocessing" }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/cards/route.ts b/src/app/api/cards/route.ts
index b19b276..bbcf8e8 100644
--- a/src/app/api/cards/route.ts
+++ b/src/app/api/cards/route.ts
@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
-import { getOrCreateUser } from "@/lib/auth";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) {
try {
+ const session = await requireApiAuthWithOrg();
+
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
@@ -17,8 +19,6 @@ export async function GET(request: NextRequest) {
const sortBy = searchParams.get("sortBy") ?? "createdAt";
const sortOrder = searchParams.get("sortOrder") ?? "desc";
- const user = await getOrCreateUser(request.headers);
-
const validSortFields = [
"createdAt",
"updatedAt",
@@ -33,7 +33,9 @@ export async function GET(request: NextRequest) {
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
const order = sortOrder === "asc" ? "asc" : "desc";
- const where: Record = {};
+ const where: Record = {
+ organizationId: session.user.orgId,
+ };
if (ocrStatus) where.ocrStatus = ocrStatus;
if (reviewStatus) where.reviewStatus = reviewStatus;
@@ -41,8 +43,8 @@ export async function GET(request: NextRequest) {
if (visitType) where.visitType = visitType;
if (serviceAttended) where.serviceAttended = serviceAttended;
- if (assignedToId === "me" && user) {
- where.assignedToId = user.id;
+ if (assignedToId === "me") {
+ where.assignedToId = session.user.id;
} else if (assignedToId === "unassigned") {
where.assignedToId = null;
} else if (assignedToId) {
@@ -74,18 +76,18 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ cards, total, page, limit });
} catch (error) {
- console.error("[cards GET]", error);
- return NextResponse.json(
- { error: "Failed to fetch cards" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
export async function POST(request: NextRequest) {
try {
+ const session = await requireApiAuthWithOrg();
+
const body = await request.json().catch(() => ({}));
- const data: Record = {};
+ const data: Record = {
+ organizationId: session.user.orgId,
+ };
const stringFields = [
"name",
@@ -134,10 +136,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json(card, { status: 201 });
} catch (error) {
- console.error("[cards POST]", error);
- return NextResponse.json(
- { error: "Failed to create card" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/cleanup/route.ts b/src/app/api/cleanup/route.ts
index 7ae7b59..d856a2f 100644
--- a/src/app/api/cleanup/route.ts
+++ b/src/app/api/cleanup/route.ts
@@ -1,9 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { deleteObject, listObjects } from "@/lib/storage";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
+ const session = await requireApiAuthWithOrg();
+ const orgId = session.user.orgId;
const body = await request.json().catch(() => ({}));
const dryRun = body.dryRun === true;
@@ -31,7 +34,7 @@ export async function POST(request: NextRequest) {
// 1. Purge source PDFs older than sourceRetentionDays
const sourceKeys = await listObjects("sources/");
const jobsWithSources = await prisma.processingJob.findMany({
- where: { createdAt: { lt: sourcesCutoff }, status: "complete" },
+ where: { createdAt: { lt: sourcesCutoff }, status: "complete", organizationId: orgId },
select: { id: true },
});
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
const oldCards = await prisma.responseCard.findMany({
- where: { createdAt: { lt: imagesCutoff } },
+ where: { createdAt: { lt: imagesCutoff }, organizationId: orgId },
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
if (!dryRun) {
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;
} else {
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);
} catch (error) {
- console.error("[cleanup POST]", error);
- return NextResponse.json(
- { error: "Cleanup failed" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
export async function GET() {
try {
+ const session = await requireApiAuthWithOrg();
+ const orgId = session.user.orgId;
let settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
@@ -106,11 +107,12 @@ export async function GET() {
const imagesCutoff = new Date(now.getTime() - settings.imageRetentionDays * 86400000);
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({
where: {
createdAt: { lt: imagesCutoff },
+ organizationId: orgId,
OR: [
{ frontImagePath: { not: null } },
{ backImagePath: { not: null } },
@@ -125,10 +127,6 @@ export async function GET() {
imagesEligible,
});
} catch (error) {
- console.error("[cleanup GET]", error);
- return NextResponse.json(
- { error: "Failed to check cleanup status" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/collection-days/[id]/route.ts b/src/app/api/collection-days/[id]/route.ts
new file mode 100644
index 0000000..170045a
--- /dev/null
+++ b/src/app/api/collection-days/[id]/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/email-watch/poll/route.ts b/src/app/api/email-watch/poll/route.ts
index 8fd2423..6de24d9 100644
--- a/src/app/api/email-watch/poll/route.ts
+++ b/src/app/api/email-watch/poll/route.ts
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { scanInbox } from "@/lib/email-watcher";
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");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
diff --git a/src/app/api/email-watch/route.ts b/src/app/api/email-watch/route.ts
index a81580a..c56fd85 100644
--- a/src/app/api/email-watch/route.ts
+++ b/src/app/api/email-watch/route.ts
@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { scanInbox } from "@/lib/email-watcher";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
+ await requireApiAuth();
const body = await request.json().catch(() => ({}));
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" } });
return NextResponse.json({ emailWatching: updated?.emailWatching ?? false });
} catch (error) {
- console.error("[email-watch POST]", error);
- const message =
- error instanceof Error ? error.message : "Failed to update email watch";
- return NextResponse.json({ error: message }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/email-watch/test/route.ts b/src/app/api/email-watch/test/route.ts
index 1ef02d2..8ce0ad5 100644
--- a/src/app/api/email-watch/test/route.ts
+++ b/src/app/api/email-watch/test/route.ts
@@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { testEmailConnection } from "@/lib/email-watcher";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
+ await requireApiAuth();
const body = await request.json().catch(() => ({}));
const host = String(body.host || "");
@@ -33,9 +35,6 @@ export async function POST(request: NextRequest) {
);
}
} catch (error) {
- console.error("[email-watch/test POST]", error);
- const message =
- error instanceof Error ? error.message : "Connection test failed";
- return NextResponse.json({ ok: false, error: message }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/form-templates/[id]/fields/[fieldId]/route.ts b/src/app/api/form-templates/[id]/fields/[fieldId]/route.ts
new file mode 100644
index 0000000..2900bdb
--- /dev/null
+++ b/src/app/api/form-templates/[id]/fields/[fieldId]/route.ts
@@ -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 = {};
+ 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);
+ }
+}
diff --git a/src/app/api/form-templates/[id]/fields/route.ts b/src/app/api/form-templates/[id]/fields/route.ts
new file mode 100644
index 0000000..d255caf
--- /dev/null
+++ b/src/app/api/form-templates/[id]/fields/route.ts
@@ -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);
+ }
+}
diff --git a/src/app/api/form-templates/[id]/qr/route.ts b/src/app/api/form-templates/[id]/qr/route.ts
new file mode 100644
index 0000000..2c2be0e
--- /dev/null
+++ b/src/app/api/form-templates/[id]/qr/route.ts
@@ -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);
+ }
+}
diff --git a/src/app/api/form-templates/[id]/route.ts b/src/app/api/form-templates/[id]/route.ts
new file mode 100644
index 0000000..b0e9f8a
--- /dev/null
+++ b/src/app/api/form-templates/[id]/route.ts
@@ -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;
+ settings?: Record;
+ };
+
+ const data: Record = {};
+ 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);
+ }
+}
diff --git a/src/app/api/form-templates/route.ts b/src/app/api/form-templates/route.ts
new file mode 100644
index 0000000..c99fc89
--- /dev/null
+++ b/src/app/api/form-templates/route.ts
@@ -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);
+ }
+}
diff --git a/src/app/api/ftp-watch/poll/route.ts b/src/app/api/ftp-watch/poll/route.ts
index c7a0c94..fc27b12 100644
--- a/src/app/api/ftp-watch/poll/route.ts
+++ b/src/app/api/ftp-watch/poll/route.ts
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { pollFtp } from "@/lib/ftp-watcher";
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");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
diff --git a/src/app/api/ftp-watch/route.ts b/src/app/api/ftp-watch/route.ts
index 8da864d..68b8c83 100644
--- a/src/app/api/ftp-watch/route.ts
+++ b/src/app/api/ftp-watch/route.ts
@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { pollFtp } from "@/lib/ftp-watcher";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
+ await requireApiAuth();
const body = await request.json().catch(() => ({}));
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" } });
return NextResponse.json({ ftpEnabled: updated?.ftpEnabled ?? false });
} catch (error) {
- console.error("[ftp-watch POST]", error);
- const message =
- error instanceof Error ? error.message : "Failed to update FTP watch";
- return NextResponse.json({ error: message }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/ftp-watch/test/route.ts b/src/app/api/ftp-watch/test/route.ts
index 979c3e2..9376bdd 100644
--- a/src/app/api/ftp-watch/test/route.ts
+++ b/src/app/api/ftp-watch/test/route.ts
@@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { testFtpConnection } from "@/lib/ftp-watcher";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
+ await requireApiAuth();
const body = await request.json();
const result = await testFtpConnection({
host: body.host,
@@ -14,10 +16,6 @@ export async function POST(request: NextRequest) {
});
return NextResponse.json(result);
} catch (error) {
- console.error("[ftp-watch/test POST]", error);
- return NextResponse.json(
- { ok: false, error: "Test failed" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/images/[...path]/route.ts b/src/app/api/images/[...path]/route.ts
index 5bc3802..202408f 100644
--- a/src/app/api/images/[...path]/route.ts
+++ b/src/app/api/images/[...path]/route.ts
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getBuffer } from "@/lib/storage";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
const EXT_TO_CONTENT_TYPE: Record = {
jpg: "image/jpeg",
@@ -15,6 +16,7 @@ export async function GET(
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
+ await requireApiAuth();
const { path: pathSegments } = await params;
const key = pathSegments.join("/");
@@ -34,10 +36,6 @@ export async function GET(
},
});
} catch (error) {
- console.error("[images GET]", error);
- return NextResponse.json(
- { error: "Failed to get image" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/integrations/monday/columns/route.ts b/src/app/api/integrations/monday/columns/route.ts
index 6231f18..c67e6cf 100644
--- a/src/app/api/integrations/monday/columns/route.ts
+++ b/src/app/api/integrations/monday/columns/route.ts
@@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { fetchBoardColumns } from "@/lib/monday";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
+import { prisma } from "@/lib/db";
export async function POST(request: NextRequest) {
try {
+ await requireApiAuth();
const body = await request.json().catch(() => ({}));
- const token = String(body.token || "");
const boardId = String(body.boardId || "");
+ const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
+ const token = settings?.mondayApiToken || "";
+
if (!token || !boardId) {
return NextResponse.json(
{ error: "API token and board ID are required" },
@@ -17,8 +22,6 @@ export async function POST(request: NextRequest) {
const columns = await fetchBoardColumns(token, boardId);
return NextResponse.json({ columns });
} catch (error) {
- console.error("[monday/columns POST]", error);
- const message = error instanceof Error ? error.message : "Failed to fetch columns";
- return NextResponse.json({ error: message }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/integrations/monday/push/[id]/route.ts b/src/app/api/integrations/monday/push/[id]/route.ts
index bade849..f58d387 100644
--- a/src/app/api/integrations/monday/push/[id]/route.ts
+++ b/src/app/api/integrations/monday/push/[id]/route.ts
@@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { pushCardToMonday } from "@/lib/integrations";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
+ await requireApiAuth();
const { id } = await params;
const result = await pushCardToMonday(id);
return NextResponse.json({
@@ -14,8 +16,6 @@ export async function POST(
mondayItemId: result.mondayItemId,
});
} catch (error) {
- console.error("[monday/push POST]", error);
- const message = error instanceof Error ? error.message : "Push failed";
- return NextResponse.json({ error: message }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/integrations/monday/subscribe/route.ts b/src/app/api/integrations/monday/subscribe/route.ts
index 77d41f3..fbfeadc 100644
--- a/src/app/api/integrations/monday/subscribe/route.ts
+++ b/src/app/api/integrations/monday/subscribe/route.ts
@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { createWebhookSubscription, deleteWebhookSubscription } from "@/lib/monday";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(request: NextRequest) {
try {
+ await requireApiAuth();
const body = await request.json().catch(() => ({}));
const action = body.action as string;
@@ -61,8 +63,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
} catch (error) {
- console.error("[monday/subscribe POST]", error);
- const message = error instanceof Error ? error.message : "Failed";
- return NextResponse.json({ error: message }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/integrations/monday/sync-all/route.ts b/src/app/api/integrations/monday/sync-all/route.ts
index 7ab5f6e..b93b76a 100644
--- a/src/app/api/integrations/monday/sync-all/route.ts
+++ b/src/app/api/integrations/monday/sync-all/route.ts
@@ -2,9 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { pushCardToMonday } from "@/lib/integrations";
import { createNotification } from "@/lib/notifications";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST(req: NextRequest) {
try {
+ await requireApiAuth();
const settings = await prisma.appSettings.findUnique({
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) });
} catch (error) {
- console.error("[monday/sync-all POST]", error);
- const message = error instanceof Error ? error.message : "Sync failed";
- return NextResponse.json({ error: message }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/integrations/webhook/test/route.ts b/src/app/api/integrations/webhook/test/route.ts
index 63cf799..52a596b 100644
--- a/src/app/api/integrations/webhook/test/route.ts
+++ b/src/app/api/integrations/webhook/test/route.ts
@@ -1,9 +1,11 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { sendWebhook } from "@/lib/webhook";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function POST() {
try {
+ await requireApiAuth();
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
@@ -40,8 +42,6 @@ export async function POST() {
);
}
} catch (error) {
- console.error("[webhook/test POST]", error);
- const message = error instanceof Error ? error.message : "Test failed";
- return NextResponse.json({ ok: false, error: message }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts
index b947d18..bb241ca 100644
--- a/src/app/api/jobs/route.ts
+++ b/src/app/api/jobs/route.ts
@@ -1,14 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) {
try {
+ const session = await requireApiAuthWithOrg();
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
const status = searchParams.get("status") || undefined;
- const where = status ? { status } : {};
+ const where: Record = { organizationId: session.user.orgId };
+ if (status) where.status = status;
const [jobs, total] = await Promise.all([
prisma.processingJob.findMany({
@@ -22,10 +25,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ jobs, total, page, limit });
} catch (error) {
- console.error("[jobs GET]", error);
- return NextResponse.json(
- { error: "Failed to fetch jobs" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts
index 7da683a..5198c91 100644
--- a/src/app/api/notifications/route.ts
+++ b/src/app/api/notifications/route.ts
@@ -6,50 +6,47 @@ import {
markAllRead,
dismissNotification,
} from "@/lib/notifications";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function GET(request: NextRequest) {
try {
+ const session = await requireApiAuth();
+ const userId = session.user.id!;
const { searchParams } = new URL(request.url);
const unreadOnly = searchParams.get("unreadOnly") === "true";
const limit = Math.min(100, parseInt(searchParams.get("limit") || "50"));
const [notifications, unreadCount] = await Promise.all([
- getNotifications({ unreadOnly, limit }),
- getUnreadCount(),
+ getNotifications({ userId, unreadOnly, limit }),
+ getUnreadCount(userId),
]);
return NextResponse.json({ notifications, unreadCount });
} catch (error) {
- console.error("[notifications GET]", error);
- return NextResponse.json(
- { error: "Failed to fetch notifications" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
export async function PUT(request: NextRequest) {
try {
+ const session = await requireApiAuth();
+ const userId = session.user.id!;
const body = await request.json().catch(() => ({}));
const action = body.action as string;
if (action === "mark_read" && body.id) {
- await markRead(body.id);
+ await markRead(body.id, userId);
} else if (action === "mark_all_read") {
- await markAllRead();
+ await markAllRead(userId);
} else if (action === "dismiss" && body.id) {
- await dismissNotification(body.id);
+ await dismissNotification(body.id, userId);
} else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
- const unreadCount = await getUnreadCount();
+ const unreadCount = await getUnreadCount(userId);
return NextResponse.json({ ok: true, unreadCount });
} catch (error) {
- console.error("[notifications PUT]", error);
- return NextResponse.json(
- { error: "Failed to update notification" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/people/[id]/merge/route.ts b/src/app/api/people/[id]/merge/route.ts
new file mode 100644
index 0000000..9707263
--- /dev/null
+++ b/src/app/api/people/[id]/merge/route.ts
@@ -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 = {
+ 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);
+ }
+}
diff --git a/src/app/api/people/[id]/route.ts b/src/app/api/people/[id]/route.ts
new file mode 100644
index 0000000..a3a4434
--- /dev/null
+++ b/src/app/api/people/[id]/route.ts
@@ -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 = {};
+
+ 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);
+ }
+}
diff --git a/src/app/api/people/link/route.ts b/src/app/api/people/link/route.ts
new file mode 100644
index 0000000..ae01926
--- /dev/null
+++ b/src/app/api/people/link/route.ts
@@ -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);
+ }
+}
diff --git a/src/app/api/people/route.ts b/src/app/api/people/route.ts
new file mode 100644
index 0000000..8332435
--- /dev/null
+++ b/src/app/api/people/route.ts
@@ -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 = {
+ 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);
+ }
+}
diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts
index 18849f5..6faa2c5 100644
--- a/src/app/api/settings/route.ts
+++ b/src/app/api/settings/route.ts
@@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
+import { requireApiAuth, handleApiError } from "@/lib/api-auth";
export async function GET() {
try {
+ await requireApiAuth();
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
@@ -14,18 +16,20 @@ export async function GET() {
return NextResponse.json(created);
}
- return NextResponse.json(settings);
+ const masked = { ...settings } as Record;
+ 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) {
- console.error("[settings GET]", error);
- return NextResponse.json(
- { error: "Failed to fetch settings" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
export async function PUT(request: NextRequest) {
try {
+ await requireApiAuth();
const body = await request.json().catch(() => ({}));
const data: Record = {};
@@ -81,10 +85,6 @@ export async function PUT(request: NextRequest) {
return NextResponse.json(settings);
} catch (error) {
- console.error("[settings PUT]", error);
- return NextResponse.json(
- { error: "Failed to update settings" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts
index 9484418..f855eea 100644
--- a/src/app/api/stats/route.ts
+++ b/src/app/api/stats/route.ts
@@ -1,32 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
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) {
+ recoverStuckJobs().catch(() => {});
+
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 effectiveUserId = userId === "me" && user ? user.id : userId;
+ const effectiveUserId = userId === "me" ? session.user.id : userId;
const [total, byOcrStatus, byReviewStatus, myCards] = await Promise.all([
- prisma.responseCard.count(),
+ prisma.responseCard.count({ where: { organizationId: orgId } }),
prisma.responseCard.groupBy({
by: ["ocrStatus"],
+ where: { organizationId: orgId },
_count: { id: true },
}),
prisma.responseCard.groupBy({
by: ["reviewStatus"],
+ where: { organizationId: orgId },
_count: { id: true },
}),
effectiveUserId
? prisma.responseCard.count({
- where: { assignedToId: effectiveUserId },
+ where: { assignedToId: effectiveUserId, organizationId: orgId },
})
- : user
- ? prisma.responseCard.count({
- where: { assignedToId: user.id },
- })
- : Promise.resolve(0),
+ : prisma.responseCard.count({
+ where: { assignedToId: session.user.id, organizationId: orgId },
+ }),
]);
const ocrStatusCounts = Object.fromEntries(
@@ -43,10 +47,6 @@ export async function GET(request: NextRequest) {
myCards,
});
} catch (error) {
- console.error("[stats GET]", error);
- return NextResponse.json(
- { error: "Failed to fetch stats" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/stats/trends/route.ts b/src/app/api/stats/trends/route.ts
new file mode 100644
index 0000000..cc1c1d6
--- /dev/null
+++ b/src/app/api/stats/trends/route.ts
@@ -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();
+
+ 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();
+
+ 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;
+}
diff --git a/src/app/api/survey/submit/route.ts b/src/app/api/survey/submit/route.ts
new file mode 100644
index 0000000..c1c1fdf
--- /dev/null
+++ b/src/app/api/survey/submit/route.ts
@@ -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;
+ };
+
+ 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 }
+ );
+ }
+}
diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts
index 59a8b1d..8779df5 100644
--- a/src/app/api/upload/route.ts
+++ b/src/app/api/upload/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { uploadBuffer } from "@/lib/storage";
import { enqueueProcessing } from "@/lib/processing-queue";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50 MB
@@ -15,6 +16,7 @@ const ALLOWED_TYPES = [
export async function POST(request: NextRequest) {
try {
+ const session = await requireApiAuthWithOrg();
const formData = await request.formData();
const files = [
...(formData.getAll("files") as File[]),
@@ -56,6 +58,7 @@ export async function POST(request: NextRequest) {
fileName,
filePath: `sources/${fileName}`,
status: "queued",
+ organizationId: session.user.orgId,
},
});
@@ -68,10 +71,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ jobIds });
} catch (error) {
- console.error("[upload POST]", error);
- return NextResponse.json(
- { error: "Failed to upload files" },
- { status: 500 }
- );
+ return handleApiError(error);
}
}
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
index ae78623..8bb2d1c 100644
--- a/src/app/api/users/route.ts
+++ b/src/app/api/users/route.ts
@@ -1,34 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
-import { getOrCreateUser } from "@/lib/auth";
+import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export async function GET(req: NextRequest) {
try {
- const user = await getOrCreateUser(req.headers);
- if (!user) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
+ const session = await requireApiAuthWithOrg();
const role = req.nextUrl.searchParams.get("role") || undefined;
- const where: Record = {};
- if (role) where.role = role;
- const users = await prisma.user.findMany({
+ const where: Record = {
+ organizationId: session.user.orgId,
+ };
+ if (role) where.user = { role };
+
+ const members = await prisma.orgMember.findMany({
where,
- select: {
- id: true,
- username: true,
- displayName: true,
- email: true,
- avatarUrl: true,
- role: true,
+ include: {
+ user: {
+ select: {
+ id: true,
+ username: true,
+ displayName: true,
+ email: true,
+ avatarUrl: true,
+ role: true,
+ },
+ },
},
- orderBy: { displayName: "asc" },
+ orderBy: { user: { displayName: "asc" } },
});
+ const users = members.map((m) => m.user);
+
return NextResponse.json({ users });
} catch (error) {
- console.error("[users GET]", error);
- return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 });
+ return handleApiError(error);
}
}
diff --git a/src/app/s/[orgSlug]/[formSlug]/page.tsx b/src/app/s/[orgSlug]/[formSlug]/page.tsx
new file mode 100644
index 0000000..05dc259
--- /dev/null
+++ b/src/app/s/[orgSlug]/[formSlug]/page.tsx
@@ -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>;
+};
+
+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 = {};
+ for (const [key, val] of Object.entries(query)) {
+ if (typeof val === "string") prefill[key] = val;
+ }
+
+ return (
+
+
+ {branding?.logoUrl && (
+
+ )}
+
+ {template.name}
+
+
{org.name}
+ {template.description && (
+
+ {template.description}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/s/layout.tsx b/src/app/s/layout.tsx
new file mode 100644
index 0000000..7e5ca89
--- /dev/null
+++ b/src/app/s/layout.tsx
@@ -0,0 +1,12 @@
+export default function SurveyLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/cards/dashboard-content.tsx b/src/components/cards/dashboard-content.tsx
index d6f9b8f..6e202bc 100644
--- a/src/components/cards/dashboard-content.tsx
+++ b/src/components/cards/dashboard-content.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import { useRouter, useSearchParams } from "next/navigation";
+import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { toast } from "sonner";
import { StatCards, type StatFilter } from "./stat-cards";
@@ -27,9 +27,18 @@ const ATTENDANCE_OPTIONS = [
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() {
const router = useRouter();
const searchParams = useSearchParams();
+ const pathname = usePathname();
const { role } = useUserProfile();
const isAdmin = role === "admin" || role === "owner";
@@ -45,8 +54,9 @@ export function DashboardContent() {
const [selectedIds, setSelectedIds] = React.useState([]);
const [uploadModalOpen, setUploadModalOpen] = React.useState(false);
const [uploadingRows, setUploadingRows] = React.useState([]);
- const [activeStatFilter, setActiveStatFilter] = React.useState(null);
+ const [activeStatFilter, setActiveStatFilter] = React.useState(() => deriveStatFilter(searchParams));
const [globalDragOver, setGlobalDragOver] = React.useState(false);
+ const [initialFiles, setInitialFiles] = React.useState([]);
const fetchCards = React.useCallback(async () => {
setLoading(true);
@@ -83,6 +93,10 @@ export function DashboardContent() {
fetchCards();
}, [fetchCards]);
+ React.useEffect(() => {
+ setActiveStatFilter(deriveStatFilter(searchParams));
+ }, [searchParams]);
+
const updateUrl = React.useCallback(
(updates: Record) => {
const params = new URLSearchParams(searchParams.toString());
@@ -93,9 +107,9 @@ export function DashboardContent() {
params.set(key, String(value));
}
}
- router.push(`/?${params.toString()}`);
+ router.push(`${pathname}?${params.toString()}`);
},
- [router, searchParams]
+ [router, searchParams, pathname]
);
const handleStatFilterChange = React.useCallback(
@@ -118,9 +132,9 @@ export function DashboardContent() {
}
params.set("page", "1");
- router.push(`/?${params.toString()}`);
+ router.push(`${pathname}?${params.toString()}`);
},
- [router, searchParams]
+ [router, searchParams, pathname]
);
const handleBulkAction = async (
@@ -398,6 +412,7 @@ export function DashboardContent() {
e.preventDefault();
setGlobalDragOver(false);
if (e.dataTransfer?.files.length) {
+ setInitialFiles(Array.from(e.dataTransfer.files));
setUploadModalOpen(true);
}
};
@@ -417,6 +432,12 @@ export function DashboardContent() {
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 (
{/* Global drag overlay */}
@@ -476,6 +497,9 @@ export function DashboardContent() {
onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })}
uploadingRows={uploadingRows.filter((f) => f.status === "uploading")}
onSelectionChange={setSelectedIds}
+ hasActiveFilters={hasActiveFilters}
+ onClearFilters={clearFilters}
+ onUploadClick={isAdmin ? openUpload : undefined}
/>
@@ -495,7 +519,11 @@ export function DashboardContent() {
{/* Upload modal */}
{
+ setUploadModalOpen(open);
+ if (!open) setInitialFiles([]);
+ }}
+ initialFiles={initialFiles}
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadComplete={handleUploadComplete}
diff --git a/src/components/cards/data-table.tsx b/src/components/cards/data-table.tsx
index 885dd37..a1262ad 100644
--- a/src/components/cards/data-table.tsx
+++ b/src/components/cards/data-table.tsx
@@ -18,6 +18,9 @@ import {
ChevronRight,
Loader2,
Copy,
+ Columns3,
+ Upload,
+ SearchX,
} from "lucide-react";
import { toast } from "sonner";
@@ -32,6 +35,8 @@ import {
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
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 type { ResponseCard } from "./columns";
import type { UploadingFile } from "./upload-modal";
@@ -133,6 +138,9 @@ type DataTableProps = {
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
uploadingRows?: UploadingFile[];
onSelectionChange?: (selectedIds: string[]) => void;
+ hasActiveFilters?: boolean;
+ onClearFilters?: () => void;
+ onUploadClick?: () => void;
};
const PAGE_SIZES = [10, 20, 50, 100];
@@ -158,6 +166,9 @@ export function DataTable({
onSortChange,
uploadingRows = [],
onSelectionChange,
+ hasActiveFilters,
+ onClearFilters,
+ onUploadClick,
}: DataTableProps) {
const router = useRouter();
@@ -333,6 +344,54 @@ export function DataTable({
return (
+
+
+
+ }
+ >
+
+ Columns
+
+
+
+ Toggle Columns
+
+
+
+ {COLUMN_GROUPS.map((group) => (
+
+
+ {group.label}
+
+ {group.columns.map((col) => (
+
+
+ toggleColumn(col.id, checked !== false)
+ }
+ />
+ {col.label}
+
+ ))}
+
+ ))}
+
+
+
+
+
+
{/* Desktop table */}
@@ -455,9 +514,31 @@ export function DataTable
({
- No results.
+ {hasActiveFilters ? (
+
+
+
No matching cards
+
Try adjusting your filters or search terms
+ {onClearFilters && (
+
+ Clear filters
+
+ )}
+
+ ) : (
+
+
+
No cards yet
+
Upload your first response card to get started
+ {onUploadClick && (
+
+ Upload
+
+ )}
+
+ )}
)
@@ -557,8 +638,30 @@ export function DataTable({
))
) : (
uploadingRows.length === 0 && (
-
- No results.
+
+ {hasActiveFilters ? (
+ <>
+
+
No matching cards
+
Try adjusting your filters or search terms
+ {onClearFilters && (
+
+ Clear filters
+
+ )}
+ >
+ ) : (
+ <>
+
+
No cards yet
+
Upload your first response card to get started
+ {onUploadClick && (
+
+ Upload
+
+ )}
+ >
+ )}
)
)}
diff --git a/src/components/cards/dynamic-field.tsx b/src/components/cards/dynamic-field.tsx
new file mode 100644
index 0000000..017de40
--- /dev/null
+++ b/src/components/cards/dynamic-field.tsx
@@ -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 (
+
+ 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 (
+