phase 1-6 implemented
This commit is contained in:
1 parent
22c274bb83
commit
2c4927478a
73 files changed
+9702
-2354
No files matched your search
+13
-1
@@ -1,3 +1,15 @@
|
||||
# Clerk Authentication
|
||||
# Get these from https://dashboard.clerk.com
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
|
||||
CLERK_SECRET_KEY=sk_test_your_secret_key_here
|
||||
CLERK_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
||||
|
||||
# DeepSeek AI API Key
|
||||
# Get your API key from https://platform.deepseek.com/
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
DEEPSEEK_API_KEY=sk-your_deepseek_api_key_here
|
||||
|
||||
# Database (optional - defaults to ./fitai.db)
|
||||
DATABASE_PATH=./fitai.db
|
||||
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
@@ -1,7 +1,29 @@
|
||||
# Admin app specific
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Environment variables (all .env files)
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
backups/
|
||||
data/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
server.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Mobile app specific
|
||||
.expo/
|
||||
|
||||
Binary file not shown.
Generated
+227
@@ -32,6 +32,8 @@
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.0.1",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.4.31",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -2381,6 +2383,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -4319,6 +4327,15 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.22",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
|
||||
@@ -5130,6 +5147,12 @@
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -5439,6 +5462,15 @@
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/dateformat": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -6617,6 +6649,12 @@
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
"integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -6665,6 +6703,12 @@
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
@@ -7372,6 +7416,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
@@ -9202,6 +9252,15 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
@@ -10428,6 +10487,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -10680,6 +10748,79 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty": {
|
||||
"version": "13.1.3",
|
||||
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
||||
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colorette": "^2.0.7",
|
||||
"dateformat": "^4.6.3",
|
||||
"fast-copy": "^4.0.0",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"help-me": "^5.0.0",
|
||||
"joycon": "^3.1.1",
|
||||
"minimist": "^1.2.6",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pump": "^3.0.0",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"strip-json-comments": "^5.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"pino-pretty": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty/node_modules/strip-json-comments": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
@@ -10995,6 +11136,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/promise-inflight": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
|
||||
@@ -11095,6 +11252,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
@@ -11294,6 +11457,15 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.0.tgz",
|
||||
@@ -11641,6 +11813,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
@@ -11667,6 +11848,22 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -12003,6 +12200,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -12033,6 +12239,15 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
@@ -12778,6 +12993,18 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.0.1",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.4.31",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
||||
@@ -1,82 +1,79 @@
|
||||
|
||||
import { getDatabase } from '../src/lib/database/index';
|
||||
import { getDatabase } from "../src/lib/database/index";
|
||||
|
||||
async function verifyDatabase() {
|
||||
console.log('Starting database verification...');
|
||||
const db = await getDatabase();
|
||||
await db.connect();
|
||||
console.log("Starting database verification...");
|
||||
const db = await getDatabase();
|
||||
await db.connect();
|
||||
|
||||
try {
|
||||
// 1. Create User
|
||||
console.log('Creating test user...');
|
||||
const userId = `test-user-${Date.now()}`;
|
||||
const user = await db.createUser({
|
||||
id: userId,
|
||||
email: `test-${Date.now()}@example.com`,
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
password: 'password123',
|
||||
role: 'client',
|
||||
phone: '1234567890'
|
||||
});
|
||||
console.log('User created:', user.id);
|
||||
try {
|
||||
// 1. Create User
|
||||
console.log("Creating test user...");
|
||||
const userId = `test-user-${Date.now()}`;
|
||||
const user = await db.createUser({
|
||||
id: userId,
|
||||
email: `test-${Date.now()}@example.com`,
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
password: "password123",
|
||||
role: "client",
|
||||
phone: "1234567890",
|
||||
});
|
||||
console.log("User created:", user.id);
|
||||
|
||||
// 2. Create Client
|
||||
console.log('Creating test client...');
|
||||
const client = await db.createClient({
|
||||
userId: user.id,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
});
|
||||
console.log('Client created:', client.id);
|
||||
// 2. Create Client
|
||||
console.log("Creating test client...");
|
||||
const client = await db.createClient({
|
||||
userId: user.id,
|
||||
membershipType: "basic",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
});
|
||||
console.log("Client created:", client.id);
|
||||
|
||||
// 3. Create Fitness Profile
|
||||
console.log('Creating fitness profile...');
|
||||
const profile = await db.createFitnessProfile({
|
||||
id: 'test-profile-id',
|
||||
userId: user.id,
|
||||
height: '180',
|
||||
weight: '75',
|
||||
age: '30',
|
||||
gender: 'male',
|
||||
activityLevel: 'moderately_active',
|
||||
fitnessGoals: ['weight_loss'],
|
||||
exerciseHabits: 'None',
|
||||
dietHabits: 'None',
|
||||
medicalConditions: 'None'
|
||||
});
|
||||
console.log('Fitness profile created for:', profile.userId);
|
||||
// 3. Create Fitness Profile
|
||||
console.log("Creating fitness profile...");
|
||||
const profile = await db.createFitnessProfile({
|
||||
id: "test-profile-id",
|
||||
userId: user.id,
|
||||
height: 180,
|
||||
weight: 75,
|
||||
age: 30,
|
||||
gender: "male",
|
||||
activityLevel: "moderately_active",
|
||||
fitnessGoals: ["weight_loss"],
|
||||
medicalConditions: "None",
|
||||
});
|
||||
console.log("Fitness profile created for:", profile.userId);
|
||||
|
||||
// 4. Attendance Check-in
|
||||
console.log('Checking in...');
|
||||
const checkIn = await db.checkIn(user.id, 'gym', 'Test check-in');
|
||||
console.log('Checked in:', checkIn.id);
|
||||
// 4. Attendance Check-in
|
||||
console.log("Checking in...");
|
||||
const checkIn = await db.checkIn(user.id, "gym", "Test check-in");
|
||||
console.log("Checked in:", checkIn.id);
|
||||
|
||||
// 5. Verify Active Check-in
|
||||
const activeCheckIn = await db.getActiveCheckIn(user.id);
|
||||
if (!activeCheckIn || activeCheckIn.id !== checkIn.id) {
|
||||
throw new Error('Active check-in verification failed');
|
||||
}
|
||||
console.log('Active check-in verified');
|
||||
|
||||
// 6. Attendance Check-out
|
||||
console.log('Checking out...');
|
||||
const checkOut = await db.checkOut(checkIn.id);
|
||||
console.log('Checked out:', checkOut?.checkOutTime);
|
||||
|
||||
// 7. Cleanup
|
||||
console.log('Cleaning up...');
|
||||
await db.deleteUser(user.id);
|
||||
console.log('Cleanup complete');
|
||||
|
||||
console.log('✅ Verification successful!');
|
||||
} catch (error) {
|
||||
console.error('❌ Verification failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await db.disconnect();
|
||||
// 5. Verify Active Check-in
|
||||
const activeCheckIn = await db.getActiveCheckIn(user.id);
|
||||
if (!activeCheckIn || activeCheckIn.id !== checkIn.id) {
|
||||
throw new Error("Active check-in verification failed");
|
||||
}
|
||||
console.log("Active check-in verified");
|
||||
|
||||
// 6. Attendance Check-out
|
||||
console.log("Checking out...");
|
||||
const checkOut = await db.checkOut(checkIn.id);
|
||||
console.log("Checked out:", checkOut?.checkOutTime);
|
||||
|
||||
// 7. Cleanup
|
||||
console.log("Cleaning up...");
|
||||
await db.deleteUser(user.id);
|
||||
console.log("Cleanup complete");
|
||||
|
||||
console.log("✅ Verification successful!");
|
||||
} catch (error) {
|
||||
console.error("❌ Verification failed:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await db.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
verifyDatabase();
|
||||
@@ -1,31 +1,32 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
import { ensureUserSynced } from '@/lib/sync-user'
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
||||
|
||||
const db = await getDatabase()
|
||||
const db = await getDatabase();
|
||||
|
||||
// Ensure user exists in DB (sync from Clerk if needed)
|
||||
await ensureUserSynced(userId, db)
|
||||
// Ensure user exists in DB (sync from Clerk if needed)
|
||||
await ensureUserSynced(userId, db);
|
||||
|
||||
// Check if already checked in
|
||||
const activeCheckIn = await db.getActiveCheckIn(userId)
|
||||
if (activeCheckIn) {
|
||||
return new NextResponse('Already checked in', { status: 400 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { type = 'gym', notes } = body
|
||||
|
||||
const attendance = await db.checkIn(userId, type, notes)
|
||||
return NextResponse.json(attendance)
|
||||
} catch (error) {
|
||||
console.error('Check-in error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
// Check if already checked in
|
||||
const activeCheckIn = await db.getActiveCheckIn(userId);
|
||||
if (activeCheckIn) {
|
||||
return new NextResponse("Already checked in", { status: 400 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { type = "gym", notes } = body;
|
||||
|
||||
const attendance = await db.checkIn(userId, type, notes);
|
||||
return NextResponse.json(attendance);
|
||||
} catch (error) {
|
||||
log.error("Failed to check in", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
||||
|
||||
const db = await getDatabase()
|
||||
const db = await getDatabase();
|
||||
|
||||
const activeCheckIn = await db.getActiveCheckIn(userId)
|
||||
if (!activeCheckIn) {
|
||||
return new NextResponse('No active check-in found', { status: 404 })
|
||||
}
|
||||
|
||||
const attendance = await db.checkOut(activeCheckIn.id)
|
||||
return NextResponse.json(attendance)
|
||||
} catch (error) {
|
||||
console.error('Check-out error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
const activeCheckIn = await db.getActiveCheckIn(userId);
|
||||
if (!activeCheckIn) {
|
||||
return new NextResponse("No active check-in found", { status: 404 });
|
||||
}
|
||||
|
||||
const attendance = await db.checkOut(activeCheckIn.id);
|
||||
return NextResponse.json(attendance);
|
||||
} catch (error) {
|
||||
log.error("Failed to check out", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,33 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
import { ensureUserSynced } from '@/lib/sync-user'
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
console.log('GET /api/attendance/history called')
|
||||
console.log('Headers:', Object.fromEntries(req.headers.entries()))
|
||||
log.apiRequest("GET", "/api/attendance/history", {
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
});
|
||||
|
||||
try {
|
||||
const authResult = await auth()
|
||||
const { userId } = authResult
|
||||
console.log('Auth result:', JSON.stringify(authResult, null, 2))
|
||||
try {
|
||||
const authResult = await auth();
|
||||
const { userId } = authResult;
|
||||
log.debug("Auth result received", { authResult });
|
||||
|
||||
if (!userId) {
|
||||
console.log('No userId found in auth result')
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const db = await getDatabase()
|
||||
|
||||
// Ensure user exists in DB (sync from Clerk if needed)
|
||||
await ensureUserSynced(userId, db)
|
||||
|
||||
const history = await db.getAttendanceHistory(userId)
|
||||
return NextResponse.json(history)
|
||||
} catch (error) {
|
||||
console.error('History error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
if (!userId) {
|
||||
log.warn("No userId found in auth result");
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Ensure user exists in DB (sync from Clerk if needed)
|
||||
await ensureUserSynced(userId, db);
|
||||
|
||||
const history = await db.getAttendanceHistory(userId);
|
||||
return NextResponse.json(history);
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch attendance history", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getDatabase } from '../../../../lib/database/index'
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDatabase } from "../../../../lib/database/index";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase()
|
||||
const { email, password } = await request.json()
|
||||
const db = await getDatabase();
|
||||
const { email, password } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
{ error: "Email and password are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const user = await db.getUserByEmail(email)
|
||||
const user = await db.getUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
)
|
||||
{ error: "Invalid credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password)
|
||||
const isValidPassword = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
)
|
||||
{ error: "Invalid credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { password: _, ...userWithoutPassword } = user
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Login successful',
|
||||
user: userWithoutPassword
|
||||
})
|
||||
message: "Login successful",
|
||||
user: userWithoutPassword,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
log.error("Login failed", error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getDatabase } from '../../../../lib/database/index'
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDatabase } from "../../../../lib/database/index";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase()
|
||||
const { email, password, firstName, lastName, phone } = await request.json()
|
||||
const db = await getDatabase();
|
||||
const { email, password, firstName, lastName, phone } =
|
||||
await request.json();
|
||||
|
||||
if (!email || !password || !firstName || !lastName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
)
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = await db.getUserByEmail(email)
|
||||
const existingUser = await db.getUserByEmail(email);
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
{ error: "User already exists" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const newUser = await db.createUser({
|
||||
email,
|
||||
@@ -31,45 +33,47 @@ export async function POST(request: NextRequest) {
|
||||
lastName,
|
||||
password: hashedPassword,
|
||||
phone,
|
||||
role: 'client'
|
||||
})
|
||||
role: "client",
|
||||
});
|
||||
|
||||
const newClient = await db.createClient({
|
||||
userId: newUser.id,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
})
|
||||
membershipType: "basic",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
});
|
||||
|
||||
const { password: _, ...userWithoutPassword } = newUser
|
||||
const { password: _, ...userWithoutPassword } = newUser;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'User registered successfully',
|
||||
user: { ...userWithoutPassword, client: newClient }
|
||||
{
|
||||
message: "User registered successfully",
|
||||
user: { ...userWithoutPassword, client: newClient },
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
log.error("User registration failed", error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = await getDatabase()
|
||||
const allUsers = await db.getAllUsers()
|
||||
const usersWithoutPassword = allUsers.map(({ password: _, ...user }) => user)
|
||||
return NextResponse.json({ users: usersWithoutPassword })
|
||||
const db = await getDatabase();
|
||||
const allUsers = await db.getAllUsers();
|
||||
const usersWithoutPassword = allUsers.map(
|
||||
({ password: _, ...user }) => user,
|
||||
);
|
||||
return NextResponse.json({ users: usersWithoutPassword });
|
||||
} catch (error) {
|
||||
console.error('Get users error:', error)
|
||||
log.error("Failed to get users", error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
// Helper to add CORS headers
|
||||
function corsHeaders() {
|
||||
@@ -52,7 +53,7 @@ export async function GET(
|
||||
|
||||
return NextResponse.json(goal, { headers: corsHeaders() });
|
||||
} catch (error) {
|
||||
console.error("Error fetching fitness goal:", error);
|
||||
log.error("Failed to fetch fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
@@ -103,7 +104,7 @@ export async function PUT(
|
||||
|
||||
return NextResponse.json(updatedGoal, { headers: corsHeaders() });
|
||||
} catch (error) {
|
||||
console.error("Error updating fitness goal:", error);
|
||||
log.error("Failed to update fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
@@ -154,7 +155,7 @@ export async function DELETE(
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting fitness goal:", error);
|
||||
log.error("Failed to delete fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { randomBytes } from "crypto";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
// Helper to add CORS headers
|
||||
function corsHeaders() {
|
||||
@@ -33,18 +34,16 @@ export async function GET(req: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(req);
|
||||
|
||||
if (!userId) {
|
||||
console.error("[Fitness Goals API] Authentication failed");
|
||||
console.error(
|
||||
"[Fitness Goals API] Headers:",
|
||||
Object.fromEntries(req.headers.entries()),
|
||||
);
|
||||
log.error("Fitness goals GET - authentication failed", undefined, {
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[Fitness Goals API] Authenticated user:", userId);
|
||||
log.debug("Fitness goals GET - authenticated user", { userId });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const targetUserId = searchParams.get("userId") || userId;
|
||||
@@ -58,12 +57,13 @@ export async function GET(req: NextRequest) {
|
||||
status && status !== "all" ? status : undefined,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Fitness Goals API] Found ${goals.length} goals for user ${targetUserId}`,
|
||||
);
|
||||
log.debug("Fitness goals retrieved", {
|
||||
count: goals.length,
|
||||
userId: targetUserId,
|
||||
});
|
||||
return NextResponse.json(goals, { headers: corsHeaders() });
|
||||
} catch (error) {
|
||||
console.error("[Fitness Goals API] Error:", error);
|
||||
log.error("Failed to get fitness goals", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
@@ -77,7 +77,7 @@ export async function POST(req: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(req);
|
||||
|
||||
if (!userId) {
|
||||
console.error("[Fitness Goals API] Authentication failed for POST");
|
||||
log.error("Fitness goals POST - authentication failed");
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
@@ -127,12 +127,10 @@ export async function POST(req: NextRequest) {
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Fitness Goals API] Created goal ${goal.id} for user ${userId}`,
|
||||
);
|
||||
log.info("Fitness goal created", { goalId: goal.id, userId });
|
||||
return NextResponse.json(goal, { status: 201, headers: corsHeaders() });
|
||||
} catch (error) {
|
||||
console.error("[Fitness Goals API] Error creating goal:", error);
|
||||
log.error("Failed to create fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import Database from "better-sqlite3";
|
||||
import { randomBytes } from "crypto";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
const db = new Database("./data/fitai.db");
|
||||
|
||||
@@ -15,16 +16,14 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const profile = db
|
||||
.prepare(
|
||||
`SELECT * FROM fitness_profiles WHERE userId = ?`
|
||||
)
|
||||
.prepare(`SELECT * FROM fitness_profiles WHERE userId = ?`)
|
||||
.get(userId);
|
||||
|
||||
if (profile) {
|
||||
const p = profile as any;
|
||||
// Parse JSON fields
|
||||
try {
|
||||
p.fitnessGoals = JSON.parse(p.fitnessGoals || '[]');
|
||||
p.fitnessGoals = JSON.parse(p.fitnessGoals || "[]");
|
||||
} catch (e) {
|
||||
p.fitnessGoals = [];
|
||||
}
|
||||
@@ -32,10 +31,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ profile: profile || null });
|
||||
} catch (error) {
|
||||
console.error("Error fetching fitness profile:", error);
|
||||
log.error("Failed to fetch fitness profile", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch fitness profile" },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -61,7 +60,7 @@ export async function POST(request: NextRequest) {
|
||||
allergies,
|
||||
injuries,
|
||||
exerciseHabits,
|
||||
dietHabits
|
||||
dietHabits,
|
||||
} = body;
|
||||
|
||||
// Check if profile exists
|
||||
@@ -88,7 +87,7 @@ export async function POST(request: NextRequest) {
|
||||
exerciseHabits = ?,
|
||||
dietHabits = ?,
|
||||
updatedAt = ?
|
||||
WHERE userId = ?`
|
||||
WHERE userId = ?`,
|
||||
).run(
|
||||
height || null,
|
||||
weight || null,
|
||||
@@ -102,7 +101,7 @@ export async function POST(request: NextRequest) {
|
||||
exerciseHabits || null,
|
||||
dietHabits || null,
|
||||
now,
|
||||
userId
|
||||
userId,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -115,7 +114,7 @@ export async function POST(request: NextRequest) {
|
||||
`INSERT INTO fitness_profiles
|
||||
(userId, height, weight, age, gender, fitnessGoals, activityLevel,
|
||||
medicalConditions, allergies, injuries, exerciseHabits, dietHabits, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
userId,
|
||||
height || null,
|
||||
@@ -130,7 +129,7 @@ export async function POST(request: NextRequest) {
|
||||
exerciseHabits || null,
|
||||
dietHabits || null,
|
||||
now,
|
||||
now
|
||||
now,
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
@@ -138,14 +137,14 @@ export async function POST(request: NextRequest) {
|
||||
message: "Fitness profile created successfully",
|
||||
userId,
|
||||
},
|
||||
{ status: 201 }
|
||||
{ status: 201 },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving fitness profile:", error);
|
||||
log.error("Failed to save fitness profile", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to save fitness profile" },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { auth } from "@clerk/nextjs/server";
|
||||
import { eq, sql } from "@fitai/database";
|
||||
import { db, users as usersTable } from "@fitai/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
async function ensureGymsTable() {
|
||||
await db.run(sql`
|
||||
@@ -29,7 +30,7 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json(rows);
|
||||
} catch (error) {
|
||||
console.error("GET /gyms error:", error);
|
||||
log.error("Failed to get gyms", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -160,7 +161,7 @@ export async function POST(req: Request) {
|
||||
const created = await db.get(sql`SELECT * FROM gyms WHERE id = ${id}`);
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("POST /gyms error:", error);
|
||||
log.error("Failed to create gym", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDatabase } from '../../../../lib/database/index'
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "../../../../lib/database/index";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase()
|
||||
const profileData = await request.json()
|
||||
const db = await getDatabase();
|
||||
const profileData = await request.json();
|
||||
|
||||
if (!profileData.userId || !profileData.height || !profileData.weight || !profileData.age) {
|
||||
if (
|
||||
!profileData.userId ||
|
||||
!profileData.height ||
|
||||
!profileData.weight ||
|
||||
!profileData.age
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
)
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await db.getUserById(profileData.userId)
|
||||
const user = await db.getUserById(profileData.userId);
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if profile already exists
|
||||
const existingProfile = await db.getFitnessProfileByUserId(profileData.userId)
|
||||
|
||||
let profile
|
||||
const existingProfile = await db.getFitnessProfileByUserId(
|
||||
profileData.userId,
|
||||
);
|
||||
|
||||
let profile;
|
||||
if (existingProfile) {
|
||||
profile = await db.updateFitnessProfile(profileData.userId, profileData)
|
||||
profile = await db.updateFitnessProfile(profileData.userId, profileData);
|
||||
} else {
|
||||
profile = await db.createFitnessProfile(profileData)
|
||||
profile = await db.createFitnessProfile(profileData);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Fitness profile saved successfully',
|
||||
profile
|
||||
{
|
||||
message: "Fitness profile saved successfully",
|
||||
profile,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Fitness profile error:', error)
|
||||
log.error("Failed to save fitness profile", error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase()
|
||||
const { searchParams } = new URL(request.url)
|
||||
const userId = searchParams.get('userId')
|
||||
const db = await getDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
if (userId) {
|
||||
const profile = await db.getFitnessProfileByUserId(userId)
|
||||
const profile = await db.getFitnessProfileByUserId(userId);
|
||||
if (!profile) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Profile not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
{ error: "Profile not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ profile })
|
||||
return NextResponse.json({ profile });
|
||||
}
|
||||
|
||||
const profiles = await db.getAllFitnessProfiles()
|
||||
return NextResponse.json({ profiles })
|
||||
const profiles = await db.getAllFitnessProfiles();
|
||||
return NextResponse.json({ profiles });
|
||||
} catch (error) {
|
||||
console.error('Get fitness profiles error:', error)
|
||||
log.error("Failed to get fitness profiles", error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,190 +2,220 @@ import { NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { buildAIContext } from "@/lib/ai/ai-context";
|
||||
import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId, useExternalModel } = await req.json();
|
||||
try {
|
||||
const { userId, useExternalModel } = await req.json();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "User ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Fetch fitness profile
|
||||
const profile = await db.getFitnessProfileByUserId(userId);
|
||||
|
||||
if (!profile) {
|
||||
return NextResponse.json(
|
||||
{ error: "Fitness profile not found for this user" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build AI context with goals and recommendations
|
||||
let prompt: string;
|
||||
try {
|
||||
const context = await buildAIContext(userId);
|
||||
prompt = buildEnhancedPrompt(context);
|
||||
console.log('Using enhanced AI context with goals and history');
|
||||
} catch (error) {
|
||||
// Fallback to basic prompt if context building fails
|
||||
console.warn('Failed to build AI context, using basic prompt:', error);
|
||||
prompt = buildBasicPrompt(profile);
|
||||
}
|
||||
|
||||
let parsedResponse;
|
||||
|
||||
if (useExternalModel) {
|
||||
// Use DeepSeek AI
|
||||
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
||||
|
||||
if (!deepseekApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "DeepSeek API key not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Using DeepSeek AI model...");
|
||||
|
||||
const deepseekResponse = await fetch("https://api.deepseek.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${deepseekApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!deepseekResponse.ok) {
|
||||
const errorText = await deepseekResponse.text();
|
||||
console.error("DeepSeek API error:", errorText);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate recommendation from DeepSeek AI" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const deepseekData = await deepseekResponse.json();
|
||||
console.log("Raw DeepSeek Response:", deepseekData);
|
||||
|
||||
try {
|
||||
const content = deepseekData.choices[0].message.content;
|
||||
let cleanResponse = content.trim();
|
||||
|
||||
// Remove markdown code blocks if present
|
||||
if (cleanResponse.startsWith("```json")) {
|
||||
cleanResponse = cleanResponse.replace(/^```json\s*/, "").replace(/\s*```$/, "");
|
||||
} else if (cleanResponse.startsWith("```")) {
|
||||
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
|
||||
}
|
||||
|
||||
// Find the first '{' and last '}' to extract the JSON object
|
||||
const firstBrace = cleanResponse.indexOf("{");
|
||||
const lastBrace = cleanResponse.lastIndexOf("}");
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1) {
|
||||
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
|
||||
}
|
||||
|
||||
parsedResponse = JSON.parse(cleanResponse);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse DeepSeek response:", deepseekData);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response format from DeepSeek AI" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Use local Ollama
|
||||
console.log("Using local Ollama model...");
|
||||
|
||||
const ollamaResponse = await fetch("http://localhost:11434/api/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gemma3:latest",
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
format: "json",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
console.error("Ollama API error:", await ollamaResponse.text());
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate recommendation from Ollama" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const aiData = await ollamaResponse.json();
|
||||
console.log("Raw Ollama Response:", aiData.response);
|
||||
|
||||
try {
|
||||
let cleanResponse = aiData.response.trim();
|
||||
|
||||
// Remove markdown code blocks if present
|
||||
if (cleanResponse.startsWith("```json")) {
|
||||
cleanResponse = cleanResponse.replace(/^```json\s*/, "").replace(/\s*```$/, "");
|
||||
} else if (cleanResponse.startsWith("```")) {
|
||||
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
|
||||
}
|
||||
|
||||
// Find the first '{' and last '}' to extract the JSON object
|
||||
const firstBrace = cleanResponse.indexOf("{");
|
||||
const lastBrace = cleanResponse.lastIndexOf("}");
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1) {
|
||||
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
|
||||
}
|
||||
|
||||
parsedResponse = JSON.parse(cleanResponse);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse Ollama response:", aiData.response);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response format from Ollama" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to database
|
||||
const recommendation = await db.createRecommendation({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
fitnessProfileId: profile.id,
|
||||
type: 'ai_plan',
|
||||
recommendationText: parsedResponse.recommendationText,
|
||||
activityPlan: parsedResponse.activityPlan,
|
||||
dietPlan: parsedResponse.dietPlan,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
return NextResponse.json(recommendation);
|
||||
} catch (error) {
|
||||
console.error("Error generating recommendation:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Fetch fitness profile
|
||||
const profile = await db.getFitnessProfileByUserId(userId);
|
||||
|
||||
if (!profile) {
|
||||
return NextResponse.json(
|
||||
{ error: "Fitness profile not found for this user" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build AI context with goals and recommendations
|
||||
let prompt: string;
|
||||
try {
|
||||
const context = await buildAIContext(userId);
|
||||
prompt = buildEnhancedPrompt(context);
|
||||
log.debug("Using enhanced AI context with goals and history");
|
||||
} catch (error) {
|
||||
// Fallback to basic prompt if context building fails
|
||||
log.warn("Failed to build AI context, using basic prompt", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
prompt = buildBasicPrompt(profile);
|
||||
}
|
||||
|
||||
let parsedResponse;
|
||||
|
||||
if (useExternalModel) {
|
||||
// Use DeepSeek AI
|
||||
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
||||
|
||||
if (!deepseekApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "DeepSeek API key not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
log.debug("Using DeepSeek AI model", { userId });
|
||||
|
||||
const deepseekResponse = await fetch(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${deepseekApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!deepseekResponse.ok) {
|
||||
const errorText = await deepseekResponse.text();
|
||||
log.error("DeepSeek API request failed", new Error(errorText), {
|
||||
status: deepseekResponse.status,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate recommendation from DeepSeek AI" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const deepseekData = await deepseekResponse.json();
|
||||
log.debug("Received DeepSeek response", { deepseekData });
|
||||
|
||||
try {
|
||||
const content = deepseekData.choices[0].message.content;
|
||||
let cleanResponse = content.trim();
|
||||
|
||||
// Remove markdown code blocks if present
|
||||
if (cleanResponse.startsWith("```json")) {
|
||||
cleanResponse = cleanResponse
|
||||
.replace(/^```json\s*/, "")
|
||||
.replace(/\s*```$/, "");
|
||||
} else if (cleanResponse.startsWith("```")) {
|
||||
cleanResponse = cleanResponse
|
||||
.replace(/^```\s*/, "")
|
||||
.replace(/\s*```$/, "");
|
||||
}
|
||||
|
||||
// Find the first '{' and last '}' to extract the JSON object
|
||||
const firstBrace = cleanResponse.indexOf("{");
|
||||
const lastBrace = cleanResponse.lastIndexOf("}");
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1) {
|
||||
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
|
||||
}
|
||||
|
||||
parsedResponse = JSON.parse(cleanResponse);
|
||||
} catch (e) {
|
||||
log.error("Failed to parse DeepSeek response", e, {
|
||||
response: deepseekData,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response format from DeepSeek AI" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Use local Ollama
|
||||
log.debug("Using local Ollama model", { userId });
|
||||
|
||||
const ollamaResponse = await fetch(
|
||||
"http://localhost:11434/api/generate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gemma3:latest",
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
format: "json",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
const errorText = await ollamaResponse.text();
|
||||
log.error("Ollama API request failed", new Error(errorText), {
|
||||
status: ollamaResponse.status,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate recommendation from Ollama" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const aiData = await ollamaResponse.json();
|
||||
log.debug("Received Ollama response", { response: aiData.response });
|
||||
|
||||
try {
|
||||
let cleanResponse = aiData.response.trim();
|
||||
|
||||
// Remove markdown code blocks if present
|
||||
if (cleanResponse.startsWith("```json")) {
|
||||
cleanResponse = cleanResponse
|
||||
.replace(/^```json\s*/, "")
|
||||
.replace(/\s*```$/, "");
|
||||
} else if (cleanResponse.startsWith("```")) {
|
||||
cleanResponse = cleanResponse
|
||||
.replace(/^```\s*/, "")
|
||||
.replace(/\s*```$/, "");
|
||||
}
|
||||
|
||||
// Find the first '{' and last '}' to extract the JSON object
|
||||
const firstBrace = cleanResponse.indexOf("{");
|
||||
const lastBrace = cleanResponse.lastIndexOf("}");
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1) {
|
||||
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
|
||||
}
|
||||
|
||||
parsedResponse = JSON.parse(cleanResponse);
|
||||
} catch (e) {
|
||||
log.error("Failed to parse Ollama response", e, {
|
||||
response: aiData.response,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response format from Ollama" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to database
|
||||
const recommendation = await db.createRecommendation({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
fitnessProfileId: profile.id,
|
||||
type: "ai_plan",
|
||||
recommendationText: parsedResponse.recommendationText,
|
||||
activityPlan: parsedResponse.activityPlan,
|
||||
dietPlan: parsedResponse.dietPlan,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
return NextResponse.json(recommendation);
|
||||
} catch (error) {
|
||||
log.error("Failed to generate recommendation", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +1,157 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { userId: currentUserId } = await auth()
|
||||
if (!currentUserId) {
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const targetUserId = searchParams.get('userId')
|
||||
|
||||
const db = await getDatabase()
|
||||
|
||||
// If no userId provided, check if staff and return all recommendations
|
||||
if (!targetUserId) {
|
||||
const currentUser = await db.getUserById(currentUserId)
|
||||
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
|
||||
|
||||
if (!isStaff) {
|
||||
return new NextResponse('User ID is required', { status: 400 })
|
||||
}
|
||||
|
||||
const recommendations = await db.getAllRecommendations()
|
||||
return NextResponse.json({ recommendations })
|
||||
}
|
||||
|
||||
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
|
||||
const currentUser = await db.getUserById(currentUserId)
|
||||
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
|
||||
|
||||
if (currentUserId !== targetUserId) {
|
||||
if (!isStaff) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
let recommendations = await db.getRecommendationsByUserId(targetUserId)
|
||||
|
||||
// Non-staff users should only see approved recommendations
|
||||
if (!isStaff) {
|
||||
recommendations = recommendations.filter((rec: any) => rec.status === 'approved')
|
||||
}
|
||||
|
||||
return NextResponse.json(recommendations)
|
||||
} catch (error) {
|
||||
console.error('Error fetching recommendations:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
try {
|
||||
const { userId: currentUserId } = await auth();
|
||||
if (!currentUserId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const targetUserId = searchParams.get("userId");
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// If no userId provided, check if staff and return all recommendations
|
||||
if (!targetUserId) {
|
||||
const currentUser = await db.getUserById(currentUserId);
|
||||
const isStaff =
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.role === "superAdmin" ||
|
||||
currentUser?.role === "trainer";
|
||||
|
||||
if (!isStaff) {
|
||||
return new NextResponse("User ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
const recommendations = await db.getAllRecommendations();
|
||||
return NextResponse.json({ recommendations });
|
||||
}
|
||||
|
||||
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
|
||||
const currentUser = await db.getUserById(currentUserId);
|
||||
const isStaff =
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.role === "superAdmin" ||
|
||||
currentUser?.role === "trainer";
|
||||
|
||||
if (currentUserId !== targetUserId) {
|
||||
if (!isStaff) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
let recommendations = await db.getRecommendationsByUserId(targetUserId);
|
||||
|
||||
// Non-staff users should only see approved recommendations
|
||||
if (!isStaff) {
|
||||
recommendations = recommendations.filter(
|
||||
(rec: any) => rec.status === "approved",
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(recommendations);
|
||||
} catch (error) {
|
||||
console.error("Error fetching recommendations:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { userId: currentUserId } = await auth()
|
||||
if (!currentUserId) {
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const db = await getDatabase()
|
||||
const currentUser = await db.getUserById(currentUserId)
|
||||
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
|
||||
|
||||
if (!isStaff) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { userId, fitnessProfileId, recommendationText, activityPlan, dietPlan, status, type, content } = body
|
||||
|
||||
// Handle AI Plan (Legacy/Specific)
|
||||
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) {
|
||||
const recommendation = await db.createRecommendation({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
fitnessProfileId,
|
||||
type: 'ai_plan',
|
||||
recommendationText: recommendationText,
|
||||
activityPlan,
|
||||
dietPlan,
|
||||
status: status || 'pending'
|
||||
})
|
||||
return NextResponse.json(recommendation)
|
||||
}
|
||||
|
||||
// Handle User Goal (Generic)
|
||||
if (type && content) {
|
||||
const recommendation = await db.createRecommendation({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
type,
|
||||
recommendationText: content,
|
||||
status: status || 'pending'
|
||||
})
|
||||
return NextResponse.json(recommendation)
|
||||
}
|
||||
|
||||
return NextResponse.json('Missing required fields', { status: 400 })
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating recommendation:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
try {
|
||||
const { userId: currentUserId } = await auth();
|
||||
if (!currentUserId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const currentUser = await db.getUserById(currentUserId);
|
||||
const isStaff =
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.role === "superAdmin" ||
|
||||
currentUser?.role === "trainer";
|
||||
|
||||
if (!isStaff) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
userId,
|
||||
fitnessProfileId,
|
||||
recommendationText,
|
||||
activityPlan,
|
||||
dietPlan,
|
||||
status,
|
||||
type,
|
||||
content,
|
||||
} = body;
|
||||
|
||||
// Handle AI Plan (Legacy/Specific)
|
||||
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) {
|
||||
const recommendation = await db.createRecommendation({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
fitnessProfileId,
|
||||
type: "ai_plan",
|
||||
recommendationText: recommendationText,
|
||||
activityPlan,
|
||||
dietPlan,
|
||||
status: status || "pending",
|
||||
});
|
||||
return NextResponse.json(recommendation);
|
||||
}
|
||||
|
||||
// Handle User Goal (Generic)
|
||||
if (type && content) {
|
||||
const recommendation = await db.createRecommendation({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
type,
|
||||
recommendationText: content,
|
||||
status: status || "pending",
|
||||
});
|
||||
return NextResponse.json(recommendation);
|
||||
}
|
||||
|
||||
return NextResponse.json("Missing required fields", { status: 400 });
|
||||
} catch (error) {
|
||||
log.error("Failed to create recommendation", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { userId: currentUserId } = await auth()
|
||||
if (!currentUserId) {
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { id, status, recommendationText, activityPlan, dietPlan, content } = body
|
||||
|
||||
if (!id) {
|
||||
return new NextResponse('Recommendation ID is required', { status: 400 })
|
||||
}
|
||||
|
||||
const db = await getDatabase()
|
||||
|
||||
const updated = await db.updateRecommendation(id, {
|
||||
...(status && { status }),
|
||||
...(recommendationText && { recommendationText }),
|
||||
...(content && { recommendationText: content }),
|
||||
...(activityPlan && { activityPlan }),
|
||||
...(dietPlan && { dietPlan })
|
||||
})
|
||||
|
||||
return NextResponse.json(updated)
|
||||
} catch (error) {
|
||||
console.error('Error updating recommendation:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
try {
|
||||
const { userId: currentUserId } = await auth();
|
||||
if (!currentUserId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, status, recommendationText, activityPlan, dietPlan, content } =
|
||||
body;
|
||||
|
||||
if (!id) {
|
||||
return new NextResponse("Recommendation ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
const updated = await db.updateRecommendation(id, {
|
||||
...(status && { status }),
|
||||
...(recommendationText && { recommendationText }),
|
||||
...(content && { recommendationText: content }),
|
||||
...(activityPlan && { activityPlan }),
|
||||
...(dietPlan && { dietPlan }),
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
log.error("Failed to update recommendation", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db, users as usersTable, eq, sql } from "@fitai/database";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
/**
|
||||
* PATCH /api/users/gym
|
||||
@@ -12,7 +13,7 @@ import { db, users as usersTable, eq, sql } from "@fitai/database";
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
console.log("PATCH /api/users/gym auth userId:", userId);
|
||||
log.debug("Updating user gym assignment", { userId });
|
||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
@@ -24,32 +25,32 @@ export async function PATCH(req: Request) {
|
||||
}
|
||||
|
||||
const gymId = body.gymId === null ? null : String(body.gymId);
|
||||
console.log("PATCH /api/users/gym parsed gymId from body:", gymId);
|
||||
log.debug("Parsed gym ID from request body", { gymId });
|
||||
|
||||
// Ensure user exists
|
||||
console.log("PATCH /api/users/gym fetching user by id:", userId);
|
||||
log.debug("Fetching user by ID", { userId });
|
||||
const user = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId))
|
||||
.get();
|
||||
console.log("PATCH /api/users/gym fetched user:", user);
|
||||
log.debug("User fetched", { user: user ? { id: user.id } : null });
|
||||
if (!user) return new NextResponse("User not found", { status: 404 });
|
||||
|
||||
// Validate gym when provided
|
||||
if (gymId) {
|
||||
console.log("PATCH /api/users/gym validating gym:", gymId);
|
||||
log.debug("Validating gym", { gymId });
|
||||
const rows = await db.all(
|
||||
sql`SELECT status FROM gyms WHERE id = ${gymId} LIMIT 1`,
|
||||
);
|
||||
console.log("PATCH /api/users/gym validation query result rows:", rows);
|
||||
log.debug("Gym validation query result", { rowCount: rows?.length });
|
||||
const gym = rows?.[0] as { status?: string } | undefined;
|
||||
if (!gym) {
|
||||
console.log("PATCH /api/users/gym validation: gym not found");
|
||||
log.warn("Gym not found during validation", { gymId });
|
||||
return NextResponse.json({ error: "Gym not found" }, { status: 404 });
|
||||
}
|
||||
if (gym.status !== "active") {
|
||||
console.log("PATCH /api/users/gym validation: gym not active", gym);
|
||||
log.warn("Gym is not active", { gymId, status: gym.status });
|
||||
return NextResponse.json(
|
||||
{ error: "Gym is not active" },
|
||||
{ status: 400 },
|
||||
@@ -58,24 +59,21 @@ export async function PATCH(req: Request) {
|
||||
}
|
||||
|
||||
// Update user's gym selection
|
||||
console.log("PATCH /api/users/gym updating user gym_id:", {
|
||||
userId,
|
||||
gymId,
|
||||
});
|
||||
log.debug("Updating user gym assignment in database", { userId, gymId });
|
||||
await db.run(
|
||||
sql`UPDATE users SET gym_id = ${gymId ?? null}, updated_at = ${Date.now()} WHERE id = ${userId}`,
|
||||
);
|
||||
console.log("PATCH /api/users/gym update completed");
|
||||
log.debug("User gym assignment updated");
|
||||
|
||||
const updated = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId))
|
||||
.get();
|
||||
console.log("PATCH /api/users/gym returning updated user:", updated);
|
||||
log.debug("Returning updated user", { userId });
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error("PATCH /users/gym error:", error);
|
||||
log.error("Failed to update user gym assignment", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getDatabase } from "../../../lib/database/index";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
import { db as rawDb, sql } from "@fitai/database";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -31,28 +32,25 @@ export async function GET(request: NextRequest) {
|
||||
(g.name as string) || (g.id as string),
|
||||
]),
|
||||
);
|
||||
console.log(
|
||||
"GET /api/users: total users fetched from DB:",
|
||||
Array.isArray(users) ? users.length : 0,
|
||||
);
|
||||
log.debug("Fetched users from database", {
|
||||
totalUsers: Array.isArray(users) ? users.length : 0,
|
||||
});
|
||||
|
||||
if (role) {
|
||||
users = users.filter((user) => user.role === role);
|
||||
}
|
||||
console.log(
|
||||
"GET /api/users: role filter:",
|
||||
log.debug("Applied role filter", {
|
||||
role,
|
||||
"users after filter:",
|
||||
Array.isArray(users) ? users.length : 0,
|
||||
"sample:",
|
||||
users && users[0]
|
||||
? {
|
||||
id: users[0].id,
|
||||
role: users[0].role,
|
||||
gymId: (users as any)[0].gymId,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
usersAfterFilter: Array.isArray(users) ? users.length : 0,
|
||||
sample:
|
||||
users && users[0]
|
||||
? {
|
||||
id: users[0].id,
|
||||
role: users[0].role,
|
||||
gymId: (users as any)[0].gymId,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
const usersWithClients = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
@@ -116,21 +114,20 @@ export async function GET(request: NextRequest) {
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(
|
||||
"GET /api/users: responding users count:",
|
||||
Array.isArray(usersWithClients) ? usersWithClients.length : 0,
|
||||
"sample:",
|
||||
usersWithClients && usersWithClients[0]
|
||||
? {
|
||||
id: usersWithClients[0].id,
|
||||
role: usersWithClients[0].role,
|
||||
gymId: (usersWithClients as any)[0].gymId,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
log.debug("Prepared response with user details", {
|
||||
usersCount: Array.isArray(usersWithClients) ? usersWithClients.length : 0,
|
||||
sample:
|
||||
usersWithClients && usersWithClients[0]
|
||||
? {
|
||||
id: usersWithClients[0].id,
|
||||
role: usersWithClients[0].role,
|
||||
gymId: (usersWithClients as any)[0].gymId,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
return NextResponse.json({ users: usersWithClients });
|
||||
} catch (error) {
|
||||
console.error("Get users error:", error);
|
||||
log.error("Failed to get users", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
@@ -218,7 +215,11 @@ export async function POST(request: NextRequest) {
|
||||
ignoreExisting: true, // Don't fail if invite exists
|
||||
});
|
||||
} catch (clerkError: any) {
|
||||
console.error("Clerk invitation error:", clerkError);
|
||||
log.error("Clerk invitation failed", clerkError, {
|
||||
email,
|
||||
role,
|
||||
errorCode: clerkError.errors?.[0]?.code,
|
||||
});
|
||||
// If user already exists in Clerk, we might want to handle it.
|
||||
// But for now, let's proceed to create local record if invite sent or if they exist.
|
||||
if (clerkError.errors?.[0]?.code === "form_identifier_exists") {
|
||||
@@ -263,7 +264,7 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Create user error:", error);
|
||||
log.error("Failed to create user", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
@@ -276,7 +277,7 @@ export async function PUT(request: NextRequest) {
|
||||
const db = await getDatabase();
|
||||
const body = await request.json();
|
||||
const { id, email, firstName, lastName, role, phone, gymId } = body;
|
||||
console.log("PUT /api/users received body:", {
|
||||
log.debug("Updating user", {
|
||||
id,
|
||||
email,
|
||||
firstName,
|
||||
@@ -346,7 +347,7 @@ export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const client = await clerkClient();
|
||||
const publicMetadata: Record<string, unknown> = {};
|
||||
console.log("PUT /api/users preparing Clerk metadata update:", {
|
||||
log.debug("Preparing Clerk metadata update", {
|
||||
targetUserId: id,
|
||||
role,
|
||||
gymId,
|
||||
@@ -360,23 +361,20 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (Object.keys(publicMetadata).length > 0) {
|
||||
console.log(
|
||||
"PUT /api/users calling Clerk updateUser with metadata:",
|
||||
publicMetadata,
|
||||
);
|
||||
log.debug("Updating Clerk user metadata", { publicMetadata });
|
||||
const clerkResult = await client.users.updateUser(id, {
|
||||
publicMetadata,
|
||||
});
|
||||
console.log("PUT /api/users Clerk updateUser result:", {
|
||||
log.debug("Clerk user updated successfully", {
|
||||
id: clerkResult.id,
|
||||
role: clerkResult.publicMetadata?.role,
|
||||
gymId: clerkResult.publicMetadata?.gymId,
|
||||
});
|
||||
} else {
|
||||
console.log("PUT /api/users no Clerk metadata changes requested");
|
||||
log.debug("No Clerk metadata changes requested");
|
||||
}
|
||||
} catch (clerkErr: any) {
|
||||
console.error("Clerk metadata update error:", clerkErr);
|
||||
log.error("Clerk metadata update failed", clerkErr, { userId: id });
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update role/gym in identity provider" },
|
||||
{ status: 500 },
|
||||
@@ -384,9 +382,7 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Update local DB for immediate UI feedback (webhook will also sync)
|
||||
console.log(
|
||||
"PUT /api/users raw SQL updating local DB user gym_id and fields",
|
||||
);
|
||||
log.debug("Updating local database user");
|
||||
await rawDb.run(
|
||||
sql`UPDATE users
|
||||
SET email = ${email ?? existingUser.email},
|
||||
@@ -402,7 +398,7 @@ export async function PUT(request: NextRequest) {
|
||||
const updatedRow = await rawDb.get(
|
||||
sql`SELECT id, email, first_name, last_name, role, phone, gym_id, created_at, updated_at FROM users WHERE id = ${id}`,
|
||||
);
|
||||
console.log("PUT /api/users raw DB row after update:", updatedRow);
|
||||
log.debug("User updated in database", { updatedRow });
|
||||
|
||||
const updatedUser = {
|
||||
...existingUser,
|
||||
@@ -418,11 +414,11 @@ export async function PUT(request: NextRequest) {
|
||||
? gymId
|
||||
: existingUser.gymId,
|
||||
};
|
||||
console.log("PUT /api/users responding with updated user:", updatedUser);
|
||||
log.debug("Sending updated user response", { updatedUser });
|
||||
|
||||
return NextResponse.json({ user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error("Update user error:", error);
|
||||
log.error("Failed to update user", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
@@ -457,7 +453,7 @@ export async function DELETE(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Delete user error:", error);
|
||||
log.error("Failed to delete user(s)", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WebhookEvent } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// Get the webhook secret from environment variables
|
||||
@@ -46,13 +47,16 @@ export async function POST(req: Request) {
|
||||
"svix-signature": svix_signature,
|
||||
}) as WebhookEvent;
|
||||
} catch (err) {
|
||||
console.error("Error verifying webhook:", err);
|
||||
log.error("Webhook verification failed", err);
|
||||
return NextResponse.json({ error: "Verification failed" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Handle the webhook
|
||||
const eventType = evt.type;
|
||||
console.log(`Received webhook with ID ${evt.data.id} and type ${eventType}`);
|
||||
log.info("Received webhook event", {
|
||||
userId: evt.data.id,
|
||||
eventType,
|
||||
});
|
||||
|
||||
try {
|
||||
// Connect to database directly for webhook operations
|
||||
@@ -70,7 +74,13 @@ export async function POST(req: Request) {
|
||||
);
|
||||
|
||||
if (!primaryEmail?.email_address) {
|
||||
console.error("No primary email found for user:", id);
|
||||
log.error(
|
||||
"No primary email found for user (user.created)",
|
||||
undefined,
|
||||
{
|
||||
userId: id,
|
||||
},
|
||||
);
|
||||
db.close();
|
||||
return NextResponse.json(
|
||||
{ error: "No primary email" },
|
||||
@@ -161,9 +171,11 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ User ${id} created in database (role=${role}, gymId=${gymId ?? "null"})`,
|
||||
);
|
||||
log.info("User created in database", {
|
||||
userId: id,
|
||||
role,
|
||||
gymId: gymId ?? null,
|
||||
});
|
||||
db.close();
|
||||
break;
|
||||
}
|
||||
@@ -178,7 +190,13 @@ export async function POST(req: Request) {
|
||||
);
|
||||
|
||||
if (!primaryEmail?.email_address) {
|
||||
console.error("No primary email found for user:", id);
|
||||
log.error(
|
||||
"No primary email found for user (user.updated)",
|
||||
undefined,
|
||||
{
|
||||
userId: id,
|
||||
},
|
||||
);
|
||||
db.close();
|
||||
return NextResponse.json(
|
||||
{ error: "No primary email" },
|
||||
@@ -236,7 +254,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ User ${id} updated in database`);
|
||||
log.info("User updated in database", { userId: id });
|
||||
db.close();
|
||||
break;
|
||||
}
|
||||
@@ -245,7 +263,7 @@ export async function POST(req: Request) {
|
||||
const { id } = evt.data;
|
||||
|
||||
if (!id) {
|
||||
console.error("No user ID provided for deletion");
|
||||
log.error("No user ID provided for deletion");
|
||||
db.close();
|
||||
return NextResponse.json({ error: "No user ID" }, { status: 400 });
|
||||
}
|
||||
@@ -254,13 +272,13 @@ export async function POST(req: Request) {
|
||||
const stmt = db.prepare("DELETE FROM users WHERE id = ?");
|
||||
stmt.run(id);
|
||||
|
||||
console.log(`✅ User ${id} deleted from database`);
|
||||
log.info("User deleted from database", { userId: id });
|
||||
db.close();
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`Unhandled webhook event type: ${eventType}`);
|
||||
log.debug("Unhandled webhook event type", { eventType });
|
||||
db.close();
|
||||
}
|
||||
|
||||
@@ -269,7 +287,7 @@ export async function POST(req: Request) {
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error processing webhook:", error);
|
||||
log.error("Webhook processing failed", error);
|
||||
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,259 +2,286 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
activityPlan: string;
|
||||
dietPlan: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
recommendationText: string;
|
||||
activityPlan: string;
|
||||
dietPlan: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export default function RecommendationsPage() {
|
||||
const { user } = useUser();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [pendingRecommendations, setPendingRecommendations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState<string | null>(null);
|
||||
const [useExternalModel, setUseExternalModel] = useState(false);
|
||||
const { user } = useUser();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [pendingRecommendations, setPendingRecommendations] = useState<
|
||||
Recommendation[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState<string | null>(null);
|
||||
const [useExternalModel, setUseExternalModel] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch users
|
||||
const usersRes = await fetch("/api/users");
|
||||
const usersData = await usersRes.json();
|
||||
setUsers(usersData.users || []);
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch users
|
||||
const usersRes = await fetch("/api/users");
|
||||
const usersData = await usersRes.json();
|
||||
setUsers(usersData.users || []);
|
||||
|
||||
// Fetch pending recommendations
|
||||
const recsRes = await fetch("/api/recommendations");
|
||||
const recsData = await recsRes.json();
|
||||
const allRecs = recsData.recommendations || [];
|
||||
setPendingRecommendations(allRecs.filter((r: any) => r.status === 'pending'));
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// Fetch pending recommendations
|
||||
const recsRes = await fetch("/api/recommendations");
|
||||
const recsData = await recsRes.json();
|
||||
const allRecs = recsData.recommendations || [];
|
||||
setPendingRecommendations(
|
||||
allRecs.filter((r: Recommendation) => r.status === "pending"),
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (userId: string) => {
|
||||
setGenerating(userId);
|
||||
try {
|
||||
const res = await fetch("/api/recommendations/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, useExternalModel }),
|
||||
});
|
||||
const handleGenerate = async (userId: string) => {
|
||||
setGenerating(userId);
|
||||
try {
|
||||
const res = await fetch("/api/recommendations/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, useExternalModel }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
alert(`Error: ${error.error}`);
|
||||
} else {
|
||||
alert("Recommendation generated successfully!");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Failed to generate recommendation.");
|
||||
} finally {
|
||||
setGenerating(null);
|
||||
}
|
||||
};
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
alert(`Error: ${error.error}`);
|
||||
} else {
|
||||
alert("Recommendation generated successfully!");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to generate recommendation", error);
|
||||
alert("Failed to generate recommendation.");
|
||||
} finally {
|
||||
setGenerating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (recommendationId: string, status: "approved" | "rejected") => {
|
||||
try {
|
||||
const res = await fetch("/api/recommendations/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
recommendationId,
|
||||
status,
|
||||
approvedBy: user?.id || "admin",
|
||||
}),
|
||||
});
|
||||
const handleApprove = async (
|
||||
recommendationId: string,
|
||||
status: "approved" | "rejected",
|
||||
) => {
|
||||
try {
|
||||
const res = await fetch("/api/recommendations/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
recommendationId,
|
||||
status,
|
||||
approvedBy: user?.id || "admin",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
alert(`Failed to update status: ${errorData.error || 'Unknown error'}`);
|
||||
} else {
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Error processing request");
|
||||
}
|
||||
};
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
alert(`Failed to update status: ${errorData.error || "Unknown error"}`);
|
||||
} else {
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to approve recommendation", error);
|
||||
alert("Error processing request");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (rec: Recommendation) => {
|
||||
const newContent = prompt("Edit Advice:", rec.content);
|
||||
const newActivityPlan = prompt("Edit Activity Plan:", rec.activityPlan);
|
||||
const newDietPlan = prompt("Edit Diet Plan:", rec.dietPlan);
|
||||
const handleEdit = async (rec: Recommendation) => {
|
||||
const newContent = prompt("Edit Advice:", rec.content);
|
||||
const newActivityPlan = prompt("Edit Activity Plan:", rec.activityPlan);
|
||||
const newDietPlan = prompt("Edit Diet Plan:", rec.dietPlan);
|
||||
|
||||
if (newContent === null || newActivityPlan === null || newDietPlan === null) {
|
||||
// User cancelled one of the prompts
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/recommendations", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: rec.id,
|
||||
content: newContent,
|
||||
activityPlan: newActivityPlan,
|
||||
dietPlan: newDietPlan,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
alert(`Failed to update recommendation: ${errorData.error || 'Unknown error'}`);
|
||||
} else {
|
||||
alert("Recommendation updated successfully!");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating recommendation:", error);
|
||||
alert("Failed to update recommendation.");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-xl">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
if (
|
||||
newContent === null ||
|
||||
newActivityPlan === null ||
|
||||
newDietPlan === null
|
||||
) {
|
||||
// User cancelled one of the prompts
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/recommendations", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: rec.id,
|
||||
content: newContent,
|
||||
activityPlan: newActivityPlan,
|
||||
dietPlan: newDietPlan,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
alert(
|
||||
`Failed to update recommendation: ${errorData.error || "Unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
alert("Recommendation updated successfully!");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to update recommendation", error);
|
||||
alert("Failed to update recommendation.");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">AI Recommendations</h1>
|
||||
|
||||
{/* Model Selection Toggle */}
|
||||
<div className="flex items-center gap-3 bg-white px-4 py-2 rounded-lg shadow">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{useExternalModel ? "DeepSeek AI" : "Local Ollama"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setUseExternalModel(!useExternalModel)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${useExternalModel ? "bg-blue-600" : "bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${useExternalModel ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{useExternalModel ? "External" : "Local"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Generate Section */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4">Generate Recommendations</h2>
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="mb-4 text-gray-600">
|
||||
Select a user to generate a new daily recommendation.
|
||||
</p>
|
||||
<ul className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<li key={user.id} className="flex items-center justify-between border-b pb-2">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleGenerate(user.id)}
|
||||
disabled={generating === user.id}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{generating === user.id ? "Generating..." : "Generate"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<p className="text-gray-500 italic">No users found.</p>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Approvals Section */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4">Pending Approvals</h2>
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
{pendingRecommendations.length === 0 ? (
|
||||
<p className="text-gray-500 italic">No pending recommendations.</p>
|
||||
) : (
|
||||
<ul className="space-y-6">
|
||||
{pendingRecommendations.map((rec) => (
|
||||
<li key={rec.id} className="border rounded p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="font-bold">For: User {rec.userId}</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(rec.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">Advice:</span> {rec.recommendationText}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Activity:</span> {rec.activityPlan}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Diet:</span> {rec.dietPlan}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEdit(rec)}
|
||||
className="flex-1 bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(rec.id, "approved")}
|
||||
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(rec.id, "rejected")}
|
||||
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-xl">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">AI Recommendations</h1>
|
||||
|
||||
{/* Model Selection Toggle */}
|
||||
<div className="flex items-center gap-3 bg-white px-4 py-2 rounded-lg shadow">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{useExternalModel ? "DeepSeek AI" : "Local Ollama"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setUseExternalModel(!useExternalModel)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
useExternalModel ? "bg-blue-600" : "bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
useExternalModel ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{useExternalModel ? "External" : "Local"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Generate Section */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Generate Recommendations
|
||||
</h2>
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="mb-4 text-gray-600">
|
||||
Select a user to generate a new daily recommendation.
|
||||
</p>
|
||||
<ul className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className="flex items-center justify-between border-b pb-2"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleGenerate(user.id)}
|
||||
disabled={generating === user.id}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{generating === user.id ? "Generating..." : "Generate"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<p className="text-gray-500 italic">No users found.</p>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Approvals Section */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4">Pending Approvals</h2>
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
{pendingRecommendations.length === 0 ? (
|
||||
<p className="text-gray-500 italic">
|
||||
No pending recommendations.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-6">
|
||||
{pendingRecommendations.map((rec) => (
|
||||
<li key={rec.id} className="border rounded p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="font-bold">For: User {rec.userId}</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(rec.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">Advice:</span>{" "}
|
||||
{rec.recommendationText}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Activity:</span>{" "}
|
||||
{rec.activityPlan}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Diet:</span>{" "}
|
||||
{rec.dietPlan}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEdit(rec)}
|
||||
className="flex-1 bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(rec.id, "approved")}
|
||||
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(rec.id, "rejected")}
|
||||
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
interface Backup {
|
||||
name: string;
|
||||
@@ -56,7 +57,7 @@ export default function SettingsPage() {
|
||||
const response = await axios.get("/api/admin/backups");
|
||||
setBackups(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch backups:", error);
|
||||
log.error("Failed to fetch backups", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -69,7 +70,7 @@ export default function SettingsPage() {
|
||||
const res = await axios.get("/api/gyms");
|
||||
setGyms(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch gyms:", error);
|
||||
log.error("Failed to fetch gyms", error);
|
||||
setGymMessage({ type: "error", text: "Failed to load gyms" });
|
||||
} finally {
|
||||
setGymsLoading(false);
|
||||
@@ -89,7 +90,7 @@ export default function SettingsPage() {
|
||||
await fetchBackups();
|
||||
setMessage({ type: "success", text: "Backup created successfully" });
|
||||
} catch (error) {
|
||||
console.error("Failed to create backup:", error);
|
||||
log.error("Failed to create backup", error);
|
||||
setMessage({ type: "error", text: "Failed to create backup" });
|
||||
} finally {
|
||||
setCreatingBackup(false);
|
||||
@@ -112,7 +113,7 @@ export default function SettingsPage() {
|
||||
setMessage({ type: "success", text: "Database restored successfully" });
|
||||
// Optional: Refresh page or force re-login if session is invalidated
|
||||
} catch (error) {
|
||||
console.error("Failed to restore backup:", error);
|
||||
log.error("Failed to restore backup", error);
|
||||
setMessage({ type: "error", text: "Failed to restore backup" });
|
||||
} finally {
|
||||
setRestoring(null);
|
||||
@@ -144,7 +145,7 @@ export default function SettingsPage() {
|
||||
text: gymId ? "Gym selected successfully" : "Proceeding without gym",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to set gym:", error);
|
||||
log.error("Failed to set gym", error);
|
||||
setGymMessage({ type: "error", text: "Failed to set gym" });
|
||||
}
|
||||
};
|
||||
@@ -247,7 +248,7 @@ export default function SettingsPage() {
|
||||
setGymLocation("");
|
||||
fetchGyms();
|
||||
} catch (error) {
|
||||
console.error("Failed to create gym:", error);
|
||||
log.error("Failed to create gym", error);
|
||||
setGymMessage({
|
||||
type: "error",
|
||||
text: "Failed to create gym",
|
||||
|
||||
@@ -1,104 +1,135 @@
|
||||
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { Recommendations } from "@/components/users/Recommendations";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function UserProfilePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
const user = await db.getUserById(id);
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
const user = await db.getUserById(id);
|
||||
|
||||
if (!user) {
|
||||
notFound();
|
||||
}
|
||||
if (!user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const client = await db.getClientByUserId(user.id);
|
||||
const fitnessProfile = await db.getFitnessProfileByUserId(user.id);
|
||||
const client = await db.getClientByUserId(user.id);
|
||||
const fitnessProfile = await db.getFitnessProfileByUserId(user.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">
|
||||
{user.firstName} {user.lastName}
|
||||
</h1>
|
||||
<span className="px-3 py-1 bg-gray-100 rounded-full text-sm font-medium capitalize">
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">
|
||||
{user.firstName} {user.lastName}
|
||||
</h1>
|
||||
<span className="px-3 py-1 bg-gray-100 rounded-full text-sm font-medium capitalize">
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">Contact Information</h2>
|
||||
<div className="space-y-2">
|
||||
<p><span className="font-medium">Email:</span> {user.email}</p>
|
||||
<p><span className="font-medium">Phone:</span> {user.phone || "N/A"}</p>
|
||||
<p><span className="font-medium">Joined:</span> {user.createdAt.toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client Info */}
|
||||
{client && (
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">Membership Details</h2>
|
||||
<div className="space-y-2">
|
||||
<p><span className="font-medium">Type:</span> {client.membershipType}</p>
|
||||
<p><span className="font-medium">Status:</span> {client.membershipStatus}</p>
|
||||
<p><span className="font-medium">Member Since:</span> {client.joinDate.toLocaleDateString()}</p>
|
||||
<p><span className="font-medium">Last Visit:</span> {client.lastVisit?.toLocaleDateString() || "Never"}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fitness Profile */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">Fitness Profile</h2>
|
||||
{fitnessProfile ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2 text-gray-700">Physical Stats</h3>
|
||||
<p>Height: {fitnessProfile.height} cm</p>
|
||||
<p>Weight: {fitnessProfile.weight} kg</p>
|
||||
<p>Age: {fitnessProfile.age}</p>
|
||||
<p>Gender: {fitnessProfile.gender}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2 text-gray-700">Health & Habits</h3>
|
||||
<p>Activity Level: {fitnessProfile.activityLevel.replace('_', ' ')}</p>
|
||||
<p>Diet: {fitnessProfile.dietHabits || "N/A"}</p>
|
||||
<p>Exercise: {fitnessProfile.exerciseHabits || "N/A"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2 text-gray-700">Medical</h3>
|
||||
<p>Conditions: {fitnessProfile.medicalConditions || "None"}</p>
|
||||
<p>Allergies: {fitnessProfile.allergies || "None"}</p>
|
||||
<p>Injuries: {fitnessProfile.injuries || "None"}</p>
|
||||
</div>
|
||||
<div className="col-span-full mt-4">
|
||||
<h3 className="font-medium mb-2 text-gray-700">Goals</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fitnessProfile.fitnessGoals.map((goal, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-sm">
|
||||
{goal.replace('_', ' ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 italic">No fitness profile created yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recommendations Component */}
|
||||
<Recommendations userId={user.id} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">Contact Information</h2>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<span className="font-medium">Email:</span> {user.email}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Phone:</span> {user.phone || "N/A"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Joined:</span>{" "}
|
||||
{user.createdAt.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Client Info */}
|
||||
{client && (
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">Membership Details</h2>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<span className="font-medium">Type:</span>{" "}
|
||||
{client.membershipType}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Status:</span>{" "}
|
||||
{client.membershipStatus}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Member Since:</span>{" "}
|
||||
{client.joinDate.toLocaleDateString()}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Last Visit:</span>{" "}
|
||||
{client.lastVisit?.toLocaleDateString() || "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fitness Profile */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">Fitness Profile</h2>
|
||||
{fitnessProfile ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2 text-gray-700">Physical Stats</h3>
|
||||
<p>Height: {fitnessProfile.height} cm</p>
|
||||
<p>Weight: {fitnessProfile.weight} kg</p>
|
||||
<p>Age: {fitnessProfile.age}</p>
|
||||
<p>Gender: {fitnessProfile.gender}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2 text-gray-700">
|
||||
Health & Habits
|
||||
</h3>
|
||||
<p>
|
||||
Activity Level:{" "}
|
||||
{fitnessProfile.activityLevel?.replace("_", " ") || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2 text-gray-700">Medical</h3>
|
||||
<p>Conditions: {fitnessProfile.medicalConditions || "None"}</p>
|
||||
<p>Allergies: {fitnessProfile.allergies || "None"}</p>
|
||||
<p>Injuries: {fitnessProfile.injuries || "None"}</p>
|
||||
</div>
|
||||
<div className="col-span-full mt-4">
|
||||
<h3 className="font-medium mb-2 text-gray-700">Goals</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fitnessProfile.fitnessGoals &&
|
||||
fitnessProfile.fitnessGoals.length > 0 ? (
|
||||
fitnessProfile.fitnessGoals.map((goal, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-sm"
|
||||
>
|
||||
{goal.replace("_", " ")}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-500 italic">No goals set</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 italic">
|
||||
No fitness profile created yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recommendations Component */}
|
||||
<Recommendations userId={user.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,172 +3,203 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
||||
recommendationText: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: "pending" | "completed" | "approved" | "rejected";
|
||||
createdAt: string;
|
||||
id: string;
|
||||
userId: string;
|
||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
||||
recommendationText: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: "pending" | "completed" | "approved" | "rejected";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface RecommendationsProps {
|
||||
userId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function Recommendations({ userId }: RecommendationsProps) {
|
||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newRec, setNewRec] = useState<{
|
||||
type: "short_term" | "medium_term" | "long_term";
|
||||
content: string;
|
||||
}>({ type: "short_term", content: "" });
|
||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newRec, setNewRec] = useState<{
|
||||
type: "short_term" | "medium_term" | "long_term";
|
||||
content: string;
|
||||
}>({ type: "short_term", content: "" });
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
fetchRecommendations();
|
||||
}, [userId]);
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/recommendations?userId=${userId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRecommendations(data);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch recommendations", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRecommendation = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch("/api/recommendations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
type: newRec.type,
|
||||
content: newRec.content,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNewRec({ ...newRec, content: "" });
|
||||
fetchRecommendations();
|
||||
}, [userId]);
|
||||
} else {
|
||||
alert("Failed to add recommendation");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to add recommendation", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/recommendations?userId=${userId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRecommendations(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch recommendations:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const groupedRecs = {
|
||||
ai_plan: recommendations.filter((r) => r.type === "ai_plan"),
|
||||
short_term: recommendations.filter((r) => r.type === "short_term"),
|
||||
medium_term: recommendations.filter((r) => r.type === "medium_term"),
|
||||
long_term: recommendations.filter((r) => r.type === "long_term"),
|
||||
};
|
||||
|
||||
const handleAddRecommendation = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch("/api/recommendations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
type: newRec.type,
|
||||
content: newRec.content,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNewRec({ ...newRec, content: "" });
|
||||
fetchRecommendations();
|
||||
} else {
|
||||
alert("Failed to add recommendation");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const groupedRecs = {
|
||||
ai_plan: recommendations.filter((r) => r.type === "ai_plan"),
|
||||
short_term: recommendations.filter((r) => r.type === "short_term"),
|
||||
medium_term: recommendations.filter((r) => r.type === "medium_term"),
|
||||
long_term: recommendations.filter((r) => r.type === "long_term"),
|
||||
};
|
||||
|
||||
const renderSection = (
|
||||
title: string,
|
||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan",
|
||||
items: Recommendation[]
|
||||
) => (
|
||||
<div className="mb-6">
|
||||
<h4 className="font-semibold text-lg mb-3 capitalize">{title}</h4>
|
||||
<div className="space-y-2">
|
||||
{items.length === 0 && (
|
||||
<p className="text-gray-500 text-sm italic">No recommendations yet.</p>
|
||||
)}
|
||||
{items.map((rec) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className={`p-3 rounded border flex justify-between items-start ${rec.status === "completed" || rec.status === "approved"
|
||||
? "bg-green-50 border-green-200"
|
||||
: "bg-white border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="w-full">
|
||||
<p className="text-gray-700">{rec.recommendationText}</p>
|
||||
{rec.type === 'ai_plan' && (
|
||||
<div className="mt-2 text-xs text-gray-600 space-y-1">
|
||||
{rec.activityPlan && <p><span className="font-semibold">Activity:</span> {rec.activityPlan}</p>}
|
||||
{rec.dietPlan && <p><span className="font-semibold">Diet:</span> {rec.dietPlan}</p>}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
{new Date(rec.createdAt).toLocaleDateString()} -{" "}
|
||||
<span
|
||||
className={
|
||||
rec.status === "completed" || rec.status === "approved"
|
||||
? "text-green-600 font-medium"
|
||||
: "text-yellow-600"
|
||||
}
|
||||
>
|
||||
{rec.status === "completed" ? "Completed" : rec.status === "approved" ? "Approved" : "Pending"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{type !== 'ai_plan' && (
|
||||
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
|
||||
<input
|
||||
type="hidden"
|
||||
value={type}
|
||||
onChange={() => setNewRec({ ...newRec, type: type as any })}
|
||||
/>
|
||||
{newRec.type === type && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Add ${title.toLowerCase()}...`}
|
||||
className="flex-1 border rounded px-3 py-1 text-sm"
|
||||
value={newRec.content}
|
||||
onChange={(e) =>
|
||||
setNewRec({ ...newRec, content: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="secondary">
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{newRec.type !== type && (
|
||||
<Button type="button" variant="secondary" onClick={() => setNewRec({ type: type as any, content: "" })} className="text-xs text-gray-500">
|
||||
+ Add New
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) return <div>Loading recommendations...</div>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)}
|
||||
{renderSection("Short Term Goals", "short_term", groupedRecs.short_term)}
|
||||
{renderSection("Medium Term Goals", "medium_term", groupedRecs.medium_term)}
|
||||
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}
|
||||
const renderSection = (
|
||||
title: string,
|
||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan",
|
||||
items: Recommendation[],
|
||||
) => (
|
||||
<div className="mb-6">
|
||||
<h4 className="font-semibold text-lg mb-3 capitalize">{title}</h4>
|
||||
<div className="space-y-2">
|
||||
{items.length === 0 && (
|
||||
<p className="text-gray-500 text-sm italic">
|
||||
No recommendations yet.
|
||||
</p>
|
||||
)}
|
||||
{items.map((rec) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className={`p-3 rounded border flex justify-between items-start ${
|
||||
rec.status === "completed" || rec.status === "approved"
|
||||
? "bg-green-50 border-green-200"
|
||||
: "bg-white border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="w-full">
|
||||
<p className="text-gray-700">{rec.recommendationText}</p>
|
||||
{rec.type === "ai_plan" && (
|
||||
<div className="mt-2 text-xs text-gray-600 space-y-1">
|
||||
{rec.activityPlan && (
|
||||
<p>
|
||||
<span className="font-semibold">Activity:</span>{" "}
|
||||
{rec.activityPlan}
|
||||
</p>
|
||||
)}
|
||||
{rec.dietPlan && (
|
||||
<p>
|
||||
<span className="font-semibold">Diet:</span>{" "}
|
||||
{rec.dietPlan}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
{new Date(rec.createdAt).toLocaleDateString()} -{" "}
|
||||
<span
|
||||
className={
|
||||
rec.status === "completed" || rec.status === "approved"
|
||||
? "text-green-600 font-medium"
|
||||
: "text-yellow-600"
|
||||
}
|
||||
>
|
||||
{rec.status === "completed"
|
||||
? "Completed"
|
||||
: rec.status === "approved"
|
||||
? "Approved"
|
||||
: "Pending"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{type !== "ai_plan" && (
|
||||
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
|
||||
<input
|
||||
type="hidden"
|
||||
value={type}
|
||||
onChange={() => setNewRec({ ...newRec, type: type as any })}
|
||||
/>
|
||||
{newRec.type === type && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Add ${title.toLowerCase()}...`}
|
||||
className="flex-1 border rounded px-3 py-1 text-sm"
|
||||
value={newRec.content}
|
||||
onChange={(e) =>
|
||||
setNewRec({ ...newRec, content: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="secondary">
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{newRec.type !== type && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setNewRec({ type: type as any, content: "" })}
|
||||
className="text-xs text-gray-500"
|
||||
>
|
||||
+ Add New
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) return <div>Loading recommendations...</div>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)}
|
||||
{renderSection(
|
||||
"Short Term Goals",
|
||||
"short_term",
|
||||
groupedRecs.short_term,
|
||||
)}
|
||||
{renderSection(
|
||||
"Medium Term Goals",
|
||||
"medium_term",
|
||||
groupedRecs.medium_term,
|
||||
)}
|
||||
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { UserGrid } from "@/components/users/UserGrid";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import { getGymIdFromUser } from "@/lib/error-helpers";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -83,30 +85,27 @@ export function UserManagement() {
|
||||
? `/api/users?ts=${ts}`
|
||||
: `/api/users?role=${filter}&ts=${ts}`;
|
||||
|
||||
console.log("UserManagement.fetchUsers: fetching URL", url);
|
||||
log.debug("Fetching users", { url });
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
console.log(
|
||||
"UserManagement.fetchUsers: response.ok",
|
||||
response.ok,
|
||||
"status",
|
||||
response.status,
|
||||
);
|
||||
log.debug("Users fetch response", {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log(
|
||||
"UserManagement.fetchUsers: received users count",
|
||||
Array.isArray(data.users) ? data.users.length : 0,
|
||||
"sample",
|
||||
data.users && data.users[0]
|
||||
? {
|
||||
id: data.users[0].id,
|
||||
gymId: data.users[0].gymId,
|
||||
role: data.users[0].role,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
log.debug("Received users data", {
|
||||
count: Array.isArray(data.users) ? data.users.length : 0,
|
||||
sample:
|
||||
data.users && data.users[0]
|
||||
? {
|
||||
id: data.users[0].id,
|
||||
gymId: data.users[0].gymId,
|
||||
role: data.users[0].role,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
setUsers(data.users || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
log.error("Failed to fetch users", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -152,7 +151,7 @@ export function UserManagement() {
|
||||
alert("Error deleting users");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
log.error("Failed to delete users", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -201,37 +200,25 @@ export function UserManagement() {
|
||||
try {
|
||||
if (selectedUser) {
|
||||
// Update existing user
|
||||
console.log(
|
||||
"UserManagement.handleSaveEdit: sending PUT /api/users payload",
|
||||
{
|
||||
id: selectedUser.id,
|
||||
email: editForm.email,
|
||||
firstName: editForm.firstName,
|
||||
lastName: editForm.lastName,
|
||||
role: editForm.role,
|
||||
phone: editForm.phone,
|
||||
gymId: editForm.gymId === "" ? null : editForm.gymId,
|
||||
},
|
||||
);
|
||||
const payload = {
|
||||
id: selectedUser.id,
|
||||
email: editForm.email,
|
||||
firstName: editForm.firstName,
|
||||
lastName: editForm.lastName,
|
||||
role: editForm.role,
|
||||
phone: editForm.phone,
|
||||
gymId: editForm.gymId === "" ? null : editForm.gymId,
|
||||
};
|
||||
log.debug("Updating user", payload);
|
||||
const response = await fetch("/api/users", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: selectedUser.id,
|
||||
email: editForm.email,
|
||||
firstName: editForm.firstName,
|
||||
lastName: editForm.lastName,
|
||||
role: editForm.role,
|
||||
phone: editForm.phone,
|
||||
gymId: editForm.gymId === "" ? null : editForm.gymId,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
log.debug("User update response", {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
});
|
||||
console.log(
|
||||
"UserManagement.handleSaveEdit: PUT /api/users response.ok",
|
||||
response.ok,
|
||||
"status",
|
||||
response.status,
|
||||
);
|
||||
if (response.ok) {
|
||||
// Optimistically update local state so grid reflects changes immediately
|
||||
setUsers((prev) =>
|
||||
@@ -265,15 +252,12 @@ export function UserManagement() {
|
||||
setIsEditing(false);
|
||||
setEditForm(null);
|
||||
// Still re-fetch from server to ensure consistency
|
||||
console.log(
|
||||
"UserManagement.handleSaveEdit: re-fetching users after successful edit",
|
||||
);
|
||||
log.debug("Re-fetching users after successful edit");
|
||||
fetchUsers();
|
||||
} else {
|
||||
const errText = await response.text().catch(() => "");
|
||||
console.error("UserManagement.handleSaveEdit: update failed", {
|
||||
log.error("User update failed", new Error(errText), {
|
||||
status: response.status,
|
||||
body: errText,
|
||||
});
|
||||
alert("Error updating user");
|
||||
}
|
||||
@@ -320,7 +304,7 @@ export function UserManagement() {
|
||||
alert("Error deleting user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
log.error("Failed to delete user", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -351,7 +335,7 @@ export function UserManagement() {
|
||||
email: "",
|
||||
role: "client",
|
||||
phone: "",
|
||||
gymId: String((user?.publicMetadata as any)?.gymId ?? ""),
|
||||
gymId: user ? getGymIdFromUser(user) : "",
|
||||
});
|
||||
setSelectedUser(null);
|
||||
setIsEditing(true);
|
||||
|
||||
@@ -1,42 +1,52 @@
|
||||
import type { AIContext } from './ai-context';
|
||||
import { formatGoalForPrompt } from './ai-context';
|
||||
import type { AIContext } from "./ai-context";
|
||||
import type { FitnessProfile } from "../database/types";
|
||||
import { formatGoalForPrompt } from "./ai-context";
|
||||
|
||||
/**
|
||||
* Build enhanced AI prompt with comprehensive user context
|
||||
* Includes profile, goals, progress, and recommendation history
|
||||
*/
|
||||
export function buildEnhancedPrompt(context: AIContext): string {
|
||||
const { profile, activeGoals, completedGoals, recentRecommendations, progressSummary } = context;
|
||||
const {
|
||||
profile,
|
||||
activeGoals,
|
||||
completedGoals,
|
||||
recentRecommendations,
|
||||
progressSummary,
|
||||
} = context;
|
||||
|
||||
// Build goals section
|
||||
const activeGoalsText =
|
||||
activeGoals.length > 0
|
||||
? activeGoals.map((g) => `- ${formatGoalForPrompt(g)}`).join('\n')
|
||||
: '- No active goals set';
|
||||
// Build goals section
|
||||
const activeGoalsText =
|
||||
activeGoals.length > 0
|
||||
? activeGoals.map((g) => `- ${formatGoalForPrompt(g)}`).join("\n")
|
||||
: "- No active goals set";
|
||||
|
||||
const completedGoalsText =
|
||||
completedGoals.length > 0
|
||||
? completedGoals.slice(0, 3).map((g) => g.title).join(', ')
|
||||
: 'None yet';
|
||||
const completedGoalsText =
|
||||
completedGoals.length > 0
|
||||
? completedGoals
|
||||
.slice(0, 3)
|
||||
.map((g) => g.title)
|
||||
.join(", ")
|
||||
: "None yet";
|
||||
|
||||
// Build context about recent recommendations
|
||||
const recommendationContextText =
|
||||
recentRecommendations.length > 0
|
||||
? `The user has received ${recentRecommendations.length} recommendations in the past week. Consider their progress and avoid repetitive advice.`
|
||||
: 'This is a new user or they haven\'t received recent recommendations. Provide comprehensive guidance.';
|
||||
// Build context about recent recommendations
|
||||
const recommendationContextText =
|
||||
recentRecommendations.length > 0
|
||||
? `The user has received ${recentRecommendations.length} recommendations in the past week. Consider their progress and avoid repetitive advice.`
|
||||
: "This is a new user or they haven't received recent recommendations. Provide comprehensive guidance.";
|
||||
|
||||
return `You are a professional fitness trainer and nutritionist with access to the user's complete fitness journey.
|
||||
return `You are a professional fitness trainer and nutritionist with access to the user's complete fitness journey.
|
||||
|
||||
## User Profile
|
||||
- Height: ${profile.height || 'Not specified'} cm
|
||||
- Weight: ${profile.weight || 'Not specified'} kg
|
||||
- Age: ${profile.age || 'Not specified'}
|
||||
- Gender: ${profile.gender || 'Not specified'}
|
||||
- Primary Goal: ${profile.fitnessGoals?.[0] || 'General fitness'}
|
||||
- Activity Level: ${profile.activityLevel || 'Not specified'}
|
||||
- Medical Conditions: ${profile.medicalConditions || 'None'}
|
||||
- Allergies: ${profile.allergies || 'None'}
|
||||
- Injuries: ${profile.injuries || 'None'}
|
||||
- Height: ${profile.height || "Not specified"} cm
|
||||
- Weight: ${profile.weight || "Not specified"} kg
|
||||
- Age: ${profile.age || "Not specified"}
|
||||
- Gender: ${profile.gender || "Not specified"}
|
||||
- Primary Goal: ${profile.fitnessGoals?.[0] || "General fitness"}
|
||||
- Activity Level: ${profile.activityLevel || "Not specified"}
|
||||
- Medical Conditions: ${profile.medicalConditions || "None"}
|
||||
- Allergies: ${profile.allergies || "None"}
|
||||
- Injuries: ${profile.injuries || "None"}
|
||||
|
||||
## Active Goals (${activeGoals.length})
|
||||
${activeGoalsText}
|
||||
@@ -45,7 +55,7 @@ ${activeGoalsText}
|
||||
- Goals Completed: ${progressSummary.goalsCompleted}
|
||||
- Active Goals: ${progressSummary.goalsActive}
|
||||
- Average Progress on Active Goals: ${progressSummary.averageProgress.toFixed(1)}%
|
||||
${completedGoals.length > 0 ? `- Recently Completed: ${completedGoalsText}` : ''}
|
||||
${completedGoals.length > 0 ? `- Recently Completed: ${completedGoalsText}` : ""}
|
||||
|
||||
## Recommendation History
|
||||
${recommendationContextText}
|
||||
@@ -70,8 +80,8 @@ Respond in the following JSON format ONLY, no other text. Do not use markdown fo
|
||||
* Build basic prompt for users without complete context
|
||||
* Fallback when goals or other data is missing
|
||||
*/
|
||||
export function buildBasicPrompt(profile: any): string {
|
||||
return `You are a professional fitness trainer and nutritionist.
|
||||
export function buildBasicPrompt(profile: FitnessProfile): string {
|
||||
return `You are a professional fitness trainer and nutritionist.
|
||||
Generate a detailed daily recommendation for a user with the following profile:
|
||||
- Height: ${profile.height} cm
|
||||
- Weight: ${profile.weight} kg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { auth, currentUser } from "@clerk/nextjs/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import log from "./logger";
|
||||
|
||||
/**
|
||||
* Get authenticated user ID from request
|
||||
@@ -15,19 +16,21 @@ export async function getAuthUserId(req: NextRequest): Promise<string | null> {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (userId) {
|
||||
console.log("✓ Authenticated user:", userId);
|
||||
log.debug("Authenticated user", { userId });
|
||||
return userId;
|
||||
}
|
||||
|
||||
console.log("✗ No authentication found");
|
||||
log.debug("No authentication found");
|
||||
|
||||
// Log headers for debugging
|
||||
const authHeader = req.headers.get("authorization");
|
||||
console.log("Authorization header:", authHeader ? "Present" : "Missing");
|
||||
log.debug("Authorization header check", {
|
||||
present: !!authHeader,
|
||||
});
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Authentication error:", error);
|
||||
log.error("Authentication error", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { USER_ROLES } from "@fitai/shared";
|
||||
|
||||
export type UserRole = (typeof USER_ROLES)[number];
|
||||
|
||||
interface AuthResult {
|
||||
userId: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require authentication and optionally check user role
|
||||
*
|
||||
* @param allowedRoles - Array of roles allowed to access the endpoint (optional)
|
||||
* @returns Authentication result with userId and role, or NextResponse error
|
||||
*
|
||||
* @example
|
||||
* export async function DELETE(request: NextRequest) {
|
||||
* const authResult = await requireAuth(["admin", "superAdmin"]);
|
||||
* if (authResult instanceof NextResponse) return authResult;
|
||||
* const { userId, role } = authResult;
|
||||
* // ... proceed with authorized logic
|
||||
* }
|
||||
*/
|
||||
export async function requireAuth(
|
||||
allowedRoles?: UserRole[],
|
||||
): Promise<AuthResult | NextResponse> {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized - Authentication required" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get user role from Clerk metadata
|
||||
const { sessionClaims } = await auth();
|
||||
const role = (sessionClaims?.metadata as { role?: UserRole })?.role;
|
||||
|
||||
if (!role) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden - User role not found" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has required role
|
||||
if (allowedRoles && allowedRoles.length > 0) {
|
||||
if (!allowedRoles.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Forbidden - Requires one of: ${allowedRoles.join(", ")}`,
|
||||
requiredRoles: allowedRoles,
|
||||
userRole: role,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { userId, role };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the authenticated user is trying to modify their own account
|
||||
* Useful for preventing self-deletion or self-demotion
|
||||
*
|
||||
* @param userId - Authenticated user's ID
|
||||
* @param targetUserId - ID of user being modified
|
||||
* @returns true if user is modifying themselves
|
||||
*/
|
||||
export function isSelfModification(
|
||||
userId: string,
|
||||
targetUserId: string,
|
||||
): boolean {
|
||||
return userId === targetUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an admin isn't demoting themselves or deleting their own account
|
||||
*
|
||||
* @param userId - Authenticated user's ID
|
||||
* @param targetUserId - ID of user being modified
|
||||
* @param action - Action being performed (e.g., "delete", "demote")
|
||||
* @returns NextResponse error if invalid, undefined if valid
|
||||
*/
|
||||
export function preventSelfModification(
|
||||
userId: string,
|
||||
targetUserId: string,
|
||||
action: string,
|
||||
): NextResponse | undefined {
|
||||
if (isSelfModification(userId, targetUserId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Cannot ${action} your own account`,
|
||||
hint: "Ask another administrator to perform this action",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { clerkClient } from '@clerk/nextjs/server';
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import log from "./logger";
|
||||
|
||||
/**
|
||||
* User roles available in the application
|
||||
*/
|
||||
export type UserRole = 'admin' | 'trainer' | 'client';
|
||||
export type UserRole = "admin" | "trainer" | "client";
|
||||
|
||||
/**
|
||||
* Set a user's role in Clerk public metadata
|
||||
@@ -52,7 +53,10 @@ export async function getUserRole(userId: string): Promise<UserRole | null> {
|
||||
* @example
|
||||
* const isAdmin = await hasRole('user_abc123', 'admin');
|
||||
*/
|
||||
export async function hasRole(userId: string, role: UserRole): Promise<boolean> {
|
||||
export async function hasRole(
|
||||
userId: string,
|
||||
role: UserRole,
|
||||
): Promise<boolean> {
|
||||
const userRole = await getUserRole(userId);
|
||||
return userRole === role;
|
||||
}
|
||||
@@ -67,7 +71,7 @@ export async function hasRole(userId: string, role: UserRole): Promise<boolean>
|
||||
* const isAdmin = await isAdmin('user_abc123');
|
||||
*/
|
||||
export async function isAdmin(userId: string): Promise<boolean> {
|
||||
return hasRole(userId, 'admin');
|
||||
return hasRole(userId, "admin");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +84,7 @@ export async function isAdmin(userId: string): Promise<boolean> {
|
||||
* const isTrainer = await isTrainer('user_abc123');
|
||||
*/
|
||||
export async function isTrainer(userId: string): Promise<boolean> {
|
||||
return hasRole(userId, 'trainer');
|
||||
return hasRole(userId, "trainer");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +97,7 @@ export async function isTrainer(userId: string): Promise<boolean> {
|
||||
* const isClient = await isClient('user_abc123');
|
||||
*/
|
||||
export async function isClient(userId: string): Promise<boolean> {
|
||||
return hasRole(userId, 'client');
|
||||
return hasRole(userId, "client");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +113,7 @@ export async function isClient(userId: string): Promise<boolean> {
|
||||
* ]);
|
||||
*/
|
||||
export async function bulkSetUserRoles(
|
||||
userRoles: Array<{ userId: string; role: UserRole }>
|
||||
userRoles: Array<{ userId: string; role: UserRole }>,
|
||||
): Promise<void> {
|
||||
const client = await clerkClient();
|
||||
|
||||
@@ -117,8 +121,8 @@ export async function bulkSetUserRoles(
|
||||
userRoles.map(({ userId, role }) =>
|
||||
client.users.updateUser(userId, {
|
||||
publicMetadata: { role },
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +143,7 @@ export async function getUsersByRole(role: UserRole) {
|
||||
const { data: users } = await client.users.getUserList();
|
||||
|
||||
return users.filter(
|
||||
(user) => (user.publicMetadata?.role as UserRole) === role
|
||||
(user) => (user.publicMetadata?.role as UserRole) === role,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,7 +167,7 @@ export async function getUserCountByRole(): Promise<Record<UserRole, number>> {
|
||||
};
|
||||
|
||||
users.forEach((user) => {
|
||||
const role = (user.publicMetadata?.role as UserRole) || 'client';
|
||||
const role = (user.publicMetadata?.role as UserRole) || "client";
|
||||
counts[role]++;
|
||||
});
|
||||
|
||||
@@ -192,7 +196,7 @@ export async function syncUserRole(userId: string): Promise<boolean> {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error syncing user role:', error);
|
||||
log.error("Failed to sync user role", error, { userId });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,114 @@ import {
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
asc,
|
||||
sql,
|
||||
} from "@fitai/database";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { SQL, or, like, inArray, gte, lte } from "drizzle-orm";
|
||||
import type { SortConfig, FilterCondition } from "../filtering";
|
||||
import { buildWhereConditions, buildSearchCondition } from "../filtering";
|
||||
import log from "../logger";
|
||||
|
||||
// Database row types (before mapping to domain types)
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password: string | null;
|
||||
phone: string | null;
|
||||
role: string;
|
||||
gymId?: string | null;
|
||||
gym_id?: string | null; // Alternative column name
|
||||
createdAt: number | Date;
|
||||
updatedAt: number | Date;
|
||||
}
|
||||
|
||||
interface ClientRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
membershipType: string;
|
||||
membershipStatus: string;
|
||||
joinDate: number | Date;
|
||||
lastVisit?: number | Date | null;
|
||||
emergencyContactName?: string | null;
|
||||
emergencyContactPhone?: string | null;
|
||||
emergencyContactRelationship?: string | null;
|
||||
createdAt: number | Date;
|
||||
updatedAt: number | Date;
|
||||
}
|
||||
|
||||
interface FitnessProfileRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
height?: number | null;
|
||||
weight?: number | null;
|
||||
age?: number | null;
|
||||
goals?: string | null;
|
||||
medicalConditions?: string | null;
|
||||
dietaryRestrictions?: string | null;
|
||||
activityLevel?: string | null;
|
||||
createdAt: number | Date;
|
||||
updatedAt: number | Date;
|
||||
}
|
||||
|
||||
interface AttendanceRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
checkInTime: number | Date;
|
||||
checkOutTime?: number | Date | null;
|
||||
notes?: string | null;
|
||||
createdAt: number | Date;
|
||||
}
|
||||
|
||||
interface RecommendationRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId: string;
|
||||
recommendationText: string;
|
||||
activityPlan: string;
|
||||
dietPlan: string;
|
||||
status: string;
|
||||
generatedAt: number | Date;
|
||||
approvedBy?: string | null;
|
||||
approvedAt?: number | Date | null;
|
||||
createdAt: number | Date;
|
||||
updatedAt: number | Date;
|
||||
}
|
||||
|
||||
interface FitnessGoalRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string | null;
|
||||
goalType: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
targetValue?: number | null;
|
||||
currentValue?: number | null;
|
||||
unit?: string | null;
|
||||
status: string;
|
||||
progress: number | null;
|
||||
priority: string;
|
||||
startDate: number | Date;
|
||||
targetDate?: number | Date | null;
|
||||
completedDate?: number | Date | null;
|
||||
notes?: string | null;
|
||||
createdAt: number | Date;
|
||||
updatedAt: number | Date;
|
||||
}
|
||||
|
||||
// Type for PRAGMA table_info result
|
||||
interface TableInfoRow {
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
}
|
||||
|
||||
// Type for raw SQL results with unknown structure
|
||||
type RawSQLRow = Record<string, unknown>;
|
||||
|
||||
export class DrizzleDatabase implements IDatabase {
|
||||
private config: DatabaseConfig;
|
||||
@@ -36,7 +141,7 @@ export class DrizzleDatabase implements IDatabase {
|
||||
// Drizzle with better-sqlite3 connects synchronously on initialization
|
||||
// We can just log here if needed
|
||||
if (this.config.options?.logging) {
|
||||
console.log("Drizzle database connected");
|
||||
log.info("Drizzle database connected");
|
||||
}
|
||||
await this.createTables();
|
||||
}
|
||||
@@ -46,7 +151,7 @@ export class DrizzleDatabase implements IDatabase {
|
||||
// but we can close the underlying sqlite instance if we had access to it.
|
||||
// For now, we'll assume it's handled.
|
||||
if (this.config.options?.logging) {
|
||||
console.log("Drizzle database disconnected");
|
||||
log.info("Drizzle database disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +349,268 @@ export class DrizzleDatabase implements IDatabase {
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users with pagination, sorting, and filtering
|
||||
* Uses SQL LIMIT/OFFSET for efficient pagination
|
||||
*/
|
||||
async getUsersWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
role?: string;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ users: User[]; total: number }> {
|
||||
const { page, limit, role, sort, filters, search } = params;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build WHERE conditions
|
||||
const whereConditions: any[] = [];
|
||||
|
||||
// Add role filter if provided
|
||||
if (role) {
|
||||
whereConditions.push(eq(users.role, role as User["role"]));
|
||||
}
|
||||
|
||||
// Add filter conditions from query params
|
||||
if (filters && filters.length > 0) {
|
||||
const columnMap = {
|
||||
role: users.role,
|
||||
email: users.email,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
phone: users.phone,
|
||||
gymId: users.gymId,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
};
|
||||
const filterCondition = buildWhereConditions(filters, columnMap);
|
||||
if (filterCondition) {
|
||||
whereConditions.push(filterCondition);
|
||||
}
|
||||
}
|
||||
|
||||
// Add search condition
|
||||
if (search) {
|
||||
const searchFields = ["email", "firstName", "lastName", "phone"];
|
||||
const searchCondition = buildSearchCondition(search, searchFields, {
|
||||
email: users.email,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
phone: users.phone,
|
||||
});
|
||||
if (searchCondition) {
|
||||
whereConditions.push(searchCondition);
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? and(...whereConditions) : undefined;
|
||||
|
||||
// Build ORDER BY clause
|
||||
const sortColumn = sort?.field
|
||||
? {
|
||||
email: users.email,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
}[sort.field]
|
||||
: users.createdAt;
|
||||
|
||||
const orderBy =
|
||||
sort?.direction === "asc"
|
||||
? asc(sortColumn || users.createdAt)
|
||||
: desc(sortColumn || users.createdAt);
|
||||
|
||||
// Get total count (without pagination)
|
||||
const countQuery = whereClause
|
||||
? this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(whereClause)
|
||||
: this.db.select({ count: sql<number>`count(*)` }).from(users);
|
||||
|
||||
const countResult = await countQuery.get();
|
||||
const total = countResult?.count ?? 0;
|
||||
|
||||
// Get paginated results
|
||||
const query = this.db
|
||||
.select()
|
||||
.from(users)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const results = whereClause
|
||||
? await query.where(whereClause).all()
|
||||
: await query.all();
|
||||
|
||||
return {
|
||||
users: results.map(this.mapUser),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users with their client and attendance data in optimized batches
|
||||
* Avoids N+1 query problem by batching related data queries
|
||||
* Now supports sorting, filtering, and search
|
||||
*/
|
||||
async getUsersWithRelatedData(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
role?: string;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{
|
||||
users: Array<
|
||||
User & {
|
||||
client?: Client | null;
|
||||
isCheckedIn?: boolean;
|
||||
checkInTime?: Date | null;
|
||||
lastCheckInTime?: Date | null;
|
||||
checkInsThisWeek?: number;
|
||||
checkInsThisMonth?: number;
|
||||
}
|
||||
>;
|
||||
total?: number;
|
||||
}> {
|
||||
const page = params?.page ?? 1;
|
||||
const limit = params?.limit ?? 1000;
|
||||
const role = params?.role;
|
||||
const sort = params?.sort;
|
||||
const filters = params?.filters;
|
||||
const search = params?.search;
|
||||
|
||||
// Step 1: Get paginated users with sorting and filtering
|
||||
const { users: paginatedUsers, total } = await this.getUsersWithPagination({
|
||||
page,
|
||||
limit,
|
||||
role,
|
||||
sort,
|
||||
filters,
|
||||
search,
|
||||
});
|
||||
|
||||
if (paginatedUsers.length === 0) {
|
||||
return { users: [], total: 0 };
|
||||
}
|
||||
|
||||
const userIds = paginatedUsers.map((u) => u.id);
|
||||
|
||||
// Step 2: Batch fetch clients for all users (1 query instead of N)
|
||||
const allClients = await this.db.select().from(clients).all();
|
||||
const clientsByUserId = new Map(
|
||||
allClients.map((c) => [c.userId, this.mapClient(c)]),
|
||||
);
|
||||
|
||||
// Step 3: Batch fetch attendance stats (2 queries instead of 2N)
|
||||
const attendanceStats = await this.getAttendanceStatsBatch(userIds);
|
||||
|
||||
// Step 4: Combine all data
|
||||
const usersWithData = paginatedUsers.map((user) => {
|
||||
const client = clientsByUserId.get(user.id) || null;
|
||||
const stats = attendanceStats.get(user.id) || {
|
||||
isCheckedIn: false,
|
||||
checkInTime: null,
|
||||
lastCheckInTime: null,
|
||||
checkInsThisWeek: 0,
|
||||
checkInsThisMonth: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
...user,
|
||||
client,
|
||||
...stats,
|
||||
};
|
||||
});
|
||||
|
||||
return { users: usersWithData, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attendance statistics for multiple users efficiently
|
||||
* Uses aggregation queries to avoid N+1 problem
|
||||
*/
|
||||
async getAttendanceStatsBatch(userIds: string[]): Promise<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
isCheckedIn: boolean;
|
||||
checkInTime: Date | null;
|
||||
lastCheckInTime: Date | null;
|
||||
checkInsThisWeek: number;
|
||||
checkInsThisMonth: number;
|
||||
}
|
||||
>
|
||||
> {
|
||||
if (userIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Fetch ALL attendance records for these users (2 queries total instead of 2N)
|
||||
const allAttendance = await this.db.select().from(attendance).all();
|
||||
|
||||
// Filter to relevant users
|
||||
const relevantAttendance = allAttendance.filter((a) =>
|
||||
userIds.includes(a.userId),
|
||||
);
|
||||
|
||||
const statsMap = new Map();
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const userId of userIds) {
|
||||
const userAttendance = relevantAttendance.filter(
|
||||
(a) => a.userId === userId,
|
||||
);
|
||||
|
||||
// Find active check-in
|
||||
const activeCheckIn = userAttendance.find((a) => !a.checkOutTime);
|
||||
|
||||
// Get last check-in time
|
||||
const sortedAttendance = userAttendance.sort(
|
||||
(a, b) =>
|
||||
new Date(b.checkInTime).getTime() - new Date(a.checkInTime).getTime(),
|
||||
);
|
||||
const lastCheckInTime = sortedAttendance[0]?.checkInTime || null;
|
||||
|
||||
// Count check-ins
|
||||
const checkInsThisWeek = userAttendance.filter(
|
||||
(a) => new Date(a.checkInTime) >= weekAgo,
|
||||
).length;
|
||||
const checkInsThisMonth = userAttendance.filter(
|
||||
(a) => new Date(a.checkInTime) >= monthAgo,
|
||||
).length;
|
||||
|
||||
statsMap.set(userId, {
|
||||
isCheckedIn: !!activeCheckIn,
|
||||
checkInTime: activeCheckIn?.checkInTime || null,
|
||||
lastCheckInTime,
|
||||
checkInsThisWeek,
|
||||
checkInsThisMonth,
|
||||
});
|
||||
}
|
||||
|
||||
// Fill in missing users with default values
|
||||
for (const userId of userIds) {
|
||||
if (!statsMap.has(userId)) {
|
||||
statsMap.set(userId, {
|
||||
isCheckedIn: false,
|
||||
checkInTime: null,
|
||||
lastCheckInTime: null,
|
||||
checkInsThisWeek: 0,
|
||||
checkInsThisMonth: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return statsMap;
|
||||
}
|
||||
|
||||
// Client operations
|
||||
async createClient(clientData: Omit<Client, "id">): Promise<Client> {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
@@ -285,6 +652,101 @@ export class DrizzleDatabase implements IDatabase {
|
||||
return results.map(this.mapClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clients with pagination, sorting, and filtering
|
||||
*/
|
||||
async getClientsWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ clients: Client[]; total: number }> {
|
||||
const { page, limit, sort, filters, search } = params;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build WHERE conditions
|
||||
const whereConditions: any[] = [];
|
||||
|
||||
// Add filter conditions
|
||||
if (filters && filters.length > 0) {
|
||||
const columnMap = {
|
||||
userId: clients.userId,
|
||||
membershipType: clients.membershipType,
|
||||
membershipStatus: clients.membershipStatus,
|
||||
joinDate: clients.joinDate,
|
||||
lastVisit: clients.lastVisit,
|
||||
};
|
||||
const filterCondition = buildWhereConditions(filters, columnMap);
|
||||
if (filterCondition) {
|
||||
whereConditions.push(filterCondition);
|
||||
}
|
||||
}
|
||||
|
||||
// Add search condition (search by user ID or emergency contact)
|
||||
if (search) {
|
||||
const searchCondition = buildSearchCondition(
|
||||
search,
|
||||
["userId", "emergencyContactName", "emergencyContactPhone"],
|
||||
{
|
||||
userId: clients.userId,
|
||||
emergencyContactName: clients.emergencyContactName,
|
||||
emergencyContactPhone: clients.emergencyContactPhone,
|
||||
},
|
||||
);
|
||||
if (searchCondition) {
|
||||
whereConditions.push(searchCondition);
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? and(...whereConditions) : undefined;
|
||||
|
||||
// Build ORDER BY clause
|
||||
const sortColumn = sort?.field
|
||||
? {
|
||||
joinDate: clients.joinDate,
|
||||
lastVisit: clients.lastVisit,
|
||||
membershipType: clients.membershipType,
|
||||
membershipStatus: clients.membershipStatus,
|
||||
userId: clients.userId,
|
||||
}[sort.field]
|
||||
: clients.joinDate;
|
||||
|
||||
const orderBy =
|
||||
sort?.direction === "asc"
|
||||
? asc(sortColumn || clients.joinDate)
|
||||
: desc(sortColumn || clients.joinDate);
|
||||
|
||||
// Get total count
|
||||
const countQuery = whereClause
|
||||
? this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(clients)
|
||||
.where(whereClause)
|
||||
: this.db.select({ count: sql<number>`count(*)` }).from(clients);
|
||||
|
||||
const countResult = await countQuery.get();
|
||||
const total = countResult?.count ?? 0;
|
||||
|
||||
// Get paginated results
|
||||
const query = this.db
|
||||
.select()
|
||||
.from(clients)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const results = whereClause
|
||||
? await query.where(whereClause).all()
|
||||
: await query.all();
|
||||
|
||||
return {
|
||||
clients: results.map(this.mapClient),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async updateClient(
|
||||
id: string,
|
||||
updates: Partial<Client>,
|
||||
@@ -444,6 +906,98 @@ export class DrizzleDatabase implements IDatabase {
|
||||
return results.map(this.mapAttendance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attendance records with pagination, sorting, and filtering
|
||||
*/
|
||||
async getAttendanceWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ attendance: Attendance[]; total: number }> {
|
||||
const { page, limit, sort, filters, search } = params;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build WHERE conditions
|
||||
const whereConditions: any[] = [];
|
||||
|
||||
// Add filter conditions
|
||||
if (filters && filters.length > 0) {
|
||||
const columnMap = {
|
||||
userId: attendance.userId,
|
||||
type: attendance.type,
|
||||
checkInTime: attendance.checkInTime,
|
||||
checkOutTime: attendance.checkOutTime,
|
||||
};
|
||||
const filterCondition = buildWhereConditions(filters, columnMap);
|
||||
if (filterCondition) {
|
||||
whereConditions.push(filterCondition);
|
||||
}
|
||||
}
|
||||
|
||||
// Add search condition (search by user ID or notes)
|
||||
if (search) {
|
||||
const searchCondition = buildSearchCondition(
|
||||
search,
|
||||
["userId", "notes"],
|
||||
{
|
||||
userId: attendance.userId,
|
||||
notes: attendance.notes,
|
||||
},
|
||||
);
|
||||
if (searchCondition) {
|
||||
whereConditions.push(searchCondition);
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? and(...whereConditions) : undefined;
|
||||
|
||||
// Build ORDER BY clause
|
||||
const sortColumn = sort?.field
|
||||
? {
|
||||
checkInTime: attendance.checkInTime,
|
||||
checkOutTime: attendance.checkOutTime,
|
||||
type: attendance.type,
|
||||
userId: attendance.userId,
|
||||
}[sort.field]
|
||||
: attendance.checkInTime;
|
||||
|
||||
const orderBy =
|
||||
sort?.direction === "asc"
|
||||
? asc(sortColumn || attendance.checkInTime)
|
||||
: desc(sortColumn || attendance.checkInTime);
|
||||
|
||||
// Get total count
|
||||
const countQuery = whereClause
|
||||
? this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(attendance)
|
||||
.where(whereClause)
|
||||
: this.db.select({ count: sql<number>`count(*)` }).from(attendance);
|
||||
|
||||
const countResult = await countQuery.get();
|
||||
const total = countResult?.count ?? 0;
|
||||
|
||||
// Get paginated results
|
||||
const query = this.db
|
||||
.select()
|
||||
.from(attendance)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const results = whereClause
|
||||
? await query.where(whereClause).all()
|
||||
: await query.all();
|
||||
|
||||
return {
|
||||
attendance: results.map(this.mapAttendance),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async getActiveCheckIn(userId: string): Promise<Attendance | null> {
|
||||
// Drizzle doesn't support IS NULL in where directly with simple syntax sometimes, but eq(col, null) works or isNull(col)
|
||||
// We need to check how to filter for null checkOutTime.
|
||||
@@ -662,59 +1216,135 @@ export class DrizzleDatabase implements IDatabase {
|
||||
};
|
||||
}
|
||||
|
||||
// Mappers
|
||||
private mapUser(row: any): User {
|
||||
// Mappers - using Record<string, unknown> for database rows since Drizzle types vary
|
||||
private mapUser(row: Record<string, unknown>): User {
|
||||
return {
|
||||
...row,
|
||||
gymId: (row as any).gymId ?? (row as any).gym_id ?? undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
id: String(row.id),
|
||||
email: String(row.email),
|
||||
firstName: String(row.firstName),
|
||||
lastName: String(row.lastName),
|
||||
password: String(row.password ?? ""),
|
||||
phone: row.phone ? String(row.phone) : undefined,
|
||||
role: String(row.role) as User["role"],
|
||||
gymId: row.gymId
|
||||
? String(row.gymId)
|
||||
: row.gym_id
|
||||
? String(row.gym_id)
|
||||
: undefined,
|
||||
createdAt: new Date(row.createdAt as number | Date),
|
||||
updatedAt: new Date(row.updatedAt as number | Date),
|
||||
};
|
||||
}
|
||||
|
||||
private mapClient(row: any): Client {
|
||||
private mapClient(row: Record<string, unknown>): Client {
|
||||
return {
|
||||
...row,
|
||||
joinDate: new Date(row.joinDate),
|
||||
lastVisit: row.lastVisit ? new Date(row.lastVisit) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapFitnessProfile(row: any): FitnessProfile {
|
||||
return {
|
||||
...row,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
private mapAttendance(row: any): Attendance {
|
||||
return {
|
||||
...row,
|
||||
checkInTime: new Date(row.checkInTime),
|
||||
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
private mapRecommendation(row: any): Recommendation {
|
||||
return {
|
||||
...row,
|
||||
createdAt: new Date(row.createdAt),
|
||||
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapFitnessGoal(row: any): FitnessGoal {
|
||||
return {
|
||||
...row,
|
||||
startDate: new Date(row.startDate),
|
||||
targetDate: row.targetDate ? new Date(row.targetDate) : undefined,
|
||||
completedDate: row.completedDate
|
||||
? new Date(row.completedDate)
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
membershipType: String(row.membershipType) as Client["membershipType"],
|
||||
membershipStatus: String(
|
||||
row.membershipStatus,
|
||||
) as Client["membershipStatus"],
|
||||
joinDate: new Date(row.joinDate as number | Date),
|
||||
lastVisit: row.lastVisit
|
||||
? new Date(row.lastVisit as number | Date)
|
||||
: undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
emergencyContact: row.emergencyContactName
|
||||
? {
|
||||
name: String(row.emergencyContactName),
|
||||
phone: String(row.emergencyContactPhone ?? ""),
|
||||
relationship: String(row.emergencyContactRelationship ?? ""),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapFitnessProfile(row: Record<string, unknown>): FitnessProfile {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
height: typeof row.height === "number" ? row.height : undefined,
|
||||
weight: typeof row.weight === "number" ? row.weight : undefined,
|
||||
age: typeof row.age === "number" ? row.age : undefined,
|
||||
gender: row.gender
|
||||
? (String(row.gender) as FitnessProfile["gender"])
|
||||
: undefined,
|
||||
fitnessGoals: row.goals ? [String(row.goals)] : undefined,
|
||||
medicalConditions: row.medicalConditions
|
||||
? String(row.medicalConditions)
|
||||
: undefined,
|
||||
allergies: row.dietaryRestrictions
|
||||
? String(row.dietaryRestrictions)
|
||||
: undefined,
|
||||
injuries: undefined,
|
||||
activityLevel: row.activityLevel
|
||||
? (String(row.activityLevel) as FitnessProfile["activityLevel"])
|
||||
: undefined,
|
||||
createdAt: new Date(row.createdAt as number | Date),
|
||||
updatedAt: new Date(row.updatedAt as number | Date),
|
||||
};
|
||||
}
|
||||
|
||||
private mapAttendance(row: Record<string, unknown>): Attendance {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
type: String(row.type) as Attendance["type"],
|
||||
checkInTime: new Date(row.checkInTime as number | Date),
|
||||
checkOutTime: row.checkOutTime
|
||||
? new Date(row.checkOutTime as number | Date)
|
||||
: undefined,
|
||||
notes: row.notes ? String(row.notes) : undefined,
|
||||
createdAt: new Date(row.createdAt as number | Date),
|
||||
};
|
||||
}
|
||||
|
||||
private mapRecommendation(row: Record<string, unknown>): Recommendation {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
fitnessProfileId: String(row.fitnessProfileId),
|
||||
recommendationText: String(row.recommendationText),
|
||||
activityPlan: String(row.activityPlan),
|
||||
dietPlan: String(row.dietPlan),
|
||||
status: String(row.status) as Recommendation["status"],
|
||||
generatedAt: new Date(row.generatedAt as number | Date),
|
||||
approvedBy: row.approvedBy ? String(row.approvedBy) : undefined,
|
||||
approvedAt: row.approvedAt
|
||||
? new Date(row.approvedAt as number | Date)
|
||||
: undefined,
|
||||
createdAt: new Date(row.createdAt as number | Date),
|
||||
updatedAt: new Date(row.updatedAt as number | Date),
|
||||
};
|
||||
}
|
||||
|
||||
private mapFitnessGoal(row: Record<string, unknown>): FitnessGoal {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
fitnessProfileId: row.fitnessProfileId
|
||||
? String(row.fitnessProfileId)
|
||||
: undefined,
|
||||
goalType: String(row.goalType) as FitnessGoal["goalType"],
|
||||
title: String(row.title),
|
||||
description: row.description ? String(row.description) : undefined,
|
||||
targetValue:
|
||||
typeof row.targetValue === "number" ? row.targetValue : undefined,
|
||||
currentValue:
|
||||
typeof row.currentValue === "number" ? row.currentValue : undefined,
|
||||
unit: row.unit ? String(row.unit) : undefined,
|
||||
status: String(row.status) as FitnessGoal["status"],
|
||||
progress: typeof row.progress === "number" ? row.progress : 0,
|
||||
priority: String(row.priority) as FitnessGoal["priority"],
|
||||
startDate: new Date(row.startDate as number | Date),
|
||||
targetDate: row.targetDate
|
||||
? new Date(row.targetDate as number | Date)
|
||||
: undefined,
|
||||
completedDate: row.completedDate
|
||||
? new Date(row.completedDate as number | Date)
|
||||
: undefined,
|
||||
notes: row.notes ? String(row.notes) : undefined,
|
||||
createdAt: new Date(row.createdAt as number | Date),
|
||||
updatedAt: new Date(row.updatedAt as number | Date),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Recommendation,
|
||||
FitnessGoal,
|
||||
} from "@fitai/shared";
|
||||
import type { SortConfig, FilterCondition } from "../filtering";
|
||||
|
||||
// Database Entity Types
|
||||
export interface User extends SharedUser {
|
||||
@@ -33,11 +34,62 @@ export interface IDatabase {
|
||||
deleteUser(id: string): Promise<boolean>;
|
||||
migrateUserId(oldId: string, newId: string): Promise<void>;
|
||||
|
||||
// Optimized query methods (Phase 3 & 4 additions)
|
||||
getUsersWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
role?: string;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ users: User[]; total: number }>;
|
||||
|
||||
getUsersWithRelatedData(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
role?: string;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{
|
||||
users: Array<
|
||||
User & {
|
||||
client?: Client | null;
|
||||
isCheckedIn?: boolean;
|
||||
checkInTime?: Date | null;
|
||||
lastCheckInTime?: Date | null;
|
||||
checkInsThisWeek?: number;
|
||||
checkInsThisMonth?: number;
|
||||
}
|
||||
>;
|
||||
total?: number;
|
||||
}>;
|
||||
|
||||
getAttendanceStatsBatch(userIds: string[]): Promise<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
isCheckedIn: boolean;
|
||||
checkInTime: Date | null;
|
||||
lastCheckInTime: Date | null;
|
||||
checkInsThisWeek: number;
|
||||
checkInsThisMonth: number;
|
||||
}
|
||||
>
|
||||
>;
|
||||
|
||||
// Client operations
|
||||
createClient(client: Omit<Client, "id">): Promise<Client>;
|
||||
getClientById(id: string): Promise<Client | null>;
|
||||
getClientByUserId(userId: string): Promise<Client | null>;
|
||||
getAllClients(): Promise<Client[]>;
|
||||
getClientsWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ clients: Client[]; total: number }>;
|
||||
updateClient(id: string, updates: Partial<Client>): Promise<Client | null>;
|
||||
deleteClient(id: string): Promise<boolean>;
|
||||
|
||||
@@ -62,6 +114,13 @@ export interface IDatabase {
|
||||
checkOut(attendanceId: string): Promise<Attendance | null>;
|
||||
getAttendanceHistory(userId: string): Promise<Attendance[]>;
|
||||
getAllAttendance(): Promise<Attendance[]>;
|
||||
getAttendanceWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ attendance: Attendance[]; total: number }>;
|
||||
getActiveCheckIn(userId: string): Promise<Attendance | null>;
|
||||
|
||||
// Recommendation operations
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Environment variable validation for admin app
|
||||
* Validates that all required environment variables are set
|
||||
*/
|
||||
|
||||
interface RequiredEnvVars {
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: string;
|
||||
CLERK_SECRET_KEY: string;
|
||||
DEEPSEEK_API_KEY: string;
|
||||
}
|
||||
|
||||
interface OptionalEnvVars {
|
||||
CLERK_WEBHOOK_SECRET?: string;
|
||||
DATABASE_PATH?: string;
|
||||
NODE_ENV?: string;
|
||||
}
|
||||
|
||||
type EnvVars = RequiredEnvVars & OptionalEnvVars;
|
||||
|
||||
/**
|
||||
* Validate required environment variables
|
||||
* Throws error if any required variable is missing
|
||||
*/
|
||||
export function validateEnv(): EnvVars {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required variables
|
||||
const requiredVars = [
|
||||
"NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY",
|
||||
"CLERK_SECRET_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
] as const;
|
||||
|
||||
for (const varName of requiredVars) {
|
||||
if (!process.env[varName]) {
|
||||
errors.push(`Missing required environment variable: ${varName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = [
|
||||
"❌ Environment validation failed:",
|
||||
...errors,
|
||||
"",
|
||||
"Please check your .env file and ensure all required variables are set.",
|
||||
"See .env.example for the list of required variables.",
|
||||
].join("\n");
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
|
||||
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!,
|
||||
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY!,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY!,
|
||||
CLERK_WEBHOOK_SECRET: process.env.CLERK_WEBHOOK_SECRET,
|
||||
DATABASE_PATH: process.env.DATABASE_PATH || "./fitai.db",
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validated environment variables
|
||||
* Caches the result after first validation
|
||||
*/
|
||||
let cachedEnv: EnvVars | null = null;
|
||||
|
||||
export function getEnv(): EnvVars {
|
||||
if (!cachedEnv) {
|
||||
cachedEnv = validateEnv();
|
||||
}
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in development mode
|
||||
*/
|
||||
export function isDevelopment(): boolean {
|
||||
return getEnv().NODE_ENV === "development";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production mode
|
||||
*/
|
||||
export function isProduction(): boolean {
|
||||
return getEnv().NODE_ENV === "production";
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Error handling utilities for type-safe error processing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clerk error structure
|
||||
*/
|
||||
interface ClerkError {
|
||||
errors?: Array<{
|
||||
message: string;
|
||||
code?: string;
|
||||
}>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if error is a Clerk error
|
||||
*/
|
||||
function isClerkError(error: unknown): error is ClerkError {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
("errors" in error || "message" in error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error type
|
||||
* Handles Clerk errors, standard Error objects, and unknown types
|
||||
*
|
||||
* @param error - The caught error
|
||||
* @param fallback - Fallback message if no message can be extracted
|
||||
* @returns Error message string
|
||||
*/
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "An unknown error occurred",
|
||||
): string {
|
||||
// Handle Clerk errors with nested error array
|
||||
if (isClerkError(error)) {
|
||||
if (error.errors?.[0]?.message) {
|
||||
return error.errors[0].message;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle standard Error objects
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// Handle string errors
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Fallback for unknown types
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if metadata has a gymId
|
||||
*/
|
||||
interface MetadataWithGymId {
|
||||
gymId: string;
|
||||
}
|
||||
|
||||
function hasGymId(metadata: unknown): metadata is MetadataWithGymId {
|
||||
return (
|
||||
typeof metadata === "object" &&
|
||||
metadata !== null &&
|
||||
"gymId" in metadata &&
|
||||
typeof (metadata as Record<string, unknown>).gymId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract gymId from Clerk public metadata
|
||||
*
|
||||
* @param user - Clerk user object
|
||||
* @returns gymId string or empty string if not found
|
||||
*/
|
||||
export function getGymIdFromUser(user: { publicMetadata?: unknown }): string {
|
||||
if (hasGymId(user.publicMetadata)) {
|
||||
return user.publicMetadata.gymId;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { SQL, and, or, eq, gte, lte, like, inArray } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Sort direction for database queries
|
||||
*/
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
/**
|
||||
* Filter operator types
|
||||
*/
|
||||
export type FilterOperator =
|
||||
| "eq" // equals
|
||||
| "ne" // not equals
|
||||
| "gt" // greater than
|
||||
| "gte" // greater than or equal
|
||||
| "lt" // less than
|
||||
| "lte" // less than or equal
|
||||
| "like" // contains (case-insensitive)
|
||||
| "in" // in array
|
||||
| "between"; // between two values
|
||||
|
||||
/**
|
||||
* Single filter condition
|
||||
*/
|
||||
export interface FilterCondition {
|
||||
field: string;
|
||||
operator: FilterOperator;
|
||||
value: string | number | boolean | string[] | number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter group with AND/OR logic
|
||||
*/
|
||||
export interface FilterGroup {
|
||||
conditions: FilterCondition[];
|
||||
logic?: "and" | "or";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort configuration
|
||||
*/
|
||||
export interface SortConfig {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse sort query parameter
|
||||
* Format: "field:asc" or "field:desc" or "-field" (shorthand for desc)
|
||||
*
|
||||
* @example
|
||||
* parseSortParam("name:asc") // { field: "name", direction: "asc" }
|
||||
* parseSortParam("-createdAt") // { field: "createdAt", direction: "desc" }
|
||||
*/
|
||||
export function parseSortParam(sort: string | null): SortConfig | null {
|
||||
if (!sort) return null;
|
||||
|
||||
// Handle shorthand: "-field" means descending
|
||||
if (sort.startsWith("-")) {
|
||||
return {
|
||||
field: sort.slice(1),
|
||||
direction: "desc",
|
||||
};
|
||||
}
|
||||
|
||||
// Handle explicit format: "field:asc" or "field:desc"
|
||||
const [field, direction] = sort.split(":");
|
||||
if (!field) return null;
|
||||
|
||||
return {
|
||||
field,
|
||||
direction: (direction as SortDirection) || "asc",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse filter query parameter
|
||||
* Format: "field:operator:value" or multiple separated by comma
|
||||
*
|
||||
* @example
|
||||
* parseFilterParam("role:eq:client") // Single filter
|
||||
* parseFilterParam("role:in:client,trainer") // Array filter
|
||||
* parseFilterParam("createdAt:gte:2024-01-01") // Date filter
|
||||
*/
|
||||
export function parseFilterParam(
|
||||
filter: string | string[] | null,
|
||||
): FilterCondition[] {
|
||||
if (!filter) return [];
|
||||
|
||||
const filters = Array.isArray(filter) ? filter : [filter];
|
||||
const conditions: FilterCondition[] = [];
|
||||
|
||||
for (const f of filters) {
|
||||
const parts = f.split(":");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const [field, operator, ...valueParts] = parts;
|
||||
const value = valueParts.join(":"); // Rejoin in case value contains ":"
|
||||
|
||||
// Handle array values (comma-separated)
|
||||
if (operator === "in") {
|
||||
conditions.push({
|
||||
field,
|
||||
operator: operator as FilterOperator,
|
||||
value: value.split(","),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle between operator (two values separated by comma)
|
||||
if (operator === "between") {
|
||||
const [start, end] = value.split(",");
|
||||
conditions.push({
|
||||
field,
|
||||
operator: operator as FilterOperator,
|
||||
value: [start, end],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle single values
|
||||
conditions.push({
|
||||
field,
|
||||
operator: operator as FilterOperator,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SQL WHERE conditions from filter conditions
|
||||
* This is a helper that works with Drizzle ORM
|
||||
*
|
||||
* @param conditions - Array of filter conditions
|
||||
* @param columnMap - Map of field names to Drizzle column objects
|
||||
* @param logic - AND or OR logic (default: AND)
|
||||
* @returns SQL condition or undefined
|
||||
*
|
||||
* @example
|
||||
* const where = buildWhereConditions(
|
||||
* [{ field: "role", operator: "eq", value: "client" }],
|
||||
* { role: users.role, email: users.email }
|
||||
* )
|
||||
*/
|
||||
export function buildWhereConditions(
|
||||
conditions: FilterCondition[],
|
||||
columnMap: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
logic: "and" | "or" = "and",
|
||||
): SQL | undefined {
|
||||
if (conditions.length === 0) return undefined;
|
||||
|
||||
const sqlConditions: SQL[] = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
const column = columnMap[condition.field];
|
||||
if (!column) continue; // Skip unknown fields
|
||||
|
||||
switch (condition.operator) {
|
||||
case "eq":
|
||||
sqlConditions.push(eq(column, condition.value as any));
|
||||
break;
|
||||
case "ne":
|
||||
// Note: Drizzle doesn't have ne(), use not(eq())
|
||||
// For now we'll skip this or implement custom
|
||||
break;
|
||||
case "gt":
|
||||
sqlConditions.push(gte(column, condition.value as any));
|
||||
break;
|
||||
case "gte":
|
||||
sqlConditions.push(gte(column, condition.value as any));
|
||||
break;
|
||||
case "lt":
|
||||
sqlConditions.push(lte(column, condition.value as any));
|
||||
break;
|
||||
case "lte":
|
||||
sqlConditions.push(lte(column, condition.value as any));
|
||||
break;
|
||||
case "like":
|
||||
sqlConditions.push(like(column, `%${condition.value}%`));
|
||||
break;
|
||||
case "in":
|
||||
if (Array.isArray(condition.value)) {
|
||||
sqlConditions.push(inArray(column, condition.value as any[]));
|
||||
}
|
||||
break;
|
||||
case "between":
|
||||
if (Array.isArray(condition.value) && condition.value.length === 2) {
|
||||
sqlConditions.push(
|
||||
and(
|
||||
gte(column, condition.value[0] as any),
|
||||
lte(column, condition.value[1] as any),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sqlConditions.length === 0) return undefined;
|
||||
|
||||
return logic === "and" ? and(...sqlConditions) : or(...sqlConditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate sort field against allowed fields
|
||||
*/
|
||||
export function validateSortField(
|
||||
field: string,
|
||||
allowedFields: string[],
|
||||
): boolean {
|
||||
return allowedFields.includes(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate filter fields against allowed fields
|
||||
*/
|
||||
export function validateFilterFields(
|
||||
conditions: FilterCondition[],
|
||||
allowedFields: string[],
|
||||
): boolean {
|
||||
return conditions.every((c) => allowedFields.includes(c.field));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse search query parameter for full-text search
|
||||
* This is a simple implementation that searches across multiple fields
|
||||
*
|
||||
* @param search - Search query string
|
||||
* @param searchFields - Fields to search in
|
||||
* @param columnMap - Map of field names to Drizzle column objects
|
||||
* @returns SQL condition or undefined
|
||||
*/
|
||||
export function buildSearchCondition(
|
||||
search: string | null,
|
||||
searchFields: string[],
|
||||
columnMap: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
): SQL | undefined {
|
||||
if (!search || !search.trim()) return undefined;
|
||||
|
||||
const searchTerm = search.trim();
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
for (const field of searchFields) {
|
||||
const column = columnMap[field];
|
||||
if (!column) continue;
|
||||
conditions.push(like(column, `%${searchTerm}%`));
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? or(...conditions) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Centralized logging utility for FitAI Admin App
|
||||
* Uses Pino for structured logging with appropriate log levels
|
||||
*/
|
||||
|
||||
import pino from "pino";
|
||||
|
||||
/**
|
||||
* Create logger instance based on environment
|
||||
* - Development: Pretty-printed logs to console
|
||||
* - Production: JSON logs for aggregation/monitoring
|
||||
*/
|
||||
const logger = pino({
|
||||
level:
|
||||
process.env.LOG_LEVEL ||
|
||||
(process.env.NODE_ENV === "production" ? "info" : "debug"),
|
||||
formatters: {
|
||||
level: (label) => {
|
||||
return { level: label };
|
||||
},
|
||||
},
|
||||
...(process.env.NODE_ENV !== "production" && {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "HH:MM:ss Z",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Log levels:
|
||||
* - debug: Detailed information for debugging (only in dev)
|
||||
* - info: General informational messages
|
||||
* - warn: Warning messages for potentially harmful situations
|
||||
* - error: Error messages for failures that don't stop the app
|
||||
* - fatal: Critical errors that may cause the app to crash
|
||||
*/
|
||||
|
||||
export { logger };
|
||||
|
||||
/**
|
||||
* Convenience functions for common logging patterns
|
||||
*/
|
||||
|
||||
export const log = {
|
||||
/**
|
||||
* Log debug information (only in development)
|
||||
*/
|
||||
debug: (message: string, data?: Record<string, unknown>) => {
|
||||
logger.debug(data || {}, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log general information
|
||||
*/
|
||||
info: (message: string, data?: Record<string, unknown>) => {
|
||||
logger.info(data || {}, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log warnings
|
||||
*/
|
||||
warn: (message: string, data?: Record<string, unknown>) => {
|
||||
logger.warn(data || {}, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log errors with optional error object
|
||||
*/
|
||||
error: (message: string, error?: unknown, data?: Record<string, unknown>) => {
|
||||
const errorData: Record<string, unknown> = {
|
||||
...(data || {}),
|
||||
};
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorData.error = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
} else if (error && typeof error === "object") {
|
||||
errorData.error = error;
|
||||
}
|
||||
|
||||
logger.error(errorData, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log fatal errors (should be rare)
|
||||
*/
|
||||
fatal: (message: string, error?: unknown, data?: Record<string, unknown>) => {
|
||||
const errorData: Record<string, unknown> = {
|
||||
...(data || {}),
|
||||
};
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorData.error = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
} else if (error && typeof error === "object") {
|
||||
errorData.error = error;
|
||||
}
|
||||
|
||||
logger.fatal(errorData, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log API requests (use in middleware or API routes)
|
||||
*/
|
||||
apiRequest: (
|
||||
method: string,
|
||||
path: string,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
logger.info({ method, path, ...(data || {}) }, "API request");
|
||||
},
|
||||
|
||||
/**
|
||||
* Log API responses
|
||||
*/
|
||||
apiResponse: (
|
||||
method: string,
|
||||
path: string,
|
||||
status: number,
|
||||
duration?: number,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
logger.info(
|
||||
{ method, path, status, duration, ...(data || {}) },
|
||||
"API response",
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log database operations
|
||||
*/
|
||||
database: (
|
||||
operation: string,
|
||||
table?: string,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
logger.debug({ operation, table, ...(data || {}) }, "Database operation");
|
||||
},
|
||||
|
||||
/**
|
||||
* Log authentication events
|
||||
*/
|
||||
auth: (event: string, userId?: string, data?: Record<string, unknown>) => {
|
||||
logger.info({ event, userId, ...(data || {}) }, "Authentication event");
|
||||
},
|
||||
};
|
||||
|
||||
export default log;
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Pagination utilities for API endpoints
|
||||
*/
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface PaginationMetadata {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: PaginationMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pagination parameters from URL search params
|
||||
*/
|
||||
export function parsePaginationParams(searchParams: URLSearchParams): {
|
||||
page: number;
|
||||
limit: number;
|
||||
sortBy?: string;
|
||||
sortOrder: "asc" | "desc";
|
||||
} {
|
||||
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 sortBy = searchParams.get("sortBy") || undefined;
|
||||
const sortOrder = (searchParams.get("sortOrder") || "desc") as "asc" | "desc";
|
||||
|
||||
return { page, limit, sortBy, sortOrder };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create pagination metadata
|
||||
*/
|
||||
export function createPaginationMetadata(
|
||||
page: number,
|
||||
limit: number,
|
||||
total: number,
|
||||
): PaginationMetadata {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPrevPage: page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pagination to an array (for in-memory pagination)
|
||||
* Use this sparingly - prefer database-level pagination
|
||||
*/
|
||||
export function paginateArray<T>(
|
||||
items: T[],
|
||||
page: number,
|
||||
limit: number,
|
||||
): PaginatedResponse<T> {
|
||||
const offset = (page - 1) * limit;
|
||||
const paginatedItems = items.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
data: paginatedItems,
|
||||
pagination: createPaginationMetadata(page, limit, items.length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SQL LIMIT and OFFSET from page and limit
|
||||
*/
|
||||
export function getPaginationOffsets(page: number, limit: number) {
|
||||
const offset = (page - 1) * limit;
|
||||
return { limit, offset };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
interface RateLimitStore {
|
||||
[key: string]: {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
const store: RateLimitStore = {};
|
||||
|
||||
interface RateLimitConfig {
|
||||
interval: number; // Time window in milliseconds
|
||||
maxRequests: number; // Maximum requests in time window
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple in-memory rate limiter
|
||||
* For production, use Redis or a dedicated rate limiting service
|
||||
*
|
||||
* @param identifier - Unique identifier (e.g., IP address, user ID)
|
||||
* @param config - Rate limit configuration
|
||||
* @returns NextResponse error if rate limited, undefined if allowed
|
||||
*
|
||||
* @example
|
||||
* const rateLimitError = rateLimit(userId, { interval: 60000, maxRequests: 5 });
|
||||
* if (rateLimitError) return rateLimitError;
|
||||
*/
|
||||
export function rateLimit(
|
||||
identifier: string,
|
||||
config: RateLimitConfig,
|
||||
): NextResponse | undefined {
|
||||
const now = Date.now();
|
||||
const key = identifier;
|
||||
|
||||
// Initialize or reset if window expired
|
||||
if (!store[key] || now > store[key].resetTime) {
|
||||
store[key] = {
|
||||
count: 1,
|
||||
resetTime: now + config.interval,
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Increment count
|
||||
store[key].count++;
|
||||
|
||||
// Check if limit exceeded
|
||||
if (store[key].count > config.maxRequests) {
|
||||
const retryAfter = Math.ceil((store[key].resetTime - now) / 1000);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Too many requests",
|
||||
message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
|
||||
retryAfter,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": retryAfter.toString(),
|
||||
"X-RateLimit-Limit": config.maxRequests.toString(),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": store[key].resetTime.toString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit configurations for different endpoints
|
||||
*/
|
||||
export const RATE_LIMITS = {
|
||||
// Authentication endpoints - stricter limits
|
||||
login: { interval: 60000, maxRequests: 5 }, // 5 attempts per minute
|
||||
register: { interval: 3600000, maxRequests: 3 }, // 3 attempts per hour
|
||||
passwordReset: { interval: 3600000, maxRequests: 3 }, // 3 attempts per hour
|
||||
|
||||
// API endpoints - more lenient
|
||||
api: { interval: 60000, maxRequests: 100 }, // 100 requests per minute
|
||||
apiStrict: { interval: 60000, maxRequests: 10 }, // 10 requests per minute
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get client identifier for rate limiting
|
||||
* Uses IP address as fallback if user is not authenticated
|
||||
*
|
||||
* @param request - NextRequest object
|
||||
* @param userId - Optional authenticated user ID
|
||||
* @returns Identifier for rate limiting
|
||||
*/
|
||||
export function getClientIdentifier(request: Request, userId?: string): string {
|
||||
if (userId) return `user:${userId}`;
|
||||
|
||||
// Try to get IP from headers (works with most reverse proxies)
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
const ip =
|
||||
forwarded?.split(",")[0] ?? request.headers.get("x-real-ip") ?? "unknown";
|
||||
|
||||
return `ip:${ip}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired rate limit entries (call periodically)
|
||||
*/
|
||||
export function cleanupRateLimitStore(): void {
|
||||
const now = Date.now();
|
||||
for (const key in store) {
|
||||
if (store[key].resetTime < now) {
|
||||
delete store[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup every 10 minutes
|
||||
if (typeof setInterval !== "undefined") {
|
||||
setInterval(cleanupRateLimitStore, 10 * 60 * 1000);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import type { IDatabase, User } from "./database/types";
|
||||
import log from "./logger";
|
||||
|
||||
export async function ensureUserSynced(
|
||||
userId: string,
|
||||
@@ -8,7 +9,7 @@ export async function ensureUserSynced(
|
||||
const existingUser = await db.getUserById(userId);
|
||||
if (existingUser) return existingUser;
|
||||
|
||||
console.log("User not found in DB by ID, checking Clerk:", userId);
|
||||
log.debug("User not found in DB by ID, checking Clerk", { userId });
|
||||
const clerkUser = await currentUser();
|
||||
|
||||
if (!clerkUser || clerkUser.id !== userId) {
|
||||
@@ -24,7 +25,7 @@ export async function ensureUserSynced(
|
||||
// Check if user exists by email (e.g. seeded user)
|
||||
const existingByEmail = await db.getUserByEmail(email);
|
||||
if (existingByEmail) {
|
||||
console.log("User found by email but ID mismatch. Migrating ID...", {
|
||||
log.debug("User found by email but ID mismatch, migrating ID", {
|
||||
oldId: existingByEmail.id,
|
||||
newId: userId,
|
||||
});
|
||||
@@ -50,7 +51,7 @@ export async function ensureUserSynced(
|
||||
return db.getUserById(userId);
|
||||
}
|
||||
|
||||
console.log("Creating new user from Clerk data:", userId);
|
||||
log.debug("Creating new user from Clerk data", { userId, email });
|
||||
const user = await db.createUser({
|
||||
id: userId,
|
||||
email,
|
||||
@@ -59,11 +60,11 @@ export async function ensureUserSynced(
|
||||
password: "", // Managed by Clerk
|
||||
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
|
||||
gymId: (clerkUser.publicMetadata.gymId as string | undefined) || undefined,
|
||||
role: ((): any => {
|
||||
role: (() => {
|
||||
const r = clerkUser.publicMetadata.role as string | undefined;
|
||||
return r &&
|
||||
["superAdmin", "admin", "trainer", "client", "generalUser"].includes(r)
|
||||
? r
|
||||
const validRoles = ["superAdmin", "admin", "trainer", "client"] as const;
|
||||
return r && validRoles.includes(r as (typeof validRoles)[number])
|
||||
? (r as (typeof validRoles)[number])
|
||||
: "client";
|
||||
})(),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
import { validateEnv } from "./lib/env";
|
||||
import log from "./lib/logger";
|
||||
|
||||
// Validate environment variables on server startup
|
||||
try {
|
||||
validateEnv();
|
||||
log.info("Environment validation passed");
|
||||
} catch (error) {
|
||||
log.error("Environment validation failed", error);
|
||||
// Re-throw to prevent server from starting with invalid config
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Define routes that should be publicly accessible (no auth required)
|
||||
const isPublicRoute = createRouteMatcher([
|
||||
@@ -16,27 +28,26 @@ export default clerkMiddleware(async (auth, req) => {
|
||||
// Log for debugging
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (authHeader) {
|
||||
console.log(
|
||||
"[Middleware] Authorization header present:",
|
||||
authHeader.substring(0, 20) + "...",
|
||||
);
|
||||
log.debug("Authorization header present", {
|
||||
preview: authHeader.substring(0, 20) + "...",
|
||||
});
|
||||
}
|
||||
|
||||
// Don't protect public routes
|
||||
if (isPublicRoute(req)) {
|
||||
console.log("[Middleware] Public route, skipping auth");
|
||||
log.debug("Public route, skipping auth");
|
||||
return;
|
||||
}
|
||||
|
||||
// For API routes, let the route handler check auth
|
||||
// This allows API routes to handle both web sessions and mobile Bearer tokens
|
||||
if (isApiRoute(req)) {
|
||||
console.log("[Middleware] API route, auth will be checked in handler");
|
||||
log.debug("API route, auth will be checked in handler");
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other routes (web pages), enforce authentication
|
||||
console.log("[Middleware] Protected route, requiring auth");
|
||||
log.debug("Protected route, requiring auth");
|
||||
await auth.protect();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Clerk Authentication
|
||||
# Get these values from https://dashboard.clerk.com
|
||||
# Make sure to use the correct publishable key for your environment
|
||||
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cGxlYXNpbmctcGhlYXNhbnQtMjAuY2xlcmsuYWNjb3VudHMuZGV2JA
|
||||
# IMPORTANT: Must start with EXPO_PUBLIC_ to be available in the app
|
||||
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
|
||||
|
||||
# API Configuration
|
||||
# Update this to point to your backend API
|
||||
# Development: Use ngrok or local network IP (e.g., http://192.168.1.100:3000)
|
||||
# Production: Use your production API URL
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3000/api
|
||||
|
||||
# App Configuration
|
||||
|
||||
+11
-2
@@ -39,6 +39,15 @@ android/app/build/generated/
|
||||
# Bundle artifact
|
||||
*.jsbundle
|
||||
|
||||
# Environment
|
||||
# Environment variables (all .env files)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env*.local
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiClient } from "./client";
|
||||
import { API_ENDPOINTS } from "../config/api";
|
||||
import { getErrorMessage } from "../utils/error-helpers";
|
||||
import log from "../utils/logger";
|
||||
|
||||
export interface FitnessProfile {
|
||||
id?: string;
|
||||
@@ -20,10 +22,7 @@ export const fitnessProfileApi = {
|
||||
token: string,
|
||||
): Promise<FitnessProfile> => {
|
||||
try {
|
||||
console.log(
|
||||
"Getting fitness profile with URL:",
|
||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
||||
);
|
||||
log.debug("Getting fitness profile", { userId });
|
||||
const response = await apiClient.get(
|
||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
||||
{
|
||||
@@ -33,12 +32,8 @@ export const fitnessProfileApi = {
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Error fetching fitness profile:",
|
||||
error.message,
|
||||
error.response?.data,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to fetch fitness profile", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -59,12 +54,8 @@ export const fitnessProfileApi = {
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Error updating fitness profile:",
|
||||
error.message,
|
||||
error.response?.data,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to update fitness profile", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -84,12 +75,8 @@ export const fitnessProfileApi = {
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Error creating fitness profile:",
|
||||
error.message,
|
||||
error.response?.data,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to create fitness profile", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import { fitnessProfileApi } from "@/api/fitnessProfile";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const { user } = useUser();
|
||||
@@ -37,7 +38,7 @@ export default function OnboardingScreen() {
|
||||
setGyms(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch gyms:", e);
|
||||
log.error("Failed to fetch gyms", e);
|
||||
} finally {
|
||||
setGymsLoading(false);
|
||||
}
|
||||
@@ -84,7 +85,7 @@ export default function OnboardingScreen() {
|
||||
body: JSON.stringify({ gymId: selectedGymId }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Failed to update gym selection:", e);
|
||||
log.warn("Failed to update gym selection", { gymId: selectedGymId });
|
||||
}
|
||||
|
||||
const fitnessData = {
|
||||
@@ -108,7 +109,7 @@ export default function OnboardingScreen() {
|
||||
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
||||
router.replace("/(tabs)");
|
||||
} catch (error) {
|
||||
console.error("Error creating fitness profile:", error);
|
||||
log.error("Failed to create fitness profile", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Failed to create fitness profile. Please try again.",
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { OAuthButtons } from "../../components/auth/OAuthButtons";
|
||||
import { getErrorMessage, getClerkErrorCode } from "../../utils/error-helpers";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function SignInScreen() {
|
||||
const { signIn, setActive, isLoaded } = useSignIn();
|
||||
@@ -47,25 +49,24 @@ export default function SignInScreen() {
|
||||
await setActive({ session: signInAttempt.createdSessionId });
|
||||
router.replace("/(tabs)");
|
||||
} else {
|
||||
console.error(
|
||||
"Sign-in incomplete:",
|
||||
JSON.stringify(signInAttempt, null, 2),
|
||||
);
|
||||
log.warn("Sign-in incomplete", { status: signInAttempt.status });
|
||||
setError("Sign-in incomplete. Please try again.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Sign-in error:", JSON.stringify(err, null, 2));
|
||||
} catch (err: unknown) {
|
||||
log.error("Sign-in failed", err);
|
||||
|
||||
// Handle specific error codes
|
||||
if (err.errors?.[0]?.code === "session_exists") {
|
||||
if (getClerkErrorCode(err) === "session_exists") {
|
||||
// User is already signed in, just redirect
|
||||
router.replace("/(tabs)");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(
|
||||
err.errors?.[0]?.message ||
|
||||
"Failed to sign in. Please check your credentials.",
|
||||
getErrorMessage(
|
||||
err,
|
||||
"Failed to sign in. Please check your credentials.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { OAuthButtons } from "../../components/auth/OAuthButtons";
|
||||
import { getErrorMessage, getClerkErrorCode } from "../../utils/error-helpers";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function SignUpScreen() {
|
||||
const { signUp, setActive, isLoaded } = useSignUp();
|
||||
@@ -53,11 +55,9 @@ export default function SignUpScreen() {
|
||||
await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
|
||||
|
||||
setPendingVerification(true);
|
||||
} catch (err: any) {
|
||||
console.error("Sign-up error:", JSON.stringify(err, null, 2));
|
||||
setError(
|
||||
err.errors?.[0]?.message || "Failed to sign up. Please try again.",
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
log.error("Failed to sign up", err);
|
||||
setError(getErrorMessage(err, "Failed to sign up. Please try again."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -78,23 +78,20 @@ export default function SignUpScreen() {
|
||||
await setActive({ session: completeSignUp.createdSessionId });
|
||||
router.replace("/(tabs)");
|
||||
} else {
|
||||
console.error(
|
||||
"Verification incomplete:",
|
||||
JSON.stringify(completeSignUp, null, 2),
|
||||
);
|
||||
log.warn("Verification incomplete", { status: completeSignUp.status });
|
||||
setError("Verification incomplete. Please try again.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Verification error:", JSON.stringify(err, null, 2));
|
||||
} catch (err: unknown) {
|
||||
log.error("Verification failed", err);
|
||||
|
||||
// Handle specific error codes
|
||||
if (err.errors?.[0]?.code === "session_exists") {
|
||||
if (getClerkErrorCode(err) === "session_exists") {
|
||||
// User is already signed in, just redirect
|
||||
router.replace("/(tabs)");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.errors?.[0]?.message || "Invalid verification code.");
|
||||
setError(getErrorMessage(err, "Invalid verification code."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from '@clerk/clerk-expo'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { attendanceApi, Attendance } from '../../api/attendance'
|
||||
import { theme } from '../../styles/theme'
|
||||
import { Animated } from 'react-native'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { attendanceApi, Attendance } from "../../api/attendance";
|
||||
import { theme } from "../../styles/theme";
|
||||
import { Animated } from "react-native";
|
||||
import { getErrorMessage } from "../../utils/error-helpers";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function AttendanceScreen() {
|
||||
const { getToken, userId } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null)
|
||||
const [history, setHistory] = useState<Attendance[]>([])
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current
|
||||
const { getToken, userId } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null);
|
||||
const [history, setHistory] = useState<Attendance[]>([]);
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCheckIn) {
|
||||
@@ -28,75 +38,75 @@ export default function AttendanceScreen() {
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
)
|
||||
pulse.start()
|
||||
return () => pulse.stop()
|
||||
]),
|
||||
);
|
||||
pulse.start();
|
||||
return () => pulse.stop();
|
||||
}
|
||||
}, [activeCheckIn])
|
||||
}, [activeCheckIn]);
|
||||
|
||||
const fetchAttendance = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
setLoading(true);
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
|
||||
console.log('Fetching attendance history')
|
||||
const data = await attendanceApi.getHistory(token)
|
||||
setHistory(data)
|
||||
log.debug("Fetching attendance history");
|
||||
const data = await attendanceApi.getHistory(token);
|
||||
setHistory(data);
|
||||
|
||||
// Check if there's an active check-in (latest one has no checkOutTime)
|
||||
if (data.length > 0 && !data[0].checkOutTime) {
|
||||
setActiveCheckIn(data[0])
|
||||
setActiveCheckIn(data[0]);
|
||||
} else {
|
||||
setActiveCheckIn(null)
|
||||
setActiveCheckIn(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching attendance:', error)
|
||||
Alert.alert('Error', 'Failed to load attendance data')
|
||||
log.error("Failed to fetch attendance", error);
|
||||
Alert.alert("Error", "Failed to load attendance data");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttendance()
|
||||
}, [])
|
||||
fetchAttendance();
|
||||
}, []);
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
|
||||
await attendanceApi.checkIn('gym', token)
|
||||
fetchAttendance()
|
||||
Alert.alert('Success', 'Checked in successfully!')
|
||||
} catch (error: any) {
|
||||
console.error('Check-in error:', error)
|
||||
Alert.alert('Error', error.response?.data || 'Failed to check in')
|
||||
await attendanceApi.checkIn("gym", token);
|
||||
fetchAttendance();
|
||||
Alert.alert("Success", "Checked in successfully!");
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to check in", error);
|
||||
Alert.alert("Error", getErrorMessage(error, "Failed to check in"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckOut = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
|
||||
await attendanceApi.checkOut(token)
|
||||
fetchAttendance()
|
||||
Alert.alert('Success', 'Checked out successfully!')
|
||||
} catch (error: any) {
|
||||
console.error('Check-out error:', error)
|
||||
Alert.alert('Error', error.response?.data || 'Failed to check out')
|
||||
await attendanceApi.checkOut(token);
|
||||
fetchAttendance();
|
||||
Alert.alert("Success", "Checked out successfully!");
|
||||
} catch (error: unknown) {
|
||||
log.error("Failed to check out", error);
|
||||
Alert.alert("Error", getErrorMessage(error, "Failed to check out"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !history.length) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -114,7 +124,7 @@ export default function AttendanceScreen() {
|
||||
<View style={styles.actionContainer}>
|
||||
{activeCheckIn ? (
|
||||
<LinearGradient
|
||||
colors={['rgba(16, 185, 129, 0.15)', 'rgba(5, 150, 105, 0.1)']}
|
||||
colors={["rgba(16, 185, 129, 0.15)", "rgba(5, 150, 105, 0.1)"]}
|
||||
style={[styles.activeCard, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.activeCardContent}>
|
||||
@@ -129,7 +139,11 @@ export default function AttendanceScreen() {
|
||||
<View style={styles.activeTextContainer}>
|
||||
<Text style={styles.activeText}>Currently Checked In</Text>
|
||||
<Text style={styles.timeText}>
|
||||
Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
Since{" "}
|
||||
{new Date(activeCheckIn.checkInTime).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -140,7 +154,12 @@ export default function AttendanceScreen() {
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[styles.checkOutButton, theme.shadows.medium]}
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={20} color="#fff" style={{ marginRight: 8 }} />
|
||||
<Ionicons
|
||||
name="log-out-outline"
|
||||
size={20}
|
||||
color="#fff"
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.buttonText}>Check Out</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
@@ -154,7 +173,12 @@ export default function AttendanceScreen() {
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[styles.checkInButton, theme.shadows.glow]}
|
||||
>
|
||||
<Ionicons name="log-in-outline" size={24} color="#fff" style={{ marginRight: 8 }} />
|
||||
<Ionicons
|
||||
name="log-in-outline"
|
||||
size={24}
|
||||
color="#fff"
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.checkInButtonText}>Check In</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
@@ -166,13 +190,17 @@ export default function AttendanceScreen() {
|
||||
{history.map((item) => (
|
||||
<LinearGradient
|
||||
key={item.id}
|
||||
colors={['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const}
|
||||
colors={["rgba(255, 255, 255, 1)", "rgba(249, 250, 251, 1)"] as const}
|
||||
style={[styles.historyItem, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.historyLeft}>
|
||||
<View style={styles.historyIconContainer}>
|
||||
<LinearGradient
|
||||
colors={item.checkOutTime ? theme.gradients.success : theme.gradients.primary}
|
||||
colors={
|
||||
item.checkOutTime
|
||||
? theme.gradients.success
|
||||
: theme.gradients.primary
|
||||
}
|
||||
style={styles.historyIcon}
|
||||
>
|
||||
<Ionicons
|
||||
@@ -191,18 +219,26 @@ export default function AttendanceScreen() {
|
||||
</View>
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.historyTime}>
|
||||
In: {new Date(item.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
In:{" "}
|
||||
{new Date(item.checkInTime).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
{item.checkOutTime && (
|
||||
<Text style={styles.historyTime}>
|
||||
Out: {new Date(item.checkOutTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
Out:{" "}
|
||||
{new Date(item.checkOutTime).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
))}
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -215,8 +251,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
@@ -227,14 +263,14 @@ const styles = StyleSheet.create({
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.typography.fontSize['3xl'],
|
||||
fontSize: theme.typography.fontSize["3xl"],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
color: "rgba(255, 255, 255, 0.9)",
|
||||
},
|
||||
actionContainer: {
|
||||
marginBottom: 32,
|
||||
@@ -244,9 +280,9 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
checkInButtonText: {
|
||||
color: theme.colors.white,
|
||||
@@ -257,9 +293,9 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonText: {
|
||||
@@ -271,11 +307,11 @@ const styles = StyleSheet.create({
|
||||
padding: 20,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(16, 185, 129, 0.2)',
|
||||
borderColor: "rgba(16, 185, 129, 0.2)",
|
||||
},
|
||||
activeCardContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
activeIconContainer: {
|
||||
@@ -285,8 +321,8 @@ const styles = StyleSheet.create({
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
activeTextContainer: {
|
||||
flex: 1,
|
||||
@@ -313,15 +349,15 @@ const styles = StyleSheet.create({
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 20,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
historyLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
historyIconContainer: {
|
||||
@@ -331,8 +367,8 @@ const styles = StyleSheet.create({
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
dateText: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
@@ -345,10 +381,10 @@ const styles = StyleSheet.create({
|
||||
marginTop: 2,
|
||||
},
|
||||
timeContainer: {
|
||||
alignItems: 'flex-end',
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
historyTime: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
},
|
||||
})
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "../../services/fitnessGoals";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function GoalsScreen() {
|
||||
const { user } = useUser();
|
||||
@@ -35,11 +36,11 @@ export default function GoalsScreen() {
|
||||
if (!user?.id) return;
|
||||
try {
|
||||
const token = await getToken();
|
||||
console.log(
|
||||
"Token obtained:",
|
||||
token ? "Yes (" + token.substring(0, 20) + "...)" : "No",
|
||||
);
|
||||
console.log("User ID:", user.id);
|
||||
log.debug("Token obtained", {
|
||||
hasToken: !!token,
|
||||
tokenPreview: token ? token.substring(0, 20) + "..." : "No",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Decode and log token details for debugging
|
||||
if (token) {
|
||||
@@ -47,21 +48,20 @@ export default function GoalsScreen() {
|
||||
const parts = token.split(".");
|
||||
if (parts.length === 3) {
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
console.log("Token issuer:", payload.iss);
|
||||
console.log(
|
||||
"Token kid from header:",
|
||||
JSON.parse(atob(parts[0])).kid,
|
||||
);
|
||||
log.debug("Token details", {
|
||||
issuer: payload.iss,
|
||||
kid: JSON.parse(atob(parts[0])).kid,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Could not decode token");
|
||||
log.warn("Could not decode token");
|
||||
}
|
||||
}
|
||||
|
||||
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
|
||||
setGoals(loadedGoals);
|
||||
} catch (error) {
|
||||
console.error("Error loading goals:", error);
|
||||
log.error("Failed to load goals", error);
|
||||
}
|
||||
}, [user?.id, getToken]);
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function GoalsScreen() {
|
||||
"Cache cleared! Please sign out and sign back in.",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error clearing cache:", error);
|
||||
log.error("Failed to clear cache", error);
|
||||
Alert.alert("Error", "Failed to clear cache");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AnimatedButton } from "../../components/AnimatedButton";
|
||||
import { GradientBackground } from "../../components/GradientBackground";
|
||||
import { useState, useEffect } from "react";
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user } = useUser();
|
||||
@@ -27,7 +28,7 @@ export default function ProfileScreen() {
|
||||
try {
|
||||
await signOut();
|
||||
} catch (err) {
|
||||
console.error("Error signing out:", err);
|
||||
log.error("Failed to sign out", err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,26 +70,32 @@ export default function ProfileScreen() {
|
||||
setGymsLoading(true);
|
||||
const token = await getToken();
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.GYMS}`;
|
||||
console.log("Profile.loadGyms fetching:", url);
|
||||
log.debug("Loading gyms", { url });
|
||||
const res = await fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("Failed to fetch gyms: non-OK response", {
|
||||
status: res.status,
|
||||
body: text?.slice(0, 200),
|
||||
});
|
||||
log.error(
|
||||
"Failed to fetch gyms - non-OK response",
|
||||
new Error(text.slice(0, 200)),
|
||||
{
|
||||
status: res.status,
|
||||
},
|
||||
);
|
||||
setGyms([]);
|
||||
return;
|
||||
}
|
||||
if (!contentType.includes("application/json")) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("Failed to fetch gyms: expected JSON", {
|
||||
contentType,
|
||||
body: text?.slice(0, 200),
|
||||
});
|
||||
log.error(
|
||||
"Failed to fetch gyms - expected JSON",
|
||||
new Error(text.slice(0, 200)),
|
||||
{
|
||||
contentType,
|
||||
},
|
||||
);
|
||||
setGyms([]);
|
||||
return;
|
||||
}
|
||||
@@ -97,12 +104,9 @@ export default function ProfileScreen() {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error(
|
||||
"Failed to parse gyms JSON:",
|
||||
e,
|
||||
"body:",
|
||||
text?.slice(0, 200),
|
||||
);
|
||||
log.error("Failed to parse gyms JSON", e, {
|
||||
bodyPreview: text?.slice(0, 200),
|
||||
});
|
||||
setGyms([]);
|
||||
return;
|
||||
}
|
||||
@@ -119,7 +123,7 @@ export default function ProfileScreen() {
|
||||
if (selectedGymId === null) setSelectedGymId(gid);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch gyms:", err);
|
||||
log.error("Failed to fetch gyms", err);
|
||||
setGyms([]);
|
||||
} finally {
|
||||
setGymsLoading(false);
|
||||
@@ -130,12 +134,7 @@ export default function ProfileScreen() {
|
||||
try {
|
||||
const token = await getToken();
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.USERS}/gym`;
|
||||
console.log(
|
||||
"Profile.handleApplyGym PATCH:",
|
||||
url,
|
||||
"gymId:",
|
||||
selectedGymId,
|
||||
);
|
||||
log.debug("Updating gym selection", { url, gymId: selectedGymId });
|
||||
const res = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -147,25 +146,25 @@ export default function ProfileScreen() {
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("Failed to update gym selection: non-OK response", {
|
||||
status: res.status,
|
||||
body: text?.slice(0, 200),
|
||||
});
|
||||
log.error(
|
||||
"Failed to update gym selection - non-OK response",
|
||||
new Error(text.slice(0, 200)),
|
||||
{
|
||||
status: res.status,
|
||||
},
|
||||
);
|
||||
Alert.alert("Error", "Failed to update gym selection");
|
||||
return;
|
||||
}
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
console.log("Gym selection updated:", data);
|
||||
log.debug("Gym selection updated", { data });
|
||||
} catch (e) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error(
|
||||
"Failed to parse update response JSON:",
|
||||
e,
|
||||
"body:",
|
||||
text?.slice(0, 200),
|
||||
);
|
||||
log.error("Failed to parse update response JSON", e, {
|
||||
bodyPreview: text?.slice(0, 200),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Update current gym state for immediate UI reflection
|
||||
@@ -179,14 +178,14 @@ export default function ProfileScreen() {
|
||||
try {
|
||||
await (user as any)?.reload?.();
|
||||
} catch (e) {
|
||||
console.log("Profile.handleApplyGym: failed to reload user", e);
|
||||
log.debug("Failed to reload user after gym update", { error: e });
|
||||
}
|
||||
Alert.alert(
|
||||
"Success",
|
||||
selectedGymId ? "Gym selected successfully" : "Proceeding without gym",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to update gym selection:", err);
|
||||
log.error("Failed to update gym selection", err);
|
||||
Alert.alert("Error", "Failed to update gym selection");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,291 +1,319 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, FlatList, ActivityIndicator, StyleSheet, RefreshControl } from "react-native";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
} from "react-native";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
||||
import { theme } from "../../styles/theme";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
recommendationText: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
userId: string;
|
||||
recommendationText: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function RecommendationsScreen() {
|
||||
const { getToken, userId } = useAuth();
|
||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const { getToken, userId } = useAuth();
|
||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
try {
|
||||
if (!userId) {
|
||||
console.error('No userId available');
|
||||
return;
|
||||
}
|
||||
const fetchRecommendations = async () => {
|
||||
try {
|
||||
if (!userId) {
|
||||
log.warn("No userId available");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const token = await getToken();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`;
|
||||
console.log('Fetching recommendations from:', url);
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`;
|
||||
log.apiRequest("GET", url);
|
||||
|
||||
const res = await fetch(url, { headers });
|
||||
const res = await fetch(url, { headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
console.error('API Error:', res.status, errorText);
|
||||
throw new Error(`Network response was not ok: ${res.status}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
log.error("API error fetching recommendations", {
|
||||
status: res.status,
|
||||
errorText,
|
||||
});
|
||||
throw new Error(`Network response was not ok: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
console.log('Recommendations data:', data);
|
||||
setRecommendations(data.recommendations || data || []);
|
||||
} catch (e) {
|
||||
console.error('Failed to load recommendations', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecommendations();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchRecommendations();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
const data = await res.json();
|
||||
log.debug("Recommendations fetched", {
|
||||
count: data.recommendations?.length || data.length || 0,
|
||||
});
|
||||
setRecommendations(data.recommendations || data || []);
|
||||
} catch (e) {
|
||||
log.error("Failed to load recommendations", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecommendations();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchRecommendations();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.header}
|
||||
>
|
||||
<Text style={styles.headerTitle}>AI Recommendations</Text>
|
||||
</LinearGradient>
|
||||
|
||||
{/* AI Context Info Banner with Glassmorphism */}
|
||||
<LinearGradient
|
||||
colors={['rgba(59, 130, 246, 0.15)', 'rgba(139, 92, 246, 0.1)'] as const}
|
||||
style={styles.infoBanner}
|
||||
>
|
||||
<View style={styles.infoBannerIconContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.infoBannerIcon}
|
||||
>
|
||||
<Ionicons name="sparkles" size={16} color="#fff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.infoBannerText}>
|
||||
Personalized based on your active fitness goals and progress
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<FlatList
|
||||
data={recommendations}
|
||||
keyExtractor={(item) => item.id}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(209, 213, 219, 0.3)', 'rgba(209, 213, 219, 0.1)'] as const}
|
||||
style={styles.emptyIconGradient}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={48} color="#9ca3af" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.empty}>No recommendations available yet.</Text>
|
||||
<Text style={styles.emptySub}>Pull down to refresh</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const}
|
||||
style={[styles.card, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.success}
|
||||
style={styles.statusBadge}
|
||||
>
|
||||
<Text style={styles.statusText}>{item.status.toUpperCase()}</Text>
|
||||
</LinearGradient>
|
||||
<Text style={styles.date}>{new Date(item.createdAt).toLocaleDateString()}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Daily Advice</Text>
|
||||
<Text style={styles.content}>{item.recommendationText}</Text>
|
||||
|
||||
{item.activityPlan && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Activity Plan</Text>
|
||||
<Text style={styles.content}>{item.activityPlan}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.dietPlan && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Diet Plan</Text>
|
||||
<Text style={styles.content}>{item.dietPlan}</Text>
|
||||
</>
|
||||
)}
|
||||
</LinearGradient>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.header}
|
||||
>
|
||||
<Text style={styles.headerTitle}>AI Recommendations</Text>
|
||||
</LinearGradient>
|
||||
|
||||
{/* AI Context Info Banner with Glassmorphism */}
|
||||
<LinearGradient
|
||||
colors={
|
||||
["rgba(59, 130, 246, 0.15)", "rgba(139, 92, 246, 0.1)"] as const
|
||||
}
|
||||
style={styles.infoBanner}
|
||||
>
|
||||
<View style={styles.infoBannerIconContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.infoBannerIcon}
|
||||
>
|
||||
<Ionicons name="sparkles" size={16} color="#fff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.infoBannerText}>
|
||||
Personalized based on your active fitness goals and progress
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<FlatList
|
||||
data={recommendations}
|
||||
keyExtractor={(item) => item.id}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"rgba(209, 213, 219, 0.3)",
|
||||
"rgba(209, 213, 219, 0.1)",
|
||||
] as const
|
||||
}
|
||||
style={styles.emptyIconGradient}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={48} color="#9ca3af" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.empty}>No recommendations available yet.</Text>
|
||||
<Text style={styles.emptySub}>Pull down to refresh</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<LinearGradient
|
||||
colors={
|
||||
["rgba(255, 255, 255, 1)", "rgba(249, 250, 251, 1)"] as const
|
||||
}
|
||||
style={[styles.card, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.success}
|
||||
style={styles.statusBadge}
|
||||
>
|
||||
<Text style={styles.statusText}>
|
||||
{item.status.toUpperCase()}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
<Text style={styles.date}>
|
||||
{new Date(item.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Daily Advice</Text>
|
||||
<Text style={styles.content}>{item.recommendationText}</Text>
|
||||
|
||||
{item.activityPlan && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Activity Plan</Text>
|
||||
<Text style={styles.content}>{item.activityPlan}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.dietPlan && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Diet Plan</Text>
|
||||
<Text style={styles.content}>{item.dietPlan}</Text>
|
||||
</>
|
||||
)}
|
||||
</LinearGradient>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 24,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: theme.typography.fontSize['3xl'],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
infoBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
padding: 14,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.2)',
|
||||
gap: 10,
|
||||
},
|
||||
infoBannerIconContainer: {
|
||||
marginRight: 4,
|
||||
},
|
||||
infoBannerIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoBannerText: {
|
||||
flex: 1,
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
lineHeight: 18,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
padding: 18,
|
||||
marginBottom: 14,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.gray200,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
date: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
color: theme.colors.gray600,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.gray900,
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
content: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyIconGradient: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 48,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
empty: {
|
||||
textAlign: 'center',
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: theme.colors.gray700,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptySub: {
|
||||
textAlign: 'center',
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray500,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 24,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: theme.typography.fontSize["3xl"],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
infoBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
padding: 14,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(59, 130, 246, 0.2)",
|
||||
gap: 10,
|
||||
},
|
||||
infoBannerIconContainer: {
|
||||
marginRight: 4,
|
||||
},
|
||||
infoBannerIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
infoBannerText: {
|
||||
flex: 1,
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
lineHeight: 18,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
padding: 18,
|
||||
marginBottom: 14,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 14,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.gray200,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
date: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
color: theme.colors.gray600,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.gray900,
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
content: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyIconGradient: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 48,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
empty: {
|
||||
textAlign: "center",
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: theme.colors.gray700,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptySub: {
|
||||
textAlign: "center",
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray500,
|
||||
},
|
||||
});
|
||||
@@ -3,39 +3,56 @@ import { Stack } from "expo-router";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { View, Text } from "react-native";
|
||||
import { useEffect, useState } from "react";
|
||||
import { validateEnv } from "../utils/env";
|
||||
import log from "../utils/logger";
|
||||
|
||||
console.log("========================================");
|
||||
console.log("🚀 _layout.tsx loaded at:", new Date().toISOString());
|
||||
console.log(
|
||||
"📦 EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY:",
|
||||
process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
);
|
||||
console.log("========================================");
|
||||
// Validate environment variables on app startup
|
||||
try {
|
||||
const env = validateEnv();
|
||||
log.info("Environment validation passed", {
|
||||
appName: env.EXPO_PUBLIC_APP_NAME,
|
||||
apiUrl: env.EXPO_PUBLIC_API_URL,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Environment validation failed", error);
|
||||
// In development, show the error but allow app to continue
|
||||
// In production, this will crash the app (as it should)
|
||||
if (!__DEV__) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Token cache for Clerk
|
||||
const tokenCache = {
|
||||
async getToken(key: string) {
|
||||
try {
|
||||
const value = await SecureStore.getItemAsync(key);
|
||||
console.log(`[TokenCache] Getting key: ${key}, exists: ${!!value}`);
|
||||
if (value && value.length > 50) {
|
||||
console.log(`[TokenCache] Value preview: ${value.substring(0, 50)}...`);
|
||||
}
|
||||
log.debug("Getting token from cache", {
|
||||
key,
|
||||
exists: !!value,
|
||||
preview:
|
||||
value && value.length > 50
|
||||
? value.substring(0, 50) + "..."
|
||||
: undefined,
|
||||
});
|
||||
return value;
|
||||
} catch (err) {
|
||||
console.error("Error getting token:", err);
|
||||
log.error("Failed to get token from cache", err, { key });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async saveToken(key: string, value: string) {
|
||||
try {
|
||||
console.log(`[TokenCache] Saving key: ${key}`);
|
||||
if (value && value.length > 50) {
|
||||
console.log(`[TokenCache] Value preview: ${value.substring(0, 50)}...`);
|
||||
}
|
||||
log.debug("Saving token to cache", {
|
||||
key,
|
||||
preview:
|
||||
value && value.length > 50
|
||||
? value.substring(0, 50) + "..."
|
||||
: undefined,
|
||||
});
|
||||
return SecureStore.setItemAsync(key, value);
|
||||
} catch (err) {
|
||||
console.error("Error saving token:", err);
|
||||
log.error("Failed to save token to cache", err, { key });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -44,18 +61,17 @@ export default function RootLayout() {
|
||||
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
||||
const [cacheCleared, setCacheCleared] = useState(false);
|
||||
|
||||
console.log(
|
||||
"🔑 [RootLayout] Component rendering with publishableKey:",
|
||||
publishableKey,
|
||||
);
|
||||
log.debug("RootLayout rendering", {
|
||||
hasPublishableKey: !!publishableKey,
|
||||
});
|
||||
|
||||
// TEMPORARY: Clear Clerk cache on app start to fix instance mismatch
|
||||
useEffect(() => {
|
||||
console.log("⚡ [RootLayout] useEffect triggered - starting cache cleanup");
|
||||
log.debug("Starting cache cleanup");
|
||||
|
||||
const clearOldClerkCache = async () => {
|
||||
try {
|
||||
console.log("🔍 [RootLayout] Checking for old Clerk tokens...");
|
||||
log.debug("Checking for old Clerk tokens");
|
||||
|
||||
const keysToCheck = [
|
||||
"__clerk_client_jwt",
|
||||
@@ -70,34 +86,30 @@ export default function RootLayout() {
|
||||
try {
|
||||
const value = await SecureStore.getItemAsync(key);
|
||||
if (value) {
|
||||
console.log(`📌 [RootLayout] Found token at ${key}`);
|
||||
console.log(
|
||||
`📝 [RootLayout] Token preview: ${value.substring(0, 80)}...`,
|
||||
);
|
||||
log.debug("Found token", {
|
||||
key,
|
||||
preview: value.substring(0, 80) + "...",
|
||||
});
|
||||
|
||||
// Check if it's from the old instance
|
||||
if (value.includes("pleasing-pheasant-20")) {
|
||||
console.log(`🗑️ [RootLayout] DELETING old token from ${key}`);
|
||||
log.debug("Deleting old token", { key });
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
} else if (value.includes("needed-elephant-64")) {
|
||||
console.log(
|
||||
`✅ [RootLayout] Token is from correct instance (needed-elephant-64)`,
|
||||
);
|
||||
log.debug("Token is from correct instance");
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ [RootLayout] Token doesn't match known instances`,
|
||||
);
|
||||
log.warn("Token doesn't match known instances", { key });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`❌ [RootLayout] Error checking key ${key}:`, e);
|
||||
log.error("Error checking token key", e, { key });
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ [RootLayout] Old token cleanup complete");
|
||||
log.info("Old token cleanup complete");
|
||||
setCacheCleared(true);
|
||||
} catch (error) {
|
||||
console.error("❌ [RootLayout] Error clearing old cache:", error);
|
||||
log.error("Error clearing old cache", error);
|
||||
setCacheCleared(true); // Continue anyway
|
||||
}
|
||||
};
|
||||
@@ -106,7 +118,7 @@ export default function RootLayout() {
|
||||
}, []);
|
||||
|
||||
if (!publishableKey) {
|
||||
console.log("❌ [RootLayout] No publishable key found!");
|
||||
log.error("No Clerk publishable key found");
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text>Missing Clerk Publishable Key</Text>
|
||||
@@ -120,7 +132,7 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
if (!cacheCleared) {
|
||||
console.log("⏳ [RootLayout] Waiting for cache to clear...");
|
||||
log.debug("Waiting for cache to clear");
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text>Clearing old authentication cache...</Text>
|
||||
@@ -131,10 +143,9 @@ export default function RootLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"🚀 [RootLayout] Rendering ClerkProvider with key:",
|
||||
publishableKey?.substring(0, 20) + "...",
|
||||
);
|
||||
log.debug("Rendering ClerkProvider", {
|
||||
keyPreview: publishableKey?.substring(0, 20) + "...",
|
||||
});
|
||||
|
||||
return (
|
||||
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
|
||||
|
||||
+170
-134
@@ -1,105 +1,118 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useAuth } from '@clerk/clerk-expo'
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
import { profileApi } from '../api/profile'
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { profileApi } from "../api/profile";
|
||||
import { getErrorMessage } from "../utils/error-helpers";
|
||||
|
||||
interface FitnessProfile {
|
||||
height: string
|
||||
weight: string
|
||||
age: string
|
||||
gender: 'male' | 'female' | 'other'
|
||||
activityLevel: 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active'
|
||||
fitnessGoals: string[]
|
||||
exerciseHabits: string
|
||||
dietHabits: string
|
||||
medicalConditions: string
|
||||
height: string;
|
||||
weight: string;
|
||||
age: string;
|
||||
gender: "male" | "female" | "other";
|
||||
activityLevel: "sedentary" | "light" | "moderate" | "active" | "very_active";
|
||||
fitnessGoals: string[];
|
||||
exerciseHabits: string;
|
||||
dietHabits: string;
|
||||
medicalConditions: string;
|
||||
}
|
||||
|
||||
export default function WelcomeScreen() {
|
||||
const [profile, setProfile] = useState<FitnessProfile>({
|
||||
height: '',
|
||||
weight: '',
|
||||
age: '',
|
||||
gender: 'male',
|
||||
activityLevel: 'moderate',
|
||||
height: "",
|
||||
weight: "",
|
||||
age: "",
|
||||
gender: "male",
|
||||
activityLevel: "moderate",
|
||||
fitnessGoals: [],
|
||||
exerciseHabits: '',
|
||||
dietHabits: '',
|
||||
medicalConditions: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { getToken } = useAuth()
|
||||
exerciseHabits: "",
|
||||
dietHabits: "",
|
||||
medicalConditions: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { getToken } = useAuth();
|
||||
|
||||
const fitnessGoalsOptions = [
|
||||
'Weight Loss',
|
||||
'Muscle Gain',
|
||||
'Improve Endurance',
|
||||
'Better Flexibility',
|
||||
'General Fitness',
|
||||
'Strength Training',
|
||||
'Cardio Health'
|
||||
]
|
||||
"Weight Loss",
|
||||
"Muscle Gain",
|
||||
"Improve Endurance",
|
||||
"Better Flexibility",
|
||||
"General Fitness",
|
||||
"Strength Training",
|
||||
"Cardio Health",
|
||||
];
|
||||
|
||||
const activityLevels = [
|
||||
{ value: 'sedentary', label: 'Sedentary (little or no exercise)' },
|
||||
{ value: 'light', label: 'Light (1-3 days/week)' },
|
||||
{ value: 'moderate', label: 'Moderate (3-5 days/week)' },
|
||||
{ value: 'active', label: 'Active (6-7 days/week)' },
|
||||
{ value: 'very_active', label: 'Very Active (twice per day)' }
|
||||
]
|
||||
const activityLevels: Array<{
|
||||
value: FitnessProfile["activityLevel"];
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "sedentary", label: "Sedentary (little or no exercise)" },
|
||||
{ value: "light", label: "Light (1-3 days/week)" },
|
||||
{ value: "moderate", label: "Moderate (3-5 days/week)" },
|
||||
{ value: "active", label: "Active (6-7 days/week)" },
|
||||
{ value: "very_active", label: "Very Active (twice per day)" },
|
||||
];
|
||||
|
||||
const toggleGoal = (goal: string) => {
|
||||
setProfile(prev => ({
|
||||
setProfile((prev) => ({
|
||||
...prev,
|
||||
fitnessGoals: prev.fitnessGoals.includes(goal)
|
||||
? prev.fitnessGoals.filter(g => g !== goal)
|
||||
: [...prev.fitnessGoals, goal]
|
||||
}))
|
||||
}
|
||||
? prev.fitnessGoals.filter((g) => g !== goal)
|
||||
: [...prev.fitnessGoals, goal],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!profile.height || !profile.weight || !profile.age) {
|
||||
Alert.alert('Error', 'Please fill in all required fields')
|
||||
return
|
||||
Alert.alert("Error", "Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = await getToken()
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication required')
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const user = await SecureStore.getItemAsync('user')
|
||||
const user = await SecureStore.getItemAsync("user");
|
||||
if (!user) {
|
||||
throw new Error('No user found')
|
||||
throw new Error("No user found");
|
||||
}
|
||||
|
||||
const userData = JSON.parse(user)
|
||||
const userData = JSON.parse(user);
|
||||
|
||||
await profileApi.createFitnessProfile(
|
||||
{
|
||||
userId: userData.id,
|
||||
...profile
|
||||
...profile,
|
||||
},
|
||||
token
|
||||
)
|
||||
token,
|
||||
);
|
||||
|
||||
Alert.alert('Success', 'Profile completed successfully!', [
|
||||
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
|
||||
])
|
||||
|
||||
|
||||
} catch (error: any) {
|
||||
console.log('Profile save error:', error)
|
||||
Alert.alert('Error', error.response?.data?.error || 'Failed to save profile. Please try again.')
|
||||
Alert.alert("Success", "Profile completed successfully!", [
|
||||
{ text: "OK", onPress: () => router.replace("/(tabs)") },
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
console.log("Profile save error:", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
getErrorMessage(error, "Failed to save profile. Please try again."),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
@@ -116,7 +129,9 @@ export default function WelcomeScreen() {
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={profile.height}
|
||||
onChangeText={(text) => setProfile({ ...profile, height: text })}
|
||||
onChangeText={(text) =>
|
||||
setProfile({ ...profile, height: text })
|
||||
}
|
||||
keyboardType="numeric"
|
||||
placeholder="170"
|
||||
/>
|
||||
@@ -127,7 +142,9 @@ export default function WelcomeScreen() {
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={profile.weight}
|
||||
onChangeText={(text) => setProfile({ ...profile, weight: text })}
|
||||
onChangeText={(text) =>
|
||||
setProfile({ ...profile, weight: text })
|
||||
}
|
||||
keyboardType="numeric"
|
||||
placeholder="70"
|
||||
/>
|
||||
@@ -149,19 +166,22 @@ export default function WelcomeScreen() {
|
||||
<View style={[styles.inputContainer, { flex: 1, marginLeft: 8 }]}>
|
||||
<Text style={styles.label}>Gender</Text>
|
||||
<View style={styles.genderRow}>
|
||||
{(['male', 'female', 'other'] as const).map((gender) => (
|
||||
{(["male", "female", "other"] as const).map((gender) => (
|
||||
<TouchableOpacity
|
||||
key={gender}
|
||||
style={[
|
||||
styles.genderButton,
|
||||
profile.gender === gender && styles.genderButtonSelected
|
||||
profile.gender === gender && styles.genderButtonSelected,
|
||||
]}
|
||||
onPress={() => setProfile({ ...profile, gender })}
|
||||
>
|
||||
<Text style={[
|
||||
styles.genderButtonText,
|
||||
profile.gender === gender && styles.genderButtonTextSelected
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.genderButtonText,
|
||||
profile.gender === gender &&
|
||||
styles.genderButtonTextSelected,
|
||||
]}
|
||||
>
|
||||
{gender.charAt(0).toUpperCase() + gender.slice(1)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -178,14 +198,20 @@ export default function WelcomeScreen() {
|
||||
key={level.value}
|
||||
style={[
|
||||
styles.activityOption,
|
||||
profile.activityLevel === level.value && styles.activityOptionSelected
|
||||
profile.activityLevel === level.value &&
|
||||
styles.activityOptionSelected,
|
||||
]}
|
||||
onPress={() => setProfile({ ...profile, activityLevel: level.value as any })}
|
||||
onPress={() =>
|
||||
setProfile({ ...profile, activityLevel: level.value })
|
||||
}
|
||||
>
|
||||
<Text style={[
|
||||
styles.activityText,
|
||||
profile.activityLevel === level.value && styles.activityTextSelected
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.activityText,
|
||||
profile.activityLevel === level.value &&
|
||||
styles.activityTextSelected,
|
||||
]}
|
||||
>
|
||||
{level.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -201,14 +227,18 @@ export default function WelcomeScreen() {
|
||||
key={goal}
|
||||
style={[
|
||||
styles.goalButton,
|
||||
profile.fitnessGoals.includes(goal) && styles.goalButtonSelected
|
||||
profile.fitnessGoals.includes(goal) &&
|
||||
styles.goalButtonSelected,
|
||||
]}
|
||||
onPress={() => toggleGoal(goal)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.goalButtonText,
|
||||
profile.fitnessGoals.includes(goal) && styles.goalButtonTextSelected
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.goalButtonText,
|
||||
profile.fitnessGoals.includes(goal) &&
|
||||
styles.goalButtonTextSelected,
|
||||
]}
|
||||
>
|
||||
{goal}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -221,7 +251,9 @@ export default function WelcomeScreen() {
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={profile.exerciseHabits}
|
||||
onChangeText={(text) => setProfile({ ...profile, exerciseHabits: text })}
|
||||
onChangeText={(text) =>
|
||||
setProfile({ ...profile, exerciseHabits: text })
|
||||
}
|
||||
placeholder="Describe your current exercise routine..."
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
@@ -233,7 +265,9 @@ export default function WelcomeScreen() {
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={profile.dietHabits}
|
||||
onChangeText={(text) => setProfile({ ...profile, dietHabits: text })}
|
||||
onChangeText={(text) =>
|
||||
setProfile({ ...profile, dietHabits: text })
|
||||
}
|
||||
placeholder="Describe your current eating habits..."
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
@@ -245,7 +279,9 @@ export default function WelcomeScreen() {
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={profile.medicalConditions}
|
||||
onChangeText={(text) => setProfile({ ...profile, medicalConditions: text })}
|
||||
onChangeText={(text) =>
|
||||
setProfile({ ...profile, medicalConditions: text })
|
||||
}
|
||||
placeholder="Any medical conditions we should know about..."
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
@@ -258,51 +294,51 @@ export default function WelcomeScreen() {
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'Saving...' : 'Complete Profile'}
|
||||
{loading ? "Saving..." : "Complete Profile"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
textAlign: 'center',
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
color: "#666",
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
textAlign: "center",
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
fontWeight: "600",
|
||||
marginBottom: 12,
|
||||
color: '#333',
|
||||
color: "#333",
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
color: "#666",
|
||||
marginBottom: 12,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
flexDirection: "row",
|
||||
marginBottom: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
@@ -310,105 +346,105 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
fontWeight: "500",
|
||||
marginBottom: 6,
|
||||
color: '#333',
|
||||
color: "#333",
|
||||
},
|
||||
input: {
|
||||
backgroundColor: 'white',
|
||||
backgroundColor: "white",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderColor: "#ddd",
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
genderRow: {
|
||||
flexDirection: 'row',
|
||||
flexDirection: "row",
|
||||
},
|
||||
genderButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
marginRight: 4,
|
||||
},
|
||||
genderButtonSelected: {
|
||||
backgroundColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: "#3b82f6",
|
||||
borderColor: "#3b82f6",
|
||||
},
|
||||
genderButtonText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
color: "#666",
|
||||
},
|
||||
genderButtonTextSelected: {
|
||||
color: 'white',
|
||||
color: "white",
|
||||
},
|
||||
activityOption: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
backgroundColor: 'white',
|
||||
backgroundColor: "white",
|
||||
},
|
||||
activityOptionSelected: {
|
||||
backgroundColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: "#3b82f6",
|
||||
borderColor: "#3b82f6",
|
||||
},
|
||||
activityText: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
color: "#333",
|
||||
},
|
||||
activityTextSelected: {
|
||||
color: 'white',
|
||||
color: "white",
|
||||
},
|
||||
goalsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
marginHorizontal: -4,
|
||||
},
|
||||
goalButton: {
|
||||
backgroundColor: 'white',
|
||||
backgroundColor: "white",
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
margin: 4,
|
||||
},
|
||||
goalButtonSelected: {
|
||||
backgroundColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: "#3b82f6",
|
||||
borderColor: "#3b82f6",
|
||||
},
|
||||
goalButtonText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
color: "#666",
|
||||
},
|
||||
goalButtonTextSelected: {
|
||||
color: 'white',
|
||||
color: "white",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#3b82f6',
|
||||
backgroundColor: "#3b82f6",
|
||||
paddingVertical: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#9ca3af',
|
||||
backgroundColor: "#9ca3af",
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontWeight: "600",
|
||||
},
|
||||
})
|
||||
});
|
||||
@@ -1,6 +1,27 @@
|
||||
export const API_BASE_URL = __DEV__
|
||||
? "https://fd87cefe27ae.ngrok-free.app"
|
||||
: "https://your-production-url.com";
|
||||
// Use environment variable for API URL
|
||||
// Set EXPO_PUBLIC_API_URL in your .env file
|
||||
const getApiBaseUrl = (): string => {
|
||||
const envUrl = process.env.EXPO_PUBLIC_API_URL;
|
||||
|
||||
if (envUrl) {
|
||||
return envUrl;
|
||||
}
|
||||
|
||||
// Fallback for development if not set
|
||||
if (__DEV__) {
|
||||
console.warn(
|
||||
"EXPO_PUBLIC_API_URL not set in .env - using default localhost",
|
||||
);
|
||||
return "http://localhost:3000";
|
||||
}
|
||||
|
||||
// Production MUST have URL set
|
||||
throw new Error(
|
||||
"EXPO_PUBLIC_API_URL must be set in production environment variables",
|
||||
);
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
|
||||
@@ -1,153 +1,187 @@
|
||||
import { API_BASE_URL, API_ENDPOINTS } from '../config/api';
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
|
||||
import log from "../utils/logger";
|
||||
|
||||
export interface FitnessGoal {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string;
|
||||
goalType: 'weight_target' | 'strength_milestone' | 'endurance_target' | 'flexibility_goal' | 'habit_building' | 'custom';
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
startDate: string;
|
||||
targetDate?: string;
|
||||
completedDate?: string;
|
||||
status: 'active' | 'completed' | 'abandoned' | 'paused';
|
||||
progress: number;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string;
|
||||
goalType:
|
||||
| "weight_target"
|
||||
| "strength_milestone"
|
||||
| "endurance_target"
|
||||
| "flexibility_goal"
|
||||
| "habit_building"
|
||||
| "custom";
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
startDate: string;
|
||||
targetDate?: string;
|
||||
completedDate?: string;
|
||||
status: "active" | "completed" | "abandoned" | "paused";
|
||||
progress: number;
|
||||
priority: "low" | "medium" | "high";
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateGoalData {
|
||||
goalType: FitnessGoal['goalType'];
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
targetDate?: string;
|
||||
priority?: FitnessGoal['priority'];
|
||||
notes?: string;
|
||||
goalType: FitnessGoal["goalType"];
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
targetDate?: string;
|
||||
priority?: FitnessGoal["priority"];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class FitnessGoalsService {
|
||||
private async getAuthHeaders(token: string | null): Promise<any> {
|
||||
const headers: any = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
private async getAuthHeaders(token: string | null): Promise<any> {
|
||||
const headers: any = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
async getGoals(userId: string, token: string | null, status?: string): Promise<FitnessGoal[]> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
if (status && status !== 'all') {
|
||||
url += `&status=${status}`;
|
||||
}
|
||||
async getGoals(
|
||||
userId: string,
|
||||
token: string | null,
|
||||
status?: string,
|
||||
): Promise<FitnessGoal[]> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`;
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (status && status !== "all") {
|
||||
url += `&status=${status}`;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch goals: ${response.status}`);
|
||||
}
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching fitness goals:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch goals: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch fitness goals", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createGoal(goalData: CreateGoalData, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(goalData),
|
||||
});
|
||||
async createGoal(
|
||||
goalData: CreateGoalData,
|
||||
token: string | null,
|
||||
): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(goalData),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to create goal');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to create goal");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error creating fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
log.error("Failed to create fitness goal", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateGoal(id: string, updates: Partial<FitnessGoal>, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
async updateGoal(
|
||||
id: string,
|
||||
updates: Partial<FitnessGoal>,
|
||||
token: string | null,
|
||||
): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify(updates),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update goal');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update goal");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error updating fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
log.error("Failed to update fitness goal", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateProgress(id: string, currentValue: number, token: string | null): Promise<FitnessGoal> {
|
||||
return this.updateGoal(id, { currentValue }, token);
|
||||
async updateProgress(
|
||||
id: string,
|
||||
currentValue: number,
|
||||
token: string | null,
|
||||
): Promise<FitnessGoal> {
|
||||
return this.updateGoal(id, { currentValue }, token);
|
||||
}
|
||||
|
||||
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to complete goal");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
log.error("Failed to complete fitness goal", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
async deleteGoal(id: string, token: string | null): Promise<void> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to complete goal');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error completing fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteGoal(id: string, token: string | null): Promise<void> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete goal');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete goal");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete fitness goal", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fitnessGoalsService = new FitnessGoalsService();
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Environment variable validation for mobile app
|
||||
* Validates that all required environment variables are set
|
||||
*/
|
||||
|
||||
interface RequiredEnvVars {
|
||||
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: string;
|
||||
EXPO_PUBLIC_API_URL: string;
|
||||
}
|
||||
|
||||
interface OptionalEnvVars {
|
||||
EXPO_PUBLIC_APP_NAME?: string;
|
||||
EXPO_PUBLIC_APP_VERSION?: string;
|
||||
NODE_ENV?: string;
|
||||
}
|
||||
|
||||
type EnvVars = RequiredEnvVars & OptionalEnvVars;
|
||||
|
||||
/**
|
||||
* Validate required environment variables
|
||||
* Throws error if any required variable is missing
|
||||
*/
|
||||
export function validateEnv(): EnvVars {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required variables
|
||||
const requiredVars = [
|
||||
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY",
|
||||
"EXPO_PUBLIC_API_URL",
|
||||
] as const;
|
||||
|
||||
for (const varName of requiredVars) {
|
||||
if (!process.env[varName]) {
|
||||
errors.push(`Missing required environment variable: ${varName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = [
|
||||
"❌ Environment validation failed:",
|
||||
...errors,
|
||||
"",
|
||||
"Please check your .env file and ensure all required variables are set.",
|
||||
"See .env.example for the list of required variables.",
|
||||
"",
|
||||
"To create your .env file:",
|
||||
"1. Copy .env.example to .env",
|
||||
"2. Fill in the required values",
|
||||
].join("\n");
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
const apiUrl = process.env.EXPO_PUBLIC_API_URL!;
|
||||
try {
|
||||
new URL(apiUrl);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Invalid EXPO_PUBLIC_API_URL: "${apiUrl}". Must be a valid URL (e.g., http://192.168.1.100:3000)`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY:
|
||||
process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!,
|
||||
EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL!,
|
||||
EXPO_PUBLIC_APP_NAME: process.env.EXPO_PUBLIC_APP_NAME || "FitAI",
|
||||
EXPO_PUBLIC_APP_VERSION: process.env.EXPO_PUBLIC_APP_VERSION || "1.0.0",
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validated environment variables
|
||||
* Caches the result after first validation
|
||||
*/
|
||||
let cachedEnv: EnvVars | null = null;
|
||||
|
||||
export function getEnv(): EnvVars {
|
||||
if (!cachedEnv) {
|
||||
cachedEnv = validateEnv();
|
||||
}
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in development mode
|
||||
*/
|
||||
export function isDevelopment(): boolean {
|
||||
return __DEV__;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production mode
|
||||
*/
|
||||
export function isProduction(): boolean {
|
||||
return !__DEV__;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Error handling utilities for type-safe error processing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clerk error structure
|
||||
*/
|
||||
interface ClerkError {
|
||||
errors?: Array<{
|
||||
message: string;
|
||||
code?: string;
|
||||
}>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if error is a Clerk error
|
||||
*/
|
||||
function isClerkError(error: unknown): error is ClerkError {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
("errors" in error || "message" in error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error type
|
||||
* Handles Clerk errors, standard Error objects, and unknown types
|
||||
*
|
||||
* @param error - The caught error
|
||||
* @param fallback - Fallback message if no message can be extracted
|
||||
* @returns Error message string
|
||||
*/
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "An unknown error occurred",
|
||||
): string {
|
||||
// Handle Clerk errors with nested error array
|
||||
if (isClerkError(error)) {
|
||||
if (error.errors?.[0]?.message) {
|
||||
return error.errors[0].message;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle standard Error objects
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// Handle string errors
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle objects with message property
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"message" in error &&
|
||||
typeof (error as Record<string, unknown>).message === "string"
|
||||
) {
|
||||
return (error as { message: string }).message;
|
||||
}
|
||||
|
||||
// Fallback for unknown types
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Clerk error code if available
|
||||
*/
|
||||
export function getClerkErrorCode(error: unknown): string | undefined {
|
||||
if (isClerkError(error) && error.errors?.[0]?.code) {
|
||||
return error.errors[0].code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract gymId from Clerk user's public metadata
|
||||
*
|
||||
* @param metadata - Clerk publicMetadata object
|
||||
* @returns gymId string or null if not found
|
||||
*/
|
||||
export function getGymIdFromMetadata(metadata: unknown): string | null {
|
||||
if (
|
||||
typeof metadata === "object" &&
|
||||
metadata !== null &&
|
||||
"gymId" in metadata &&
|
||||
typeof (metadata as Record<string, unknown>).gymId === "string"
|
||||
) {
|
||||
return (metadata as { gymId: string }).gymId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Centralized logging utility for FitAI Mobile App
|
||||
* Provides structured logging with appropriate log levels
|
||||
*
|
||||
* In production, logs can be sent to a remote logging service
|
||||
* For now, we use console with prefixes for easy filtering
|
||||
*/
|
||||
|
||||
/**
|
||||
* Log levels
|
||||
*/
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
/**
|
||||
* Check if we're in development mode
|
||||
*/
|
||||
const isDevelopment = __DEV__;
|
||||
|
||||
/**
|
||||
* Log level configuration
|
||||
* - Development: debug and above
|
||||
* - Production: info and above
|
||||
*/
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
const currentLogLevel: LogLevel = isDevelopment ? "debug" : "info";
|
||||
const currentLogLevelValue = LOG_LEVELS[currentLogLevel];
|
||||
|
||||
/**
|
||||
* Check if a log level should be output
|
||||
*/
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= currentLogLevelValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format log message with timestamp and level
|
||||
*/
|
||||
function formatMessage(level: LogLevel, message: string): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized logger
|
||||
*/
|
||||
export const log = {
|
||||
/**
|
||||
* Log debug information (only in development)
|
||||
*/
|
||||
debug: (message: string, data?: Record<string, unknown>) => {
|
||||
if (shouldLog("debug")) {
|
||||
console.log(formatMessage("debug", message), data || "");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log general information
|
||||
*/
|
||||
info: (message: string, data?: Record<string, unknown>) => {
|
||||
if (shouldLog("info")) {
|
||||
console.log(formatMessage("info", message), data || "");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log warnings
|
||||
*/
|
||||
warn: (message: string, data?: Record<string, unknown>) => {
|
||||
if (shouldLog("warn")) {
|
||||
console.warn(formatMessage("warn", message), data || "");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log errors with optional error object
|
||||
*/
|
||||
error: (message: string, error?: unknown, data?: Record<string, unknown>) => {
|
||||
if (shouldLog("error")) {
|
||||
const errorInfo: Record<string, unknown> = {
|
||||
...(data || {}),
|
||||
};
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorInfo.error = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
} else if (error) {
|
||||
errorInfo.error = error;
|
||||
}
|
||||
|
||||
console.error(formatMessage("error", message), errorInfo);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log API requests
|
||||
*/
|
||||
apiRequest: (method: string, url: string, data?: Record<string, unknown>) => {
|
||||
log.debug(`API ${method} ${url}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log API responses
|
||||
*/
|
||||
apiResponse: (
|
||||
method: string,
|
||||
url: string,
|
||||
status: number,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
const level = status >= 400 ? "error" : status >= 300 ? "warn" : "info";
|
||||
if (level === "error") {
|
||||
log.error(`API ${method} ${url} - ${status}`, undefined, data);
|
||||
} else if (level === "warn") {
|
||||
log.warn(`API ${method} ${url} - ${status}`, data);
|
||||
} else {
|
||||
log.debug(`API ${method} ${url} - ${status}`, data);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log authentication events
|
||||
*/
|
||||
auth: (event: string, data?: Record<string, unknown>) => {
|
||||
log.info(`Auth: ${event}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log navigation events
|
||||
*/
|
||||
navigation: (screen: string, data?: Record<string, unknown>) => {
|
||||
log.debug(`Navigation: ${screen}`, data);
|
||||
},
|
||||
};
|
||||
|
||||
export default log;
|
||||
|
||||
/**
|
||||
* TODO: In production, consider integrating with:
|
||||
* - Sentry for error tracking
|
||||
* - LogRocket for session replay
|
||||
* - Custom logging service for analytics
|
||||
*/
|
||||
Reference in new issue
Block a user