Compare commits

...
Author SHA1 Message Date
dimitar bac7df33e8 Merge branch 'screen2' 2026-03-31 19:46:00 +02:00
dimitar 178ad3fa90 db 2026-03-31 19:44:55 +02:00
dimitar ef9f39e564 add resilient self-plan generation with provider fallback 2026-03-31 19:43:19 +02:00
dimitar 73218402f6 fix self ai plan generation authorization and error handling 2026-03-31 19:31:45 +02:00
dimitar c877577fba fix ai activity plan conversion and immediate goals refresh 2026-03-31 19:21:00 +02:00
dimitar e119f0923c pause previous ai-linked goals on self plan regeneration 2026-03-31 19:10:17 +02:00
dimitar a65b3cac08 add client self-service ai activity plans on goals screen 2026-03-31 18:51:30 +02:00
dimitar 0825bb3d65 Merge branch 'workout' 2026-03-31 18:19:12 +02:00
dimitar 6740dcb18f android buuild clean uo p 2026-03-31 18:18:36 +02:00
dimitar 12d6c07186 fix step progress header overflow on home card 2026-03-31 18:16:59 +02:00
dimitar 5010a579d6 add daily pedometer steps metric on home screen 2026-03-31 18:00:35 +02:00
dimitar 2cff8eafbd add home workout quick action with attendance check-in/out 2026-03-31 17:38:12 +02:00
dimitar 275248fc35 dbs 2026-03-31 17:18:41 +02:00
dimitar 4c2e97b66d Merge branch 'screen1' 2026-03-31 17:05:43 +02:00
dimitar 21afb085e3 scan food 2026-03-31 16:55:55 +02:00
dimitar ca64a100b6 integrate barcode scan with openfoodfacts and meal type selection 2026-03-31 16:36:18 +02:00
dimitar 3c3dfb6cd6 hydration calories persistance 2026-03-31 16:17:08 +02:00
dimitar 871f33bf5a dfg 2026-03-31 15:57:23 +02:00
dimitar c5dde63355 dbs 2026-03-31 13:48:39 +02:00
dimitar cd13333b52 stabilize membership loading and home motivational message 2026-03-30 20:24:13 +02:00
dimitar a620921202 Merge branch 'uifix' 2026-03-29 20:08:39 +02:00
dimitar ed14c57749 normalize mobile auth and profile ui to theme components 2026-03-29 20:07:33 +02:00
dimitar 7ada05da6a db up 2026-03-29 19:54:17 +02:00
dimitar 50ece15089 add membership features endpoint and use it in mobile 2026-03-29 19:51:36 +02:00
dimitar 091cb5ba85 Merge branch 'planDef' 2026-03-29 16:26:38 +02:00
dimitar ebfd633a11 enforce membership feature access in api and surface plans in admin 2026-03-29 16:22:45 +02:00
dimitar 1f4800c055 membership 2026-03-29 16:05:10 +02:00
dimitar 0ddac10c59 Merge branch 'phase-9-release-hardening' 2026-03-29 15:54:11 +02:00
dimitar 573690ab02 Merge branch 'phase-8-api-contract-tightening' 2026-03-29 15:54:00 +02:00
dimitar efbfa58c10 Merge branch 'phase-7-query-cache-consolidation' 2026-03-29 15:53:47 +02:00
dimitar 7ad1e5133e Merge branch 'phase-6-coverage-expansion' 2026-03-29 15:53:27 +02:00
dimitar 9c3d3f5b72 add release hardening checklist for admin and mobile 2026-03-29 15:49:47 +02:00
dimitar ff9f3d582a standardize mobile api response parsing with shared helper 2026-03-29 15:48:58 +02:00
dimitar 60d7a7963d initialize react-query provider for mobile app 2026-03-29 15:46:59 +02:00
dimitar 0eede3fa91 add mobile notifications api unit tests 2026-03-29 15:46:16 +02:00
dimitar b1f84722af Merge branch 'nextPhase' 2026-03-29 15:41:09 +02:00
dimitar 34e88bdde5 reset mobile context caches on user identity changes 2026-03-29 15:38:51 +02:00
dimitar aa662a9b74 add mobile api unit tests for gyms and recommendations 2026-03-29 15:34:43 +02:00
dimitar 80110acbf7 standardize mobile api transport for gyms and core services 2026-03-29 15:31:06 +02:00
dimitar c36cad9c54 Merge branch 'testRep' 2026-03-29 11:27:14 +02:00
dimitar 2ecb8a3515 android 2026-03-29 11:25:28 +02:00
dimitar 8275da687b fix mobile profile flow and onboarding edge cases 2026-03-29 11:23:49 +02:00
dimitar 10b58245f5 harden admin api authorization and add authz regression tests 2026-03-29 11:23:49 +02:00
dimitar 272c9b36dd todos 2026-03-29 09:52:22 +02:00
dimitar e586662c19 reports fully implemented 2026-03-19 05:04:12 +01:00
dimitar 06973ccfb2 assigment and reports groundwork 2026-03-19 04:36:35 +01:00
dimitar 74f0d0dbed user report gtound work 2026-03-19 03:37:15 +01:00
dimitar 1be2de05fa todos 2026-03-19 01:49:07 +01:00
dimitar 1be7dc2858 user id -> user name
in attendace
2026-03-19 01:37:10 +01:00
dimitar 36f52b42c6 ash 2026-03-19 01:32:18 +01:00
dimitar 3e30fae173 attendace, user stats 2026-03-19 01:29:09 +01:00
dimitar d6a77fcd23 various fixes
raw sql to drizzle
2026-03-19 00:57:04 +01:00
dimitar ffd3aabc55 delete gym working 2026-03-19 00:44:39 +01:00
dimitar 8cac57ed67 db updated 2026-03-19 00:39:23 +01:00
dimitar d4fae890bb invitation flow finished 2026-03-18 23:58:14 +01:00
286 changed files with 14092 additions and 19152 deletions

No files matched your search

Binary file not shown.
+531 -5
View File
@@ -13,6 +13,7 @@
"@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@react-email/components": "^1.0.8",
"@react-email/render": "^2.0.4",
@@ -32,6 +33,8 @@
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.553.0",
"next": "^16.0.1",
"pino": "^10.3.1",
@@ -574,10 +577,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"dev": true,
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1027,6 +1029,44 @@
"resolved": "../../packages/shared",
"link": true
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -2417,12 +2457,85 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -2507,6 +2620,21 @@
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
@@ -2592,6 +2720,38 @@
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
@@ -2681,6 +2841,67 @@
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
@@ -2784,6 +3005,86 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@react-email/body": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.1.tgz",
@@ -3019,7 +3320,6 @@
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.4.tgz",
"integrity": "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"html-to-text": "^9.0.5",
"prettier": "^3.5.3"
@@ -3595,6 +3895,19 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
@@ -3641,6 +3954,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -4891,6 +5211,16 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -5268,6 +5598,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5569,6 +5919,18 @@
"node": ">=18"
}
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5583,6 +5945,16 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -6092,6 +6464,16 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -7135,6 +7517,17 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -7166,6 +7559,12 @@
"bser": "2.1.1"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -7907,6 +8306,20 @@
"node": ">=14"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@@ -8156,6 +8569,12 @@
"node": ">=12"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -9897,6 +10316,33 @@
"node": ">=6"
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf-autotable": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz",
"integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==",
"license": "MIT",
"peerDependencies": {
"jspdf": "^2 || ^3 || ^4"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -11119,6 +11565,12 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -11243,6 +11695,13 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11810,6 +12269,16 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -12102,6 +12571,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -12242,6 +12718,16 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -12943,6 +13429,16 @@
"node": ">=8"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
@@ -13333,6 +13829,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/svix": {
"version": "1.84.1",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz",
@@ -13566,6 +14072,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -14212,6 +14728,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
+3
View File
@@ -19,6 +19,7 @@
"@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@react-email/components": "^1.0.8",
"@react-email/render": "^2.0.4",
@@ -38,6 +39,8 @@
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.553.0",
"next": "^16.0.1",
"pino": "^10.3.1",
@@ -5,6 +5,7 @@ import { ensureUserSynced } from "@/lib/sync-user";
import { successResponse } from "@/lib/api/responses";
import { db as rawDb, sql } from "@fitai/database";
import { getUsersByGym, getClientsByGym } from "@/lib/gym-context";
import log from "@/lib/logger";
interface UserGrowthPoint {
label: string;
@@ -158,7 +159,7 @@ export async function GET(req: NextRequest) {
return successResponse({ analytics: analyticsData });
} catch (error) {
console.error("Analytics error:", error);
log.error("Analytics error", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
@@ -34,7 +34,33 @@ export async function GET(req: NextRequest) {
? await getAttendanceByGym(targetGymId)
: await db.getAllAttendance();
return successResponse({ records: attendance });
// Get all users to enrich attendance with user names
const allUsers = await db.getAllUsers();
const userMap = new Map(
allUsers.map((u) => [
u.id,
{
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
},
]),
);
// Enrich attendance records with user information
const enrichedAttendance = attendance.map((record) => {
const userInfo = userMap.get(record.userId);
return {
...record,
userName: userInfo
? `${userInfo.firstName} ${userInfo.lastName}`.trim() ||
userInfo.email
: record.userId,
userEmail: userInfo?.email,
};
});
return successResponse({ records: enrichedAttendance });
} catch (error) {
console.error("Admin attendance error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
@@ -0,0 +1,103 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import { POST } from "../route";
jest.mock("@clerk/nextjs/server", () => ({
auth: jest.fn(),
}));
jest.mock("@/lib/database", () => ({
getDatabase: jest.fn(),
}));
jest.mock("@/lib/sync-user", () => ({
ensureUserSynced: jest.fn(),
}));
jest.mock("@/lib/clerk-helpers", () => ({
setUserRole: jest.fn(),
}));
describe("POST /api/admin/set-role", () => {
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock;
const mockEnsureUserSynced = require("@/lib/sync-user")
.ensureUserSynced as jest.Mock;
const mockSetUserRole = require("@/lib/clerk-helpers")
.setUserRole as jest.Mock;
const mockDb = {
getUserById: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetDatabase.mockResolvedValue(mockDb);
});
it("returns 403 when admin tries to assign role across gyms", async () => {
mockAuth.mockResolvedValue({ userId: "admin_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "admin_1",
role: "admin",
gymId: "gym_a",
});
mockDb.getUserById.mockResolvedValue({
id: "user_2",
role: "client",
gymId: "gym_b",
});
const request = new NextRequest("http://localhost/api/admin/set-role", {
method: "POST",
body: JSON.stringify({
targetUserId: "user_2",
role: "trainer",
}),
});
const response = await POST(request);
expect(response.status).toBe(403);
expect(mockSetUserRole).not.toHaveBeenCalled();
});
it("allows superAdmin to assign roles across gyms", async () => {
mockAuth.mockResolvedValue({ userId: "super_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "super_1",
role: "superAdmin",
gymId: null,
});
mockDb.getUserById.mockResolvedValue({
id: "user_2",
role: "client",
gymId: "gym_b",
});
mockSetUserRole.mockResolvedValue({
id: "user_2",
emailAddresses: [{ emailAddress: "user2@example.com" }],
firstName: "User",
lastName: "Two",
publicMetadata: { role: "admin" },
});
const request = new NextRequest("http://localhost/api/admin/set-role", {
method: "POST",
body: JSON.stringify({
targetUserId: "user_2",
role: "admin",
}),
});
const response = await POST(request);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(mockSetUserRole).toHaveBeenCalledWith("user_2", "admin");
});
});
+66 -20
View File
@@ -1,6 +1,9 @@
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
import { setUserRole, isAdmin, type UserRole } from '@/lib/clerk-helpers';
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { USER_ROLES, type UserRole } from "@fitai/shared";
import { setUserRole } from "@/lib/clerk-helpers";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
export async function POST(req: Request) {
try {
@@ -8,16 +11,27 @@ export async function POST(req: Request) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
const currentUser = await ensureUserSynced(userId, db);
if (!currentUser) {
return NextResponse.json(
{ error: "Forbidden: user not found" },
{ status: 403 },
);
}
// Check if the requesting user is an admin
const requestingUserIsAdmin = await isAdmin(userId);
const requestingUserIsAdmin =
currentUser.role === "admin" || currentUser.role === "superAdmin";
if (!requestingUserIsAdmin) {
return NextResponse.json(
{ error: 'Forbidden: Admin access required' },
{ status: 403 }
{ error: "Forbidden: Admin access required" },
{ status: 403 },
);
}
@@ -26,25 +40,57 @@ export async function POST(req: Request) {
const { targetUserId, role } = body;
// Validate inputs
if (!targetUserId || typeof targetUserId !== 'string') {
if (!targetUserId || typeof targetUserId !== "string") {
return NextResponse.json(
{ error: 'Invalid or missing targetUserId' },
{ status: 400 }
{ error: "Invalid or missing targetUserId" },
{ status: 400 },
);
}
if (!role || !['admin', 'trainer', 'client'].includes(role)) {
if (!role || !USER_ROLES.includes(role as UserRole)) {
return NextResponse.json(
{ error: 'Invalid role. Must be admin, trainer, or client' },
{ status: 400 }
{
error: `Invalid role. Must be one of: ${USER_ROLES.join(", ")}`,
},
{ status: 400 },
);
}
const allowedRolesByRequester: Record<UserRole, UserRole[]> = {
superAdmin: ["superAdmin", "admin", "trainer", "client"],
admin: ["admin", "trainer", "client"],
trainer: [],
client: [],
};
const allowedTargetRoles = allowedRolesByRequester[currentUser.role];
if (!allowedTargetRoles.includes(role as UserRole)) {
return NextResponse.json(
{ error: `Forbidden: cannot assign role '${role}'` },
{ status: 403 },
);
}
// Prevent admin from changing their own role
if (userId === targetUserId) {
return NextResponse.json(
{ error: 'Cannot change your own role' },
{ status: 400 }
{ error: "Cannot change your own role" },
{ status: 400 },
);
}
const targetUser = await db.getUserById(targetUserId);
if (!targetUser) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (
currentUser.role !== "superAdmin" &&
(!currentUser.gymId || targetUser.gymId !== currentUser.gymId)
) {
return NextResponse.json(
{ error: "Cannot change roles for users from other gyms" },
{ status: 403 },
);
}
@@ -63,15 +109,15 @@ export async function POST(req: Request) {
},
});
} catch (error) {
console.error('Error setting user role:', error);
console.error("Error setting user role:", error);
if (error instanceof Error && error.message.includes('not found')) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
if (error instanceof Error && error.message.includes("not found")) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+14 -5
View File
@@ -3,20 +3,26 @@ import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import { successResponse } from "@/lib/api/responses";
import log from "@/lib/logger";
export async function GET(req: Request) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
const user = await ensureUserSynced(userId, db);
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
return new NextResponse("Forbidden", { status: 403 });
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (user.role === "admin" && !user.gymId) {
return new NextResponse("Admin gymId not set", { status: 400 });
return NextResponse.json(
{ error: "Admin gymId not set" },
{ status: 400 },
);
}
const url = new URL(req.url);
@@ -54,7 +60,10 @@ export async function GET(req: Request) {
return successResponse({ stats });
} catch (error) {
console.error("Dashboard stats error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
log.error("Dashboard stats error", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+27 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "../../../../lib/database/index";
import log from "@/lib/logger";
import { userSchema } from "@/lib/validation/schemas";
@@ -7,6 +8,8 @@ import {
validateRequestBody,
validationErrorResponse,
} from "@/lib/validation/helpers";
import { ensureUserSynced } from "@/lib/sync-user";
import { getUsersByGym } from "@/lib/gym-context";
export async function POST(request: NextRequest) {
try {
@@ -68,8 +71,31 @@ export async function POST(request: NextRequest) {
export async function GET() {
try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
const allUsers = await db.getAllUsers();
const currentUser = await ensureUserSynced(clerkUserId, db);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const canViewUsers =
currentUser.role === "superAdmin" || currentUser.role === "admin";
if (!canViewUsers) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const allUsers =
currentUser.role === "superAdmin"
? await db.getAllUsers()
: currentUser.gymId
? await getUsersByGym(currentUser.gymId)
: [];
const usersWithoutPassword = allUsers.map(
({ password: _, ...user }) => user,
);
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
/**
* GET /api/fitness-profile/history
* Get fitness profile history for a user
*
* Query params:
* - userId: string (required)
* - startDate: YYYY-MM-DD (optional)
* - endDate: YYYY-MM-DD (optional)
*/
export async function GET(request: NextRequest) {
try {
const { userId: authUserId } = await auth();
if (!authUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
const startDateStr = searchParams.get("startDate");
const endDateStr = searchParams.get("endDate");
if (!userId) {
return NextResponse.json(
{ error: "userId is required" },
{ status: 400 },
);
}
// Convert date strings to Date objects if provided
const startDate = startDateStr ? new Date(startDateStr) : undefined;
const endDate = endDateStr ? new Date(endDateStr) : undefined;
const db = await getDatabase();
const history = await db.getFitnessProfileHistory(
userId,
startDate,
endDate,
);
return NextResponse.json({ history });
} catch (error) {
console.error("Get fitness profile history error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,156 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getUserMembershipContext } from "@/lib/membership/access";
import log from "@/lib/logger";
interface OpenFoodFactsProduct {
product_name?: string;
product_name_en?: string;
brands?: string;
image_url?: string;
image_front_url?: string;
serving_size?: string;
nutriments?: {
[key: string]: number | string | undefined;
"energy-kcal_serving"?: number;
"energy-kcal_100g"?: number;
proteins_serving?: number;
proteins_100g?: number;
carbohydrates_serving?: number;
carbohydrates_100g?: number;
fat_serving?: number;
fat_100g?: number;
};
}
interface OpenFoodFactsResponse {
status: number;
code: string;
product?: OpenFoodFactsProduct;
}
function normalizeBarcode(rawCode: string): string {
return rawCode.replace(/\D/g, "").trim();
}
function isSupportedBarcode(code: string): boolean {
return [8, 12, 13].includes(code.length);
}
function getNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function buildProductPayload(code: string, product: OpenFoodFactsProduct) {
const caloriesPerServing =
getNumber(product.nutriments?.["energy-kcal_serving"]) ??
getNumber(product.nutriments?.["energy-kcal_100g"]) ??
0;
const protein =
getNumber(product.nutriments?.proteins_serving) ??
getNumber(product.nutriments?.proteins_100g);
const carbs =
getNumber(product.nutriments?.carbohydrates_serving) ??
getNumber(product.nutriments?.carbohydrates_100g);
const fat =
getNumber(product.nutriments?.fat_serving) ??
getNumber(product.nutriments?.fat_100g);
return {
barcode: code,
name: product.product_name || product.product_name_en || "Unknown Product",
brand: product.brands || null,
imageUrl: product.image_url || product.image_front_url || null,
servingSize: product.serving_size || "1 serving",
caloriesPerServing: Math.max(0, Math.round(caloriesPerServing)),
macros: {
protein: protein ?? null,
carbs: carbs ?? null,
fat: fat ?? null,
},
source: "openfoodfacts" as const,
};
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ code: string }> },
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Barcode food scan is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const { code: rawCode } = await params;
const code = normalizeBarcode(rawCode);
if (!isSupportedBarcode(code)) {
return NextResponse.json(
{ error: "Invalid barcode. Use EAN-8, UPC-A, or EAN-13 formats." },
{ status: 400 },
);
}
const response = await fetch(
`https://world.openfoodfacts.org/api/v2/product/${code}.json`,
{
headers: {
"User-Agent": "FitAI/1.0 (fitai.app)",
},
cache: "no-store",
},
);
if (!response.ok) {
log.warn("OpenFoodFacts lookup failed", {
status: response.status,
barcode: code,
});
return NextResponse.json(
{ error: "Food lookup service unavailable. Please try again." },
{ status: 503 },
);
}
const payload = (await response.json()) as OpenFoodFactsResponse;
if (payload.status !== 1 || !payload.product) {
return NextResponse.json(
{ error: "Product not found in OpenFoodFacts" },
{ status: 404 },
);
}
return NextResponse.json({
success: true,
data: buildProductPayload(code, payload.product),
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Failed barcode food lookup", error);
return NextResponse.json(
{ error: "Failed to lookup food barcode" },
{ status: 500 },
);
}
}
+27 -38
View File
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database";
import { db, users as usersTable } from "@fitai/database";
import { db, users as usersTable, gyms as gymsTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
async function ensureGymsTable() {
@@ -30,53 +31,38 @@ export async function DELETE(
const { userId } = await auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Ensure user is synced
const currentUser = await ensureUserSynced(userId, {
getUserById: async (id: string) => {
const row = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.get();
return row
? {
id: row.id,
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
password: row.password ?? "",
phone: row.phone ?? undefined,
role: row.role,
imageUrl: undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
: null;
},
} as any);
const appDb = await getDatabase();
const currentUser = await ensureUserSynced(userId, appDb);
// Only superAdmin can delete gyms
if (!currentUser || currentUser.role !== "superAdmin") {
return new NextResponse("Forbidden - Only superAdmin can delete gyms", {
status: 403,
});
return NextResponse.json(
{ error: "Forbidden - Only superAdmin can delete gyms" },
{ status: 403 },
);
}
await ensureGymsTable();
// Check if gym exists
const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`);
if (gymRows.length === 0) {
return new NextResponse("Gym not found", { status: 404 });
// Check if gym exists using Drizzle ORM
const existingGym = await db
.select()
.from(gymsTable)
.where(eq(gymsTable.id, gymId))
.get();
if (!existingGym) {
return NextResponse.json({ error: "Gym not found" }, { status: 404 });
}
// Soft delete - mark as inactive
await db.run(
sql`UPDATE gyms SET status = 'inactive', updated_at = ${Date.now()} WHERE id = ${gymId}`,
);
// Soft delete - mark as inactive using Drizzle ORM
await db
.update(gymsTable)
.set({ status: "inactive", updatedAt: new Date() })
.where(eq(gymsTable.id, gymId));
return NextResponse.json({
success: true,
@@ -84,6 +70,9 @@ export async function DELETE(
});
} catch (error) {
log.error("Failed to delete gym", error);
return new NextResponse("Internal Server Error", { status: 500 });
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}
@@ -1,7 +1,10 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database";
import { db } from "@fitai/database";
import { db, gyms as gymsTable } from "@fitai/database";
import log from "@/lib/logger";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
async function ensureGymsTable() {
await db.run(sql`
@@ -24,15 +27,45 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const appDb = await getDatabase();
const currentUser = await ensureUserSynced(clerkUserId, appDb);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id: gymId } = await params;
const canViewGymStats =
currentUser.role === "superAdmin" || currentUser.role === "admin";
if (!canViewGymStats) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (currentUser.role !== "superAdmin" && currentUser.gymId !== gymId) {
return NextResponse.json(
{ error: "Forbidden - Cannot access other gym's data" },
{ status: 403 },
);
}
await ensureGymsTable();
// Get gym info
const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`);
if (gymRows.length === 0) {
return new NextResponse("Gym not found", { status: 404 });
// Get gym info using Drizzle ORM
const gym = await db
.select()
.from(gymsTable)
.where(eq(gymsTable.id, gymId))
.get();
if (!gym) {
return NextResponse.json({ error: "Gym not found" }, { status: 404 });
}
const gym = gymRows[0];
// Get user counts
const usersResult = await db.all(
@@ -76,7 +109,8 @@ export async function GET(
}
// Get recent activity (attendance in last 30 days)
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
// Database stores timestamps in seconds, so convert milliseconds to seconds
const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
const attendanceResult = (await db.all(
sql`SELECT COUNT(*) as count FROM attendance
WHERE user_id IN (SELECT id FROM users WHERE gym_id = ${gymId})
@@ -100,6 +134,9 @@ export async function GET(
return NextResponse.json({ gym, stats });
} catch (error) {
log.error("Failed to get gym stats", error);
return new NextResponse("Internal Server Error", { status: 500 });
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,88 @@
/**
* @jest-environment node
*/
import { GET } from "../route";
jest.mock("@clerk/nextjs/server", () => ({
auth: jest.fn(),
}));
jest.mock("@/lib/database", () => ({
getDatabase: jest.fn(),
}));
jest.mock("@/lib/sync-user", () => ({
ensureUserSynced: jest.fn(),
}));
jest.mock("@fitai/database", () => ({
eq: jest.fn(() => ({})),
sql: jest.fn((strings: TemplateStringsArray) => strings.join("")),
db: {
run: jest.fn(),
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({
orderBy: jest.fn(() => ({
all: jest.fn().mockResolvedValue([
{ id: "gym_a", status: "active", name: "Gym A" },
{ id: "gym_b", status: "active", name: "Gym B" },
]),
})),
})),
})),
})),
gyms: {
status: "active",
},
users: {},
},
gyms: {
status: "active",
},
users: {},
}));
describe("GET /api/gyms authz", () => {
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock;
const mockEnsureUserSynced = require("@/lib/sync-user")
.ensureUserSynced as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockGetDatabase.mockResolvedValue({});
});
it("returns only own gym for admin", async () => {
mockAuth.mockResolvedValue({ userId: "admin_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "admin_1",
role: "admin",
gymId: "gym_a",
});
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0].id).toBe("gym_a");
});
it("returns all gyms for superAdmin", async () => {
mockAuth.mockResolvedValue({ userId: "super_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "super_1",
role: "superAdmin",
gymId: null,
});
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(2);
});
});
+53 -67
View File
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database";
import { db, users as usersTable } from "@fitai/database";
import { db, users as usersTable, gyms as gymsTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
async function ensureGymsTable() {
@@ -23,10 +24,36 @@ async function ensureGymsTable() {
// Lists active gyms for selection (grid)
export async function GET() {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const appDb = await getDatabase();
const currentUser = await ensureUserSynced(userId, appDb);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (currentUser.role !== "admin" && currentUser.role !== "superAdmin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await ensureGymsTable();
const rows = await db.all(
sql`SELECT * FROM gyms WHERE status = 'active' ORDER BY created_at DESC`,
);
let rows = await db
.select()
.from(gymsTable)
.where(eq(gymsTable.status, "active"))
.orderBy(sql`created_at DESC`)
.all();
if (currentUser.role !== "superAdmin") {
if (!currentUser.gymId) {
return NextResponse.json({ error: "No gym assigned" }, { status: 403 });
}
rows = rows.filter((row) => row.id === currentUser.gymId);
}
return NextResponse.json(rows);
} catch (error) {
@@ -45,60 +72,8 @@ export async function POST(req: Request) {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
// Ensure our local DB has the user synced (role, etc.)
const currentUser = await ensureUserSynced(userId, {
// minimal facade for ensureUserSynced to work: it expects an object implementing part of IDatabase
getUserById: async (id: string) => {
const row = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.get();
return row
? {
id: row.id,
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
password: row.password ?? "",
phone: row.phone ?? undefined,
role: row.role,
imageUrl: undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
: null;
},
updateUser: async (id: string, updates: any) => {
await db
.update(usersTable)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(usersTable.id, id))
.run();
const row = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.get();
return row
? {
id: row.id,
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
password: row.password ?? "",
phone: row.phone ?? undefined,
role: row.role,
imageUrl: undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
: null;
},
} as any);
const appDb = await getDatabase();
const currentUser = await ensureUserSynced(userId, appDb);
if (
!currentUser ||
@@ -146,19 +121,30 @@ export async function POST(req: Request) {
}
const id = generateId();
const nowTs = Date.now();
const nowTs = new Date();
await db.run(
sql`INSERT INTO gyms (id, name, location, status, admin_user_id, created_at, updated_at)
VALUES (${id}, ${name}, ${location ?? null}, 'active', ${adminUserId!}, ${nowTs}, ${nowTs})`,
);
// Use Drizzle's insert method instead of raw SQL
await db.insert(gymsTable).values({
id,
name,
location: location ?? null,
status: "active",
adminUserId: adminUserId!,
createdAt: nowTs,
updatedAt: nowTs,
});
// Assign the admin to this gym immediately after creation
await db.run(
sql`UPDATE users SET gym_id = ${id}, updated_at = ${nowTs} WHERE id = ${adminUserId!}`,
);
await db
.update(usersTable)
.set({ gymId: id, updatedAt: nowTs })
.where(eq(usersTable.id, adminUserId!));
const created = await db.get(sql`SELECT * FROM gyms WHERE id = ${id}`);
const created = await db
.select()
.from(gymsTable)
.where(eq(gymsTable.id, id))
.get();
return NextResponse.json(created, { status: 201 });
} catch (error) {
log.error("Failed to create gym", error);
+175
View File
@@ -0,0 +1,175 @@
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
import { getUserMembershipContext } from "@/lib/membership/access";
export async function POST(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.hydrationTracking) {
return NextResponse.json(
{
error:
"Hydration tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const body = await req.json();
const { date, entries, totalWater, waterGoal } = body;
if (!date) {
return NextResponse.json({ error: "Date is required" }, { status: 400 });
}
// Check if entry already exists for this date
const existing = await db.getDailyHydration(userId, date);
let result;
if (existing) {
// Update existing entry
result = await db.updateDailyHydration(existing.id, {
entries,
totalWater: totalWater ?? existing.totalWater,
waterGoal: waterGoal ?? existing.waterGoal,
});
} else {
// Create new entry
result = await db.createDailyHydration({
userId,
date,
entries: entries || [],
totalWater: totalWater || 0,
waterGoal: waterGoal || 2000,
});
}
return NextResponse.json(result);
} catch (error) {
log.error("Failed to save hydration data", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function GET(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.hydrationTracking) {
return NextResponse.json(
{
error:
"Hydration tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const date = url.searchParams.get("date");
const startDate = url.searchParams.get("startDate");
const endDate = url.searchParams.get("endDate");
// Single date query
if (date) {
const result = await db.getDailyHydration(userId, date);
return NextResponse.json(result);
}
// Date range query
if (startDate && endDate) {
const results = await db.getDailyHydrationRange(
userId,
startDate,
endDate,
);
return NextResponse.json(results);
}
return NextResponse.json(
{ error: "Either 'date' or 'startDate' and 'endDate' are required" },
{ status: 400 },
);
} catch (error) {
log.error("Failed to fetch hydration data", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function DELETE(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.hydrationTracking) {
return NextResponse.json(
{
error:
"Hydration tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "ID is required" }, { status: 400 });
}
// Verify ownership before deletion
const existing = await db.getDailyHydrationById(id);
if (!existing) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (existing.userId !== userId) {
return NextResponse.json(
{ error: "Forbidden: You can only delete your own hydration data" },
{ status: 403 },
);
}
const success = await db.deleteDailyHydration(id);
if (success) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
} catch (error) {
log.error("Failed to delete hydration data", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
import { getAuthContext } from "@/lib/auth/context";
import log from "@/lib/logger";
/**
@@ -18,6 +19,7 @@ export async function POST(
}
const { id: invitationId } = await params;
const authContext = await getAuthContext();
// Fetch pending invitations to find the one being resent
const client = await clerkClient();
@@ -38,11 +40,23 @@ export async function POST(
}
const metadata = invitation.publicMetadata as any;
const invitationGymId =
(metadata?.gymId as string | null | undefined) ?? null;
const createdBy = (metadata?.createdBy as string | undefined) ?? undefined;
const canManageByRole =
authContext.role === "superAdmin" ||
(authContext.role === "admin" &&
authContext.gymId !== null &&
invitationGymId === authContext.gymId);
// Check if current user created this invitation
if (metadata?.createdBy !== userId) {
if (createdBy !== userId && !canManageByRole) {
return NextResponse.json(
{ error: "Forbidden - You can only resend invitations you created" },
{
error:
"Forbidden - You can only resend invitations you created or manage within your scope",
},
{ status: 403 },
);
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
import { getAuthContext } from "@/lib/auth/context";
import log from "@/lib/logger";
/**
@@ -18,6 +19,7 @@ export async function DELETE(
}
const { id: invitationId } = await params;
const authContext = await getAuthContext();
// Fetch pending invitations to find and verify the one being revoked
const client = await clerkClient();
@@ -38,11 +40,23 @@ export async function DELETE(
}
const metadata = invitation.publicMetadata as any;
const invitationGymId =
(metadata?.gymId as string | null | undefined) ?? null;
const createdBy = (metadata?.createdBy as string | undefined) ?? undefined;
const canManageByRole =
authContext.role === "superAdmin" ||
(authContext.role === "admin" &&
authContext.gymId !== null &&
invitationGymId === authContext.gymId);
// Check if current user created this invitation
if (metadata?.createdBy !== userId) {
if (createdBy !== userId && !canManageByRole) {
return NextResponse.json(
{ error: "Forbidden - You can only cancel invitations you created" },
{
error:
"Forbidden - You can only cancel invitations you created or manage within your scope",
},
{ status: 403 },
);
}
@@ -0,0 +1,92 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import { POST } from "../route";
jest.mock("@clerk/nextjs/server", () => ({
auth: jest.fn(),
clerkClient: jest.fn(),
}));
jest.mock("@/lib/auth/context", () => ({
getAuthContext: jest.fn(),
}));
jest.mock("@/lib/logger", () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
describe("POST /api/invitations authz", () => {
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
const mockClerkClient = require("@clerk/nextjs/server")
.clerkClient as jest.Mock;
const mockGetAuthContext = require("@/lib/auth/context")
.getAuthContext as jest.Mock;
const createInvitation = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockClerkClient.mockResolvedValue({
invitations: {
createInvitation,
},
});
});
it("blocks admin from inviting into another gym", async () => {
mockAuth.mockResolvedValue({ userId: "admin_1" });
mockGetAuthContext.mockResolvedValue({
userId: "admin_1",
role: "admin",
gymId: "gym_a",
});
const request = new NextRequest("http://localhost/api/invitations", {
method: "POST",
body: JSON.stringify({
inviteeEmail: "test@example.com",
roleAssigned: "trainer",
gymId: "gym_b",
}),
});
const response = await POST(request);
expect(response.status).toBe(403);
expect(createInvitation).not.toHaveBeenCalled();
});
it("allows superAdmin to invite with explicit gym", async () => {
mockAuth.mockResolvedValue({ userId: "super_1" });
mockGetAuthContext.mockResolvedValue({
userId: "super_1",
role: "superAdmin",
gymId: null,
});
createInvitation.mockResolvedValue({ id: "inv_1" });
const request = new NextRequest("http://localhost/api/invitations", {
method: "POST",
body: JSON.stringify({
inviteeEmail: "test@example.com",
roleAssigned: "admin",
gymId: "gym_b",
}),
});
const response = await POST(request);
expect(response.status).toBe(201);
expect(createInvitation).toHaveBeenCalledWith(
expect.objectContaining({
emailAddress: "test@example.com",
}),
);
});
});
+35 -75
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
import { getAuthContext } from "@/lib/auth/context";
import { validateGymAccess } from "@/lib/auth/permissions";
import { getInvitableRoles, validateGymAccess } from "@/lib/auth/permissions";
import log from "@/lib/logger";
/**
@@ -54,13 +54,13 @@ export async function GET(request: NextRequest) {
// Gym filter: SuperAdmin can see all, others only their gym
if (role === "superAdmin") {
// If gymId param provided, filter by it
if (targetGymId && metadata?.gymId !== targetGymId) {
if (targetGymId && (metadata?.gymId ?? null) !== targetGymId) {
return false;
}
return true;
} else {
// Non-superAdmins: must match their gym
return metadata?.gymId === userGymId;
// Non-superAdmins: must match their gym (normalize null/undefined)
return (metadata?.gymId ?? null) === (userGymId ?? null);
}
})
.map((inv) => ({
@@ -132,91 +132,51 @@ export async function POST(req: Request) {
);
}
// Fetch inviter user from Clerk
const client = await clerkClient();
const inviter = await client.users.getUser(userId);
const inviterRole =
(inviter.publicMetadata?.role as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser") ?? "client";
const inviterGymId =
(inviter.publicMetadata?.gymId as string | undefined) ?? undefined;
const authContext = await getAuthContext();
const { role: inviterRole, gymId: inviterGymId } = authContext;
const allowedRoles = getInvitableRoles(inviterRole);
if (!allowedRoles.includes(roleAssigned)) {
return NextResponse.json(
{ error: `Forbidden - Cannot invite role '${roleAssigned}'` },
{ status: 403 },
);
}
// Enforce role-based rules and resolve target gymId for the invitation
let gymIdForInvite: string | null = null;
switch (inviterRole) {
case "admin": {
if (roleAssigned !== "trainer" && roleAssigned !== "client") {
return NextResponse.json(
{ error: "Admin can only invite trainer or client" },
{ status: 403 },
);
}
if (!inviterGymId) {
return NextResponse.json(
{ error: "Inviter admin must be assigned to a gym" },
{ status: 400 },
);
}
gymIdForInvite = inviterGymId;
break;
}
case "trainer": {
if (roleAssigned !== "client") {
return NextResponse.json(
{ error: "Trainer can only invite client" },
{ status: 403 },
);
}
if (!inviterGymId) {
return NextResponse.json(
{ error: "Inviter trainer must be assigned to a gym" },
{ status: 400 },
);
}
gymIdForInvite = inviterGymId;
break;
}
case "superAdmin": {
if (
roleAssigned !== "admin" &&
roleAssigned !== "trainer" &&
roleAssigned !== "client"
) {
return NextResponse.json(
{ error: "Invalid roleAssigned for SuperAdmin" },
{ status: 400 },
);
}
// Prefer explicitly provided gymId, otherwise fall back to inviter's gymId if present
gymIdForInvite = requestedGymId || inviterGymId || null;
if (!gymIdForInvite) {
return NextResponse.json(
{ error: "gymId is required for SuperAdmin when inviting" },
{ status: 400 },
);
}
break;
}
default: {
if (inviterRole === "superAdmin") {
gymIdForInvite = requestedGymId || inviterGymId || null;
if (!gymIdForInvite) {
return NextResponse.json(
{ error: "Inviter role not permitted to create invitations" },
{ error: "gymId is required for superAdmin invitations" },
{ status: 400 },
);
}
} else {
if (!inviterGymId) {
return NextResponse.json(
{ error: "Inviter must be assigned to a gym" },
{ status: 400 },
);
}
if (requestedGymId && requestedGymId !== inviterGymId) {
return NextResponse.json(
{ error: "Cannot invite users into another gym" },
{ status: 403 },
);
}
gymIdForInvite = inviterGymId;
}
// Create Clerk invitation with metadata needed by webhook to assign role & gym
// reuse existing Clerk client instance
const client = await clerkClient();
const invitation = await client.invitations.createInvitation({
emailAddress: inviteeEmail,
publicMetadata: {
roleAssigned,
role: roleAssigned,
gymId: gymIdForInvite,
inviterUserId: inviter.id,
createdBy: userId,
},
});
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { MEMBERSHIP_FEATURES } from "@/lib/membership/features";
import { getUserMembershipContext } from "@/lib/membership/access";
export async function GET() {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { membershipType, features } = await getUserMembershipContext(userId);
return NextResponse.json({
success: true,
data: {
membershipType,
currentFeatures: features,
plans: MEMBERSHIP_FEATURES,
},
meta: {
timestamp: new Date().toISOString(),
},
});
} catch {
return NextResponse.json(
{ error: "Failed to load membership features" },
{ status: 500 },
);
}
}
@@ -0,0 +1,87 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import { POST } from "../route";
jest.mock("@clerk/nextjs/server", () => ({
auth: jest.fn(),
}));
jest.mock("@/lib/database", () => ({
getDatabase: jest.fn(),
}));
jest.mock("@/lib/sync-user", () => ({
ensureUserSynced: jest.fn(),
}));
jest.mock("@/lib/logger", () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
describe("POST /api/notifications authz", () => {
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock;
const mockEnsureUserSynced = require("@/lib/sync-user")
.ensureUserSynced as jest.Mock;
const mockDb = {
getUserById: jest.fn(),
createNotification: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetDatabase.mockResolvedValue(mockDb);
});
it("returns 403 for non-staff user", async () => {
mockAuth.mockResolvedValue({ userId: "client_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "client_1",
role: "client",
gymId: "gym_a",
});
const request = new NextRequest("http://localhost/api/notifications", {
method: "POST",
body: JSON.stringify({
targetUserId: "client_2",
title: "Hello",
message: "Test",
type: "system",
}),
});
const response = await POST(request);
expect(response.status).toBe(403);
});
it("returns 403 for cross-gym notify by admin", async () => {
mockAuth.mockResolvedValue({ userId: "admin_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "admin_1",
role: "admin",
gymId: "gym_a",
});
mockDb.getUserById.mockResolvedValue({ id: "client_2", gymId: "gym_b" });
const request = new NextRequest("http://localhost/api/notifications", {
method: "POST",
body: JSON.stringify({
targetUserId: "client_2",
title: "Hello",
message: "Test",
type: "system",
}),
});
const response = await POST(request);
expect(response.status).toBe(403);
});
});
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
import { ensureUserSynced } from "@/lib/sync-user";
/**
* GET /api/notifications
@@ -84,6 +85,39 @@ export async function POST(req: NextRequest) {
}
const db = await getDatabase();
const currentUser = await ensureUserSynced(userId, db);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const canCreateNotifications =
currentUser.role === "superAdmin" ||
currentUser.role === "admin" ||
currentUser.role === "trainer";
if (!canCreateNotifications) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const targetUser = await db.getUserById(targetUserId);
if (!targetUser) {
return NextResponse.json(
{ error: "Target user not found" },
{ status: 404 },
);
}
if (
currentUser.role !== "superAdmin" &&
(!currentUser.gymId || targetUser.gymId !== currentUser.gymId)
) {
return NextResponse.json(
{ error: "Forbidden - Cannot notify users from other gyms" },
{ status: 403 },
);
}
const notification = await db.createNotification({
id: crypto.randomUUID(),
userId: targetUserId,
@@ -0,0 +1,163 @@
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
import { getUserMembershipContext } from "@/lib/membership/access";
export async function POST(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const body = await req.json();
const {
dailyNutritionId,
mealType,
foodName,
calories,
protein,
carbs,
fats,
} = body;
if (!mealType || !foodName || calories === undefined) {
return NextResponse.json(
{ error: "mealType, foodName, and calories are required" },
{ status: 400 },
);
}
const result = await db.createMealEntry({
userId,
dailyNutritionId,
mealType,
foodName,
calories,
protein,
carbs,
fats,
timestamp: new Date(),
});
return NextResponse.json(result);
} catch (error) {
log.error("Failed to create meal entry", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function GET(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const date = url.searchParams.get("date");
if (!date) {
return NextResponse.json(
{ error: "date is required (YYYY-MM-DD format)" },
{ status: 400 },
);
}
const results = await db.getMealEntriesByDate(userId, date);
return NextResponse.json(results);
} catch (error) {
log.error("Failed to fetch meal entries", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function DELETE(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "ID is required" }, { status: 400 });
}
// Verify ownership before deletion
const existing = await db.getMealEntryById(id);
if (!existing) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (existing.userId !== userId) {
return NextResponse.json(
{ error: "Forbidden: You can only delete your own meal entries" },
{ status: 403 },
);
}
const success = await db.deleteMealEntry(id);
if (success) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
} catch (error) {
log.error("Failed to delete meal entry", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+175
View File
@@ -0,0 +1,175 @@
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
import { getUserMembershipContext } from "@/lib/membership/access";
export async function POST(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const body = await req.json();
const { date, meals, totalCalories, calorieGoal } = body;
if (!date) {
return NextResponse.json({ error: "Date is required" }, { status: 400 });
}
// Check if entry already exists for this date
const existing = await db.getDailyNutrition(userId, date);
let result;
if (existing) {
// Update existing entry
result = await db.updateDailyNutrition(existing.id, {
meals,
totalCalories: totalCalories ?? existing.totalCalories,
calorieGoal: calorieGoal ?? existing.calorieGoal,
});
} else {
// Create new entry
result = await db.createDailyNutrition({
userId,
date,
meals: meals || [],
totalCalories: totalCalories || 0,
calorieGoal: calorieGoal || 2000,
});
}
return NextResponse.json(result);
} catch (error) {
log.error("Failed to save nutrition data", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function GET(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const date = url.searchParams.get("date");
const startDate = url.searchParams.get("startDate");
const endDate = url.searchParams.get("endDate");
// Single date query
if (date) {
const result = await db.getDailyNutrition(userId, date);
return NextResponse.json(result);
}
// Date range query
if (startDate && endDate) {
const results = await db.getDailyNutritionRange(
userId,
startDate,
endDate,
);
return NextResponse.json(results);
}
return NextResponse.json(
{ error: "Either 'date' or 'startDate' and 'endDate' are required" },
{ status: 400 },
);
} catch (error) {
log.error("Failed to fetch nutrition data", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function DELETE(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "ID is required" }, { status: 400 });
}
// Verify ownership before deletion
const existing = await db.getDailyNutritionById(id);
if (!existing) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (existing.userId !== userId) {
return NextResponse.json(
{ error: "Forbidden: You can only delete your own nutrition data" },
{ status: 403 },
);
}
const success = await db.deleteDailyNutrition(id);
if (success) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
} catch (error) {
log.error("Failed to delete nutrition data", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,83 @@
/**
* @jest-environment node
*/
import { POST } from "../route";
jest.mock("@clerk/nextjs/server", () => ({
auth: jest.fn(),
}));
jest.mock("@/lib/database", () => ({
getDatabase: jest.fn(),
}));
jest.mock("@/lib/sync-user", () => ({
ensureUserSynced: jest.fn(),
}));
jest.mock("@/lib/logger", () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
describe("POST /api/recommendations/approve authz", () => {
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock;
const mockEnsureUserSynced = require("@/lib/sync-user")
.ensureUserSynced as jest.Mock;
const mockDb = {
getAllRecommendations: jest.fn(),
getUserById: jest.fn(),
updateRecommendation: jest.fn(),
createNotification: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetDatabase.mockResolvedValue(mockDb);
});
it("returns 403 for non-staff role", async () => {
mockAuth.mockResolvedValue({ userId: "client_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "client_1",
role: "client",
gymId: "gym_a",
});
const req = new Request("http://localhost/api/recommendations/approve", {
method: "POST",
body: JSON.stringify({ recommendationId: "rec_1", status: "approved" }),
headers: { "Content-Type": "application/json" },
});
const res = await POST(req);
expect(res.status).toBe(403);
});
it("returns 403 for cross-gym approval by admin", async () => {
mockAuth.mockResolvedValue({ userId: "admin_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "admin_1",
role: "admin",
gymId: "gym_a",
});
mockDb.getAllRecommendations.mockResolvedValue([
{ id: "rec_1", userId: "client_1" },
]);
mockDb.getUserById.mockResolvedValue({ id: "client_1", gymId: "gym_b" });
const req = new Request("http://localhost/api/recommendations/approve", {
method: "POST",
body: JSON.stringify({ recommendationId: "rec_1", status: "approved" }),
headers: { "Content-Type": "application/json" },
});
const res = await POST(req);
expect(res.status).toBe(403);
});
});
@@ -1,13 +1,20 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
import { ensureUserSynced } from "@/lib/sync-user";
export async function POST(req: Request) {
try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
log.debug("Approve recommendation request body", { body });
const { recommendationId, status, approvedBy } = body;
const { recommendationId, status } = body;
if (!recommendationId || !status) {
log.error("Missing required fields", {
@@ -22,12 +29,52 @@ export async function POST(req: Request) {
}
const db = await getDatabase();
const currentUser = await ensureUserSynced(clerkUserId, db);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const canApproveRecommendations =
currentUser.role === "superAdmin" ||
currentUser.role === "admin" ||
currentUser.role === "trainer";
if (!canApproveRecommendations) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const existingRecommendation = (await db.getAllRecommendations()).find(
(recommendation) => recommendation.id === recommendationId,
);
if (!existingRecommendation) {
return NextResponse.json(
{ error: "Recommendation not found" },
{ status: 404 },
);
}
if (currentUser.role !== "superAdmin") {
const targetUser = await db.getUserById(existingRecommendation.userId);
if (
!currentUser.gymId ||
!targetUser ||
targetUser.gymId !== currentUser.gymId
) {
return NextResponse.json(
{ error: "Forbidden - Cannot access users from other gyms" },
{ status: 403 },
);
}
}
// Update recommendation status
const updates: any = {
status,
approvedAt: status === "approved" ? new Date() : undefined,
approvedBy: status === "approved" ? approvedBy : undefined,
approvedBy: status === "approved" ? clerkUserId : undefined,
};
// Remove undefined keys
@@ -0,0 +1,455 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/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";
import { ensureUserSynced } from "@/lib/sync-user";
import { getUserMembershipContext } from "@/lib/membership/access";
const AI_LINK_PREFIX = "[AI_LINKED]";
interface ParsedPlanItem {
id: string;
title: string;
description: string;
goalType:
| "weight_target"
| "strength_milestone"
| "endurance_target"
| "flexibility_goal"
| "habit_building"
| "custom";
}
interface GeneratedPlanContent {
recommendationText?: string;
activityPlan?: string;
dietPlan?: string;
}
function buildFallbackPlan(profile: {
activityLevel?: string;
fitnessGoals?: string[] | string;
medicalConditions?: string;
}): GeneratedPlanContent {
const goals = Array.isArray(profile.fitnessGoals)
? profile.fitnessGoals
: typeof profile.fitnessGoals === "string" && profile.fitnessGoals
? [profile.fitnessGoals]
: ["general fitness"];
const primaryGoal = goals[0] || "general fitness";
const activityLevel = profile.activityLevel || "moderate";
const hasMedicalNotes = Boolean(profile.medicalConditions?.trim());
return {
recommendationText:
`Personalized starter plan focused on ${primaryGoal} with ${activityLevel} activity pacing.` +
(hasMedicalNotes
? " Medical notes detected, so keep intensity conservative and progress gradually."
: ""),
activityPlan:
"- 3 strength sessions per week (full-body, 35-45 min)\n" +
"- 2 cardio sessions per week (20-30 min brisk walk/run/cycle)\n" +
"- 10 minutes daily mobility/stretching after workouts\n" +
"- 1 full recovery day each week",
dietPlan:
"- Build meals around lean protein, vegetables, whole grains, and hydration\n" +
"- Keep portions consistent and avoid skipping meals\n" +
"- Track intake daily and adjust calories based on weekly progress",
};
}
function inferGoalType(text: string): ParsedPlanItem["goalType"] {
const normalized = text.toLowerCase();
if (
normalized.includes("strength") ||
normalized.includes("bench") ||
normalized.includes("squat") ||
normalized.includes("deadlift") ||
normalized.includes("weights")
) {
return "strength_milestone";
}
if (
normalized.includes("run") ||
normalized.includes("cardio") ||
normalized.includes("endurance") ||
normalized.includes("cycle")
) {
return "endurance_target";
}
if (
normalized.includes("stretch") ||
normalized.includes("mobility") ||
normalized.includes("yoga") ||
normalized.includes("flexibility")
) {
return "flexibility_goal";
}
if (
normalized.includes("daily") ||
normalized.includes("routine") ||
normalized.includes("habit")
) {
return "habit_building";
}
return "custom";
}
function parseActivityPlanToItems(activityPlan: string): ParsedPlanItem[] {
const lines = activityPlan
.replace(/\r\n/g, "\n")
.split(/\n+/)
.flatMap((line) => line.split(/(?<=[.!?])\s+(?=[A-Z0-9])/g))
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.replace(/^[-*•\d.)\s]+/, "").trim())
.filter((line) => line.length > 10)
.slice(0, 8);
const uniqueLines = Array.from(new Set(lines));
return uniqueLines.map((line) => ({
id: crypto.randomUUID(),
title: line.length > 72 ? `${line.slice(0, 69)}...` : line,
description: line,
goalType: inferGoalType(line),
}));
}
function getDefaultPlanItems(): ParsedPlanItem[] {
const defaults = [
"Complete 3 strength sessions this week with progressive overload.",
"Add 2 cardio sessions of 25-30 minutes for endurance.",
"Do a 10-minute mobility routine daily after training.",
];
return defaults.map((line) => ({
id: crypto.randomUUID(),
title: line.length > 72 ? `${line.slice(0, 69)}...` : line,
description: line,
goalType: inferGoalType(line),
}));
}
function parseJsonPayload(content: string): GeneratedPlanContent {
let cleanResponse = content.trim();
if (cleanResponse.startsWith("```json")) {
cleanResponse = cleanResponse
.replace(/^```json\s*/, "")
.replace(/\s*```$/, "");
} else if (cleanResponse.startsWith("```")) {
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
}
const firstBrace = cleanResponse.indexOf("{");
const lastBrace = cleanResponse.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1) {
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
}
return JSON.parse(cleanResponse) as GeneratedPlanContent;
}
async function generateWithOpenAI(
openaiApiKey: string,
prompt: string,
): Promise<GeneratedPlanContent> {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${openaiApiKey}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content:
'You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks. The response must have this exact structure: {"recommendationText": "string", "activityPlan": "string", "dietPlan": "string"}',
},
{
role: "user",
content: prompt,
},
],
temperature: 0.7,
max_tokens: 1500,
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenAI failed: ${response.status} ${errorText}`);
}
const data = await response.json();
return parseJsonPayload(data.choices[0].message.content as string);
}
async function generateWithDeepSeek(
deepseekApiKey: string,
prompt: string,
): Promise<GeneratedPlanContent> {
const response = 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: 1200,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`DeepSeek failed: ${response.status} ${errorText}`);
}
const data = await response.json();
return parseJsonPayload(data.choices[0].message.content as string);
}
async function generateWithOllama(
prompt: string,
): Promise<GeneratedPlanContent> {
const response = await fetch("http://localhost:11434/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gemma3:latest",
prompt,
stream: false,
format: "json",
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ollama failed: ${response.status} ${errorText}`);
}
const data = await response.json();
return parseJsonPayload(data.response as string);
}
export async function POST() {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
const currentUser = await ensureUserSynced(userId, db);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { membershipType, features } = await getUserMembershipContext(userId);
if (membershipType === "basic") {
return NextResponse.json(
{
error:
"AI plan generation is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
if (features.recommendationsPerMonth > 0) {
const currentMonth = new Date();
const monthStart = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth(),
1,
);
const monthEnd = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth() + 1,
1,
);
const existingRecommendations =
await db.getRecommendationsByUserId(userId);
const recommendationsThisMonth = existingRecommendations.filter(
(recommendation) =>
recommendation.generatedAt >= monthStart &&
recommendation.generatedAt < monthEnd,
).length;
if (recommendationsThisMonth >= features.recommendationsPerMonth) {
return NextResponse.json(
{
error: `Your ${membershipType} plan includes ${features.recommendationsPerMonth} AI recommendation(s) per month`,
membershipType,
},
{ status: 403 },
);
}
}
const profile = await db.getFitnessProfileByUserId(userId);
if (!profile) {
return NextResponse.json(
{ error: "Complete your fitness profile before generating a plan" },
{ status: 404 },
);
}
let prompt: string;
try {
const context = await buildAIContext(userId);
prompt = buildEnhancedPrompt(context);
} catch (error) {
log.warn("Failed to build AI context for self-generate", {
userId,
error: error instanceof Error ? error.message : String(error),
});
prompt = buildBasicPrompt(profile);
}
const openaiApiKey = process.env.OPENAI_API_KEY;
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
let parsedResponse: GeneratedPlanContent;
let usedFallbackPlan = false;
try {
if (openaiApiKey) {
parsedResponse = await generateWithOpenAI(openaiApiKey, prompt);
} else if (deepseekApiKey) {
parsedResponse = await generateWithDeepSeek(deepseekApiKey, prompt);
} else {
parsedResponse = await generateWithOllama(prompt);
}
} catch (providerError) {
log.error("Self-generate provider failed", providerError, {
userId,
hasOpenAI: Boolean(openaiApiKey),
hasDeepSeek: Boolean(deepseekApiKey),
});
parsedResponse = buildFallbackPlan({
activityLevel: profile.activityLevel,
fitnessGoals: profile.fitnessGoals,
medicalConditions: profile.medicalConditions,
});
usedFallbackPlan = true;
}
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId: profile.id,
recommendationText: parsedResponse.recommendationText || "",
activityPlan: parsedResponse.activityPlan || "",
dietPlan: parsedResponse.dietPlan || "",
status: "approved",
generatedAt: new Date(),
updatedAt: new Date(),
});
const existingActiveGoals = await db.getFitnessGoalsByUserId(
userId,
"active",
);
const linkedGoals = existingActiveGoals.filter((goal) =>
goal.notes?.startsWith(AI_LINK_PREFIX),
);
await Promise.all(
linkedGoals.map((goal) =>
db.updateFitnessGoal(goal.id, {
status: "paused",
notes: `${goal.notes || ""}\nPaused due to new AI plan generation on ${new Date().toISOString()}`,
}),
),
);
let planItems = parseActivityPlanToItems(parsedResponse.activityPlan || "");
if (planItems.length === 0 && parsedResponse.recommendationText) {
planItems = parseActivityPlanToItems(parsedResponse.recommendationText);
}
if (planItems.length === 0) {
planItems = getDefaultPlanItems();
}
log.debug("AI plan parsed into goal items", {
recommendationId: recommendation.id,
userId,
parsedItems: planItems.length,
});
const createdGoals = await Promise.all(
planItems.map((item) =>
db.createFitnessGoal({
id: crypto.randomUUID(),
userId,
fitnessProfileId: profile.id,
goalType: item.goalType,
title: item.title,
description: item.description,
targetValue: undefined,
currentValue: 0,
unit: undefined,
startDate: new Date(),
targetDate: undefined,
completedDate: undefined,
status: "active",
progress: 0,
priority: "medium",
notes: `${AI_LINK_PREFIX} recommendationId=${recommendation.id}; itemId=${item.id}`,
}),
),
);
return NextResponse.json({
success: true,
data: recommendation,
meta: {
timestamp: new Date().toISOString(),
createdGoals: createdGoals.length,
pausedGoals: linkedGoals.length,
usedFallbackPlan,
},
});
} catch (error) {
log.error("Failed to self-generate recommendation", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,85 @@
/**
* @jest-environment node
*/
import { POST } from "../route";
jest.mock("@clerk/nextjs/server", () => ({
auth: jest.fn(),
}));
jest.mock("@/lib/database", () => ({
getDatabase: jest.fn(),
}));
jest.mock("@/lib/sync-user", () => ({
ensureUserSynced: jest.fn(),
}));
jest.mock("@/lib/ai/ai-context", () => ({
buildAIContext: jest.fn(),
}));
jest.mock("@/lib/ai/prompt-builder", () => ({
buildEnhancedPrompt: jest.fn(),
buildBasicPrompt: jest.fn(),
}));
jest.mock("@/lib/logger", () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
describe("POST /api/recommendations/generate authz", () => {
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock;
const mockEnsureUserSynced = require("@/lib/sync-user")
.ensureUserSynced as jest.Mock;
const mockDb = {
getUserById: jest.fn(),
getFitnessProfileByUserId: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetDatabase.mockResolvedValue(mockDb);
});
it("returns 401 when unauthenticated", async () => {
mockAuth.mockResolvedValue({ userId: null });
const req = new Request("http://localhost/api/recommendations/generate", {
method: "POST",
body: JSON.stringify({ userId: "client_1" }),
headers: { "Content-Type": "application/json" },
});
const res = await POST(req);
expect(res.status).toBe(401);
});
it("returns 403 when staff accesses user from another gym", async () => {
mockAuth.mockResolvedValue({ userId: "admin_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "admin_1",
role: "admin",
gymId: "gym_a",
});
mockDb.getUserById.mockResolvedValue({
id: "client_1",
gymId: "gym_b",
});
const req = new Request("http://localhost/api/recommendations/generate", {
method: "POST",
body: JSON.stringify({ userId: "client_1" }),
headers: { "Content-Type": "application/json" },
});
const res = await POST(req);
expect(res.status).toBe(403);
});
});
@@ -1,11 +1,19 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/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";
import { ensureUserSynced } from "@/lib/sync-user";
import { getUserMembershipContext } from "@/lib/membership/access";
export async function POST(req: Request) {
try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId, useExternalModel, modelProvider } = await req.json();
if (!userId) {
@@ -22,6 +30,69 @@ export async function POST(req: Request) {
});
const db = await getDatabase();
const currentUser = await ensureUserSynced(clerkUserId, db);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const canGenerateRecommendations =
currentUser.role === "superAdmin" ||
currentUser.role === "admin" ||
currentUser.role === "trainer";
if (!canGenerateRecommendations) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const targetUser = await db.getUserById(userId);
if (!targetUser) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const { membershipType, features } = await getUserMembershipContext(userId);
if (features.recommendationsPerMonth === 1) {
const currentMonth = new Date();
const monthStart = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth(),
1,
);
const monthEnd = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth() + 1,
1,
);
const existingRecommendations =
await db.getRecommendationsByUserId(userId);
const recommendationsThisMonth = existingRecommendations.filter(
(recommendation) =>
recommendation.generatedAt >= monthStart &&
recommendation.generatedAt < monthEnd,
).length;
if (recommendationsThisMonth >= 1) {
return NextResponse.json(
{
error:
"Basic membership includes 1 recommendation per month. Upgrade to Premium or VIP for unlimited recommendations.",
membershipType,
},
{ status: 403 },
);
}
}
if (currentUser.role !== "superAdmin") {
if (!currentUser.gymId || targetUser.gymId !== currentUser.gymId) {
return NextResponse.json(
{ error: "Forbidden - Cannot access users from other gyms" },
{ status: 403 },
);
}
}
// Fetch fitness profile
const profile = await db.getFitnessProfileByUserId(userId);
@@ -106,7 +106,7 @@ export async function POST(request: NextRequest) {
}
const db = await getDatabase();
const currentUser = await db.getUserById(currentUserId);
const currentUser = await ensureUserSynced(currentUserId, db);
const isStaff =
currentUser?.role === "admin" ||
currentUser?.role === "superAdmin" ||
@@ -140,6 +140,18 @@ export async function POST(request: NextRequest) {
content,
} = validation.data;
const targetUser = await db.getUserById(userId);
if (!targetUser) {
return badRequestResponse("Target user not found");
}
if (
currentUser?.role !== "superAdmin" &&
(!currentUser?.gymId || targetUser.gymId !== currentUser.gymId)
) {
return forbiddenResponse("Cannot create recommendations for other gyms");
}
// Handle AI Plan (Legacy/Specific)
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) {
const recommendation = await db.createRecommendation({
@@ -198,6 +210,41 @@ export async function PUT(request: NextRequest) {
validation.data;
const db = await getDatabase();
const currentUser = await ensureUserSynced(currentUserId, db);
if (!currentUser) {
return forbiddenResponse("User not found");
}
const isStaff =
currentUser.role === "admin" ||
currentUser.role === "superAdmin" ||
currentUser.role === "trainer";
if (!isStaff) {
return forbiddenResponse();
}
const existingRecommendation = (await db.getAllRecommendations()).find(
(recommendation) => recommendation.id === id,
);
if (!existingRecommendation) {
return badRequestResponse("Recommendation not found");
}
if (currentUser.role !== "superAdmin") {
const targetUser = await db.getUserById(existingRecommendation.userId);
if (
!currentUser.gymId ||
!targetUser ||
targetUser.gymId !== currentUser.gymId
) {
return forbiddenResponse(
"Cannot modify recommendations for other gyms",
);
}
}
const updated = await db.updateRecommendation(id, {
...(status && { status }),
@@ -0,0 +1,373 @@
/**
* Report Generation API Tests
*
* Tests for /api/reports/user/[userId] endpoint
*
* Test Coverage:
* 1. Access Control (client, trainer, admin, superAdmin)
* 2. Data Aggregation (attendance, nutrition, hydration, goals)
* 3. PDF Generation
* 4. Date Range Validation
*/
import { describe, it, expect, beforeAll, afterAll } from "@jest/globals";
// Mock database
const mockDatabase = {
getUserById: jest.fn(),
getClientByUserId: jest.fn(),
getFitnessProfileByUserId: jest.fn(),
getAttendanceByWeek: jest.fn(),
getDailyNutritionRange: jest.fn(),
getDailyHydrationRange: jest.fn(),
getFitnessGoalsByUserId: jest.fn(),
getFitnessProfileHistory: jest.fn(),
getRecommendationsByUserId: jest.fn(),
getTrainerClientAssignments: jest.fn(),
};
// Mock user data
const mockUsers = {
client: {
id: "user_client_123",
email: "client@example.com",
firstName: "John",
lastName: "Client",
role: "client",
gymId: "gym_123",
},
trainer: {
id: "user_trainer_456",
email: "trainer@example.com",
firstName: "Jane",
lastName: "Trainer",
role: "trainer",
gymId: "gym_123",
},
admin: {
id: "user_admin_789",
email: "admin@example.com",
firstName: "Admin",
lastName: "User",
role: "admin",
gymId: "gym_123",
},
superAdmin: {
id: "user_superadmin_000",
email: "superadmin@example.com",
firstName: "Super",
lastName: "Admin",
role: "superAdmin",
gymId: undefined,
},
};
describe("Report Generation API", () => {
describe("Access Control", () => {
it("should allow clients to view their own reports", async () => {
const currentUser = mockUsers.client;
const targetUserId = mockUsers.client.id;
// Users can always view their own reports
const hasAccess = currentUser.id === targetUserId;
expect(hasAccess).toBe(true);
});
it("should allow trainers to view assigned client reports", async () => {
const currentUser = mockUsers.trainer;
const targetUserId = mockUsers.client.id;
// Mock assignment exists
mockDatabase.getTrainerClientAssignments.mockResolvedValue([
{
trainerId: currentUser.id,
clientId: targetUserId,
isActive: true,
},
]);
const assignments = await mockDatabase.getTrainerClientAssignments(
currentUser.id,
);
const assignment = assignments.find(
(a: any) => a.clientId === targetUserId && a.isActive,
);
const hasAccess = !!assignment;
expect(hasAccess).toBe(true);
});
it("should deny trainers access to non-assigned client reports", async () => {
const currentUser = mockUsers.trainer;
const targetUserId = "user_other_client_999";
mockDatabase.getTrainerClientAssignments.mockResolvedValue([]);
const assignments = await mockDatabase.getTrainerClientAssignments(
currentUser.id,
);
const assignment = assignments.find(
(a: any) => a.clientId === targetUserId && a.isActive,
);
const hasAccess = !!assignment;
expect(hasAccess).toBe(false);
});
it("should allow admins to view clients in their gym", async () => {
const currentUser = mockUsers.admin;
const targetUser = { ...mockUsers.client, gymId: "gym_123" };
mockDatabase.getUserById.mockResolvedValue(targetUser);
const user = await mockDatabase.getUserById(targetUser.id);
const hasAccess = user?.gymId === currentUser.gymId;
expect(hasAccess).toBe(true);
});
it("should deny admins access to clients outside their gym", async () => {
const currentUser = mockUsers.admin;
const targetUser = { ...mockUsers.client, gymId: "gym_other_999" };
mockDatabase.getUserById.mockResolvedValue(targetUser);
const user = await mockDatabase.getUserById(targetUser.id);
const hasAccess = user?.gymId === currentUser.gymId;
expect(hasAccess).toBe(false);
});
it("should allow superAdmins to view any user", async () => {
const currentUser = mockUsers.superAdmin;
// SuperAdmins bypass gym checks
const hasAccess = currentUser.role === "superAdmin";
expect(hasAccess).toBe(true);
});
it("should deny clients access to other users reports", async () => {
const currentUser = mockUsers.client;
const targetUserId = "user_other_123";
// Only own ID or specific permissions
const hasAccess = currentUser.id === targetUserId;
expect(hasAccess).toBe(false);
});
});
describe("Date Range Validation", () => {
it("should accept valid date format YYYY-MM-DD", () => {
const validDates = ["2024-01-01", "2024-12-31", "2024-06-15"];
validDates.forEach((date) => {
const parsed = Date.parse(date);
expect(isNaN(parsed)).toBe(false);
});
});
it("should reject invalid date formats", () => {
const invalidDates = ["01-01-2024", "2024/01/01", "2024-1-1", "invalid"];
invalidDates.forEach((date) => {
const parsed = Date.parse(date);
expect(isNaN(parsed)).toBe(true);
});
});
it("should default to last 30 days if no dates provided", () => {
const endDate = new Date();
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const daysDiff = Math.ceil(
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),
);
expect(daysDiff).toBe(30);
});
});
describe("Data Aggregation", () => {
it("should aggregate weekly check-ins correctly", () => {
const attendanceRecords = [
{
checkInTime: new Date("2024-01-02T10:00:00"),
checkOutTime: new Date("2024-01-02T11:00:00"),
type: "gym",
},
{
checkInTime: new Date("2024-01-03T14:00:00"),
checkOutTime: new Date("2024-01-03T15:30:00"),
type: "gym",
},
{
checkInTime: new Date("2024-01-04T09:00:00"),
checkOutTime: null, // Still checked in
type: "class",
},
];
const totalCheckIns = attendanceRecords.length;
const completedCheckIns = attendanceRecords.filter(
(r) => r.checkOutTime,
).length;
expect(totalCheckIns).toBe(3);
expect(completedCheckIns).toBe(2);
});
it("should calculate total time spent correctly", () => {
const attendanceRecords = [
{
checkInTime: new Date("2024-01-02T10:00:00"),
checkOutTime: new Date("2024-01-02T11:30:00"), // 90 minutes
},
{
checkInTime: new Date("2024-01-03T14:00:00"),
checkOutTime: new Date("2024-01-03T15:00:00"), // 60 minutes
},
];
const totalMinutes = attendanceRecords.reduce((sum, record) => {
if (record.checkOutTime) {
const duration =
record.checkOutTime.getTime() - record.checkInTime.getTime();
return sum + Math.floor(duration / 60000);
}
return sum;
}, 0);
expect(totalMinutes).toBe(150);
});
it("should handle nutrition goal achievement correctly", () => {
const nutritionRecords = [
{ date: "2024-01-01", totalCalories: 2100, calorieGoal: 2000 },
{ date: "2024-01-02", totalCalories: 1900, calorieGoal: 2000 },
{ date: "2024-01-03", totalCalories: 2200, calorieGoal: 2000 },
];
const daysMetGoal = nutritionRecords.filter((record) => {
const lowerBound = record.calorieGoal * 0.9;
const upperBound = record.calorieGoal * 1.1;
return (
record.totalCalories >= lowerBound &&
record.totalCalories <= upperBound
);
}).length;
expect(daysMetGoal).toBe(3); // All within ±10%
});
it("should handle hydration goal achievement correctly", () => {
const hydrationRecords = [
{ date: "2024-01-01", totalWater: 2500, waterGoal: 2000 }, // 125%
{ date: "2024-01-02", totalWater: 1800, waterGoal: 2000 }, // 90%
{ date: "2024-01-03", totalWater: 2000, waterGoal: 2000 }, // 100%
];
const daysMetGoal = hydrationRecords.filter(
(record) => record.totalWater / record.waterGoal >= 1,
).length;
expect(daysMetGoal).toBe(2); // Days 1 and 3 meet goal
});
});
describe("Report Structure", () => {
it("should include all required sections", () => {
const requiredSections = [
"userId",
"user",
"client",
"fitnessProfile",
"reportPeriod",
"weeklyCheckIns",
"nutrition",
"hydration",
"goals",
"profileHistory",
"recommendations",
"generatedAt",
];
// Mock report object
const report = {
userId: "user_123",
user: {},
client: null,
fitnessProfile: null,
reportPeriod: { startDate: "", endDate: "" },
weeklyCheckIns: [],
nutrition: {
dailySummaries: [],
averageDailyCalories: 0,
totalDays: 0,
daysMetGoal: 0,
},
hydration: {
dailySummaries: [],
averageDailyWater: 0,
totalDays: 0,
daysMetGoal: 0,
},
goals: {
active: [],
completed: [],
totalActive: 0,
totalCompleted: 0,
averageProgress: 0,
},
profileHistory: [],
recommendations: {
accepted: [],
rejected: [],
pending: [],
totalAccepted: 0,
totalRejected: 0,
totalPending: 0,
},
generatedAt: new Date(),
};
requiredSections.forEach((section) => {
expect(report).toHaveProperty(section);
});
});
});
});
describe("ISO Week Calculations", () => {
it("should start week on Monday", () => {
// Test dates
const dates = [
{ date: "2024-01-01", expectedMonday: "2024-01-01" }, // Monday
{ date: "2024-01-02", expectedMonday: "2024-01-01" }, // Tuesday
{ date: "2024-01-03", expectedMonday: "2024-01-01" }, // Wednesday
{ date: "2024-01-07", expectedMonday: "2024-01-01" }, // Sunday
{ date: "2024-01-08", expectedMonday: "2024-01-08" }, // Monday (next week)
];
dates.forEach(({ date, expectedMonday }) => {
const current = new Date(date);
const day = current.getDay();
const diff = current.getDate() - day + (day === 0 ? -6 : 1);
const monday = new Date(current);
monday.setDate(diff);
const mondayStr = monday.toISOString().split("T")[0];
expect(mondayStr).toBe(expectedMonday);
});
});
it("should end week on Sunday", () => {
const monday = new Date("2024-01-01");
const sunday = new Date(monday);
sunday.setDate(sunday.getDate() + 6);
const sundayStr = sunday.toISOString().split("T")[0];
expect(sundayStr).toBe("2024-01-07");
});
});
@@ -0,0 +1,425 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
import type {
UserReport,
WeeklyCheckInStats,
NutritionSummary,
HydrationSummary,
GoalSummary,
ProfileChangeSummary,
RecommendationSummary,
AttendanceType,
} from "@fitai/shared";
import { ATTENDANCE_TYPES } from "@fitai/shared";
import { generateReportPDFBase64 } from "@/lib/pdf/report-helpers";
/**
* GET /api/reports/user/[userId]
* Generate a comprehensive user report
*
* Query params:
* - userId: string (required) - Target user ID
* - startDate: YYYY-MM-DD (optional, default: 30 days ago)
* - endDate: YYYY-MM-DD (optional, default: today)
*
* Access Control:
* - Admins: Can generate reports for any user in their gym
* - Trainers: Can generate reports for their assigned clients only
* - Users: Can only generate reports for themselves
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ userId: string }> },
) {
try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
const currentUser = await ensureUserSynced(clerkUserId, db);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { userId: targetUserId } = await params;
// Access Control Check
const hasAccess = await checkReportAccess(currentUser, targetUserId, db);
if (!hasAccess) {
return NextResponse.json(
{ error: "You don't have permission to view this report" },
{ status: 403 },
);
}
// Parse date range from query params
const { searchParams } = new URL(request.url);
const endDate =
searchParams.get("endDate") || new Date().toISOString().split("T")[0];
const startDate =
searchParams.get("startDate") ||
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0];
const format = searchParams.get("format") || "json";
// Validate dates
if (isNaN(Date.parse(startDate)) || isNaN(Date.parse(endDate))) {
return NextResponse.json(
{ error: "Invalid date format. Use YYYY-MM-DD" },
{ status: 400 },
);
}
// Fetch all user data
const [user, client, fitnessProfile] = await Promise.all([
db.getUserById(targetUserId),
db.getClientByUserId(targetUserId),
db.getFitnessProfileByUserId(targetUserId),
]);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Aggregate data in parallel
const [
nutritionRecords,
hydrationRecords,
fitnessGoals,
profileHistory,
recommendations,
] = await Promise.all([
db.getDailyNutritionRange(targetUserId, startDate, endDate),
db.getDailyHydrationRange(targetUserId, startDate, endDate),
db.getFitnessGoalsByUserId(targetUserId),
db.getFitnessProfileHistory(
targetUserId,
new Date(startDate),
new Date(endDate),
),
db.getRecommendationsByUserId(targetUserId),
]);
// Process and aggregate data
const weeklyCheckIns = await processWeeklyCheckIns(
db,
targetUserId,
startDate,
endDate,
);
const nutrition = processNutrition(nutritionRecords);
const hydration = processHydration(hydrationRecords);
const goals = processGoals(fitnessGoals);
const profileChanges = processProfileHistory(profileHistory);
const recs = processRecommendations(recommendations);
const report: UserReport = {
userId: targetUserId,
user,
client,
fitnessProfile,
reportPeriod: {
startDate,
endDate,
},
weeklyCheckIns,
nutrition,
hydration,
goals,
profileHistory: profileChanges,
recommendations: recs,
generatedAt: new Date(),
};
// Return PDF if requested
if (format === "pdf") {
try {
const pdfBase64 = generateReportPDFBase64(report);
const pdfBuffer = Buffer.from(pdfBase64, "base64");
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="FitAI_Report_${report.user.firstName}_${report.user.lastName}_${startDate}_${endDate}.pdf"`,
"Content-Length": pdfBuffer.length.toString(),
},
});
} catch (pdfError) {
log.error("Failed to generate PDF", pdfError);
return NextResponse.json(
{ error: "Failed to generate PDF" },
{ status: 500 },
);
}
}
return NextResponse.json(report);
} catch (error) {
log.error("Failed to generate user report", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* Check if the current user has access to view the target user's report
*/
async function checkReportAccess(
currentUser: { id: string; role: string; gymId?: string },
targetUserId: string,
db: Awaited<ReturnType<typeof getDatabase>>,
): Promise<boolean> {
// Users can always view their own reports
if (currentUser.id === targetUserId) {
return true;
}
// Admins can view any user's report in their gym
if (currentUser.role === "admin" || currentUser.role === "superAdmin") {
const targetUser = await db.getUserById(targetUserId);
if (!targetUser) return false;
// SuperAdmins can view all users
if (currentUser.role === "superAdmin") return true;
// Admins can only view users in their gym
if (currentUser.gymId && targetUser.gymId === currentUser.gymId) {
return true;
}
return false;
}
// Trainers can only view reports for their assigned clients
if (currentUser.role === "trainer") {
const assignments = await db.getTrainerClientAssignments(currentUser.id);
const assignment = assignments.find(
(a) => a.clientId === targetUserId && a.isActive,
);
return !!assignment;
}
// Regular users cannot view other users' reports
return false;
}
/**
* Process attendance records into weekly check-in stats
*/
async function processWeeklyCheckIns(
db: Awaited<ReturnType<typeof getDatabase>>,
userId: string,
startDate: string,
endDate: string,
): Promise<WeeklyCheckInStats[]> {
const weeks: WeeklyCheckInStats[] = [];
// Generate all weeks in the range
const current = new Date(startDate);
const end = new Date(endDate);
while (current <= end) {
// Get Monday of current week (ISO week)
const monday = new Date(current);
const day = monday.getDay();
const diff = monday.getDate() - day + (day === 0 ? -6 : 1);
monday.setDate(diff);
const weekStart = monday.toISOString().split("T")[0];
const sunday = new Date(monday);
sunday.setDate(sunday.getDate() + 6);
const weekEnd = sunday.toISOString().split("T")[0];
weeks.push({
weekStart,
weekEnd,
totalCheckIns: 0,
totalTimeMinutes: 0,
averageDurationMinutes: 0,
checkInsByType: ATTENDANCE_TYPES.map((type) => ({ type, count: 0 })),
});
current.setDate(current.getDate() + 7);
}
// Fetch attendance for each week and aggregate
for (let i = 0; i < weeks.length; i++) {
const week = weeks[i];
const attendanceRecords = await db.getAttendanceByWeek(
userId,
new Date(week.weekStart),
);
// Filter records to only include those within the week
const weekStartDate = new Date(week.weekStart);
const weekEndDate = new Date(week.weekEnd);
weekEndDate.setHours(23, 59, 59, 999);
const filteredRecords = attendanceRecords.filter((record) => {
const checkInTime = new Date(record.checkInTime);
return checkInTime >= weekStartDate && checkInTime <= weekEndDate;
});
week.totalCheckIns = filteredRecords.length;
// Calculate time spent
for (const record of filteredRecords) {
if (record.checkOutTime) {
const checkIn = new Date(record.checkInTime);
const checkOut = new Date(record.checkOutTime);
const durationMs = checkOut.getTime() - checkIn.getTime();
const durationMinutes = Math.floor(durationMs / 60000);
week.totalTimeMinutes += durationMinutes;
}
// Count by type
const typeIndex = week.checkInsByType.findIndex(
(t) => t.type === record.type,
);
if (typeIndex !== -1) {
week.checkInsByType[typeIndex].count++;
}
}
// Calculate average duration
week.averageDurationMinutes =
week.totalCheckIns > 0
? Math.round(week.totalTimeMinutes / week.totalCheckIns)
: 0;
}
return weeks.sort((a, b) => a.weekStart.localeCompare(b.weekStart));
}
/**
* Process nutrition records into summaries
*/
function processNutrition(records: any[]): UserReport["nutrition"] {
const dailySummaries: NutritionSummary[] = records.map((record) => ({
date: record.date,
totalCalories: record.totalCalories,
calorieGoal: record.calorieGoal,
caloriesDelta: record.totalCalories - record.calorieGoal,
mealsCount: record.meals?.length || 0,
}));
const totalDays = dailySummaries.length;
const totalCalories = dailySummaries.reduce(
(sum, day) => sum + day.totalCalories,
0,
);
const averageDailyCalories =
totalDays > 0 ? Math.round(totalCalories / totalDays) : 0;
// Count days where calories were within ±10% of goal
const daysMetGoal = dailySummaries.filter((day) => {
const lowerBound = day.calorieGoal * 0.9;
const upperBound = day.calorieGoal * 1.1;
return day.totalCalories >= lowerBound && day.totalCalories <= upperBound;
}).length;
return {
dailySummaries,
averageDailyCalories,
totalDays,
daysMetGoal,
};
}
/**
* Process hydration records into summaries
*/
function processHydration(records: any[]): UserReport["hydration"] {
const dailySummaries: HydrationSummary[] = records.map((record) => ({
date: record.date,
totalWater: record.totalWater,
waterGoal: record.waterGoal,
hydrationPercentage:
record.waterGoal > 0
? Math.round((record.totalWater / record.waterGoal) * 100)
: 0,
}));
const totalDays = dailySummaries.length;
const totalWater = dailySummaries.reduce(
(sum, day) => sum + day.totalWater,
0,
);
const averageDailyWater =
totalDays > 0 ? Math.round(totalWater / totalDays) : 0;
// Count days where water goal was met (>= 100%)
const daysMetGoal = dailySummaries.filter(
(day) => day.hydrationPercentage >= 100,
).length;
return {
dailySummaries,
averageDailyWater,
totalDays,
daysMetGoal,
};
}
/**
* Process fitness goals into summaries
*/
function processGoals(goals: any[]): GoalSummary {
const active = goals.filter((g) => g.status === "active");
const completed = goals.filter((g) => g.status === "completed");
const averageProgress =
active.length > 0
? Math.round(
active.reduce((sum, g) => sum + g.progress, 0) / active.length,
)
: 0;
return {
active,
completed,
totalActive: active.length,
totalCompleted: completed.length,
averageProgress,
};
}
/**
* Process fitness profile history into summaries
*/
function processProfileHistory(history: any[]): ProfileChangeSummary[] {
return history.map((record) => ({
changeType: record.changeType,
fieldName: record.fieldName,
previousValue: record.previousValue,
newValue: record.newValue,
changedAt: record.changedAt,
}));
}
/**
* Process recommendations into summaries
*/
function processRecommendations(recommendations: any[]): RecommendationSummary {
const accepted = recommendations.filter((r) => r.status === "accepted");
const rejected = recommendations.filter((r) => r.status === "rejected");
const pending = recommendations.filter((r) => r.status === "pending");
return {
accepted,
rejected,
pending,
totalAccepted: accepted.length,
totalRejected: rejected.length,
totalPending: pending.length,
};
}
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
/**
* DELETE /api/trainer-client/[id]
* Delete (deactivate) a trainer-client assignment
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { userId: authUserId } = await auth();
if (!authUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
const currentUser = await ensureUserSynced(authUserId, db);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Only admins can delete assignments
if (currentUser.role !== "admin" && currentUser.role !== "superAdmin") {
return NextResponse.json(
{ error: "Only admins can remove trainer-client assignments" },
{ status: 403 },
);
}
const { id } = await params;
const allAssignments = await db.getAllTrainerClientAssignments();
const assignment = allAssignments.find((a) => a.id === id);
if (!assignment) {
return NextResponse.json(
{ error: "Assignment not found" },
{ status: 404 },
);
}
if (currentUser.role !== "superAdmin") {
if (!currentUser.gymId) {
return NextResponse.json({ error: "No gym assigned" }, { status: 403 });
}
const [trainer, client] = await Promise.all([
db.getUserById(assignment.trainerId),
db.getUserById(assignment.clientId),
]);
if (
!trainer ||
!client ||
trainer.gymId !== currentUser.gymId ||
client.gymId !== currentUser.gymId
) {
return NextResponse.json(
{ error: "Cannot modify assignments from other gyms" },
{ status: 403 },
);
}
}
// Deactivate the assignment
const result = await db.deactivateTrainerClientAssignment(id);
if (!result) {
return NextResponse.json(
{ error: "Failed to deactivate assignment" },
{ status: 500 },
);
}
log.info("Trainer-client assignment deactivated", {
assignmentId: id,
deactivatedBy: currentUser.id,
});
return NextResponse.json({
success: true,
message: "Assignment deactivated successfully",
});
} catch (error) {
log.error("Failed to delete trainer-client assignment", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,89 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import { GET, POST } from "../route";
jest.mock("@clerk/nextjs/server", () => ({
auth: jest.fn(),
}));
jest.mock("@/lib/database", () => ({
getDatabase: jest.fn(),
}));
jest.mock("@/lib/sync-user", () => ({
ensureUserSynced: jest.fn(),
}));
jest.mock("@/lib/logger", () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
describe("/api/trainer-client authz", () => {
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock;
const mockEnsureUserSynced = require("@/lib/sync-user")
.ensureUserSynced as jest.Mock;
const mockDb = {
getUserById: jest.fn(),
getTrainerClientAssignments: jest.fn(),
getAllTrainerClientAssignments: jest.fn(),
createTrainerClientAssignment: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetDatabase.mockResolvedValue(mockDb);
});
it("blocks non-admin users from listing assignments", async () => {
mockAuth.mockResolvedValue({ userId: "trainer_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "trainer_1",
role: "trainer",
gymId: "gym_a",
});
const request = new NextRequest("http://localhost/api/trainer-client");
const response = await GET(request);
expect(response.status).toBe(403);
});
it("blocks admins from creating assignments across gyms", async () => {
mockAuth.mockResolvedValue({ userId: "admin_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "admin_1",
role: "admin",
gymId: "gym_a",
});
mockDb.getUserById
.mockResolvedValueOnce({
id: "trainer_2",
role: "trainer",
gymId: "gym_b",
})
.mockResolvedValueOnce({
id: "client_2",
role: "client",
gymId: "gym_b",
});
const request = new NextRequest("http://localhost/api/trainer-client", {
method: "POST",
body: JSON.stringify({ trainerId: "trainer_2", clientId: "client_2" }),
});
const response = await POST(request);
expect(response.status).toBe(403);
expect(mockDb.createTrainerClientAssignment).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,264 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
/**
* GET /api/trainer-client
* Get trainer-client assignments
*
* Query params:
* - trainerId: string (optional) - Filter by trainer
* - clientId: string (optional) - Filter by client
*/
export async function GET(request: NextRequest) {
try {
const { userId: authUserId } = await auth();
if (!authUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
const currentUser = await ensureUserSynced(authUserId, db);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Only admins and superadmins can view all assignments
if (currentUser.role !== "admin" && currentUser.role !== "superAdmin") {
return NextResponse.json(
{ error: "Only admins can view trainer-client assignments" },
{ status: 403 },
);
}
const { searchParams } = new URL(request.url);
const trainerId = searchParams.get("trainerId");
const clientId = searchParams.get("clientId");
if (trainerId && clientId) {
const [trainer, client] = await Promise.all([
db.getUserById(trainerId),
db.getUserById(clientId),
]);
if (!trainer || !client) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (
currentUser.role !== "superAdmin" &&
(!currentUser.gymId ||
trainer.gymId !== currentUser.gymId ||
client.gymId !== currentUser.gymId)
) {
return NextResponse.json(
{ error: "Cannot access assignments from other gyms" },
{ status: 403 },
);
}
}
if (trainerId && !clientId) {
const trainer = await db.getUserById(trainerId);
if (!trainer) {
return NextResponse.json(
{ error: "Trainer not found" },
{ status: 404 },
);
}
if (
currentUser.role !== "superAdmin" &&
(!currentUser.gymId || trainer.gymId !== currentUser.gymId)
) {
return NextResponse.json(
{ error: "Cannot access assignments from other gyms" },
{ status: 403 },
);
}
}
if (!trainerId && clientId) {
const client = await db.getUserById(clientId);
if (!client) {
return NextResponse.json(
{ error: "Client not found" },
{ status: 404 },
);
}
if (
currentUser.role !== "superAdmin" &&
(!currentUser.gymId || client.gymId !== currentUser.gymId)
) {
return NextResponse.json(
{ error: "Cannot access assignments from other gyms" },
{ status: 403 },
);
}
}
let assignments = trainerId
? await db.getTrainerClientAssignments(trainerId)
: await db.getAllTrainerClientAssignments();
if (clientId) {
assignments = assignments.filter((a) => a.clientId === clientId);
}
if (currentUser.role !== "superAdmin") {
if (!currentUser.gymId) {
return NextResponse.json({ error: "No gym assigned" }, { status: 403 });
}
const involvedUserIds = Array.from(
new Set(
assignments.flatMap((assignment) => [
assignment.trainerId,
assignment.clientId,
]),
),
);
const users = await Promise.all(
involvedUserIds.map((userId) => db.getUserById(userId)),
);
const gymByUserId = new Map(
users
.filter((user): user is NonNullable<typeof user> => !!user)
.map((user) => [user.id, user.gymId]),
);
assignments = assignments.filter((assignment) => {
const trainerGymId = gymByUserId.get(assignment.trainerId);
const clientGymId = gymByUserId.get(assignment.clientId);
return (
trainerGymId === currentUser.gymId &&
clientGymId === currentUser.gymId
);
});
}
return NextResponse.json({ assignments });
} catch (error) {
log.error("Failed to get trainer-client assignments", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* POST /api/trainer-client
* Create a new trainer-client assignment
*/
export async function POST(request: NextRequest) {
try {
const { userId: authUserId } = await auth();
if (!authUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
const currentUser = await ensureUserSynced(authUserId, db);
if (!currentUser) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Only admins can create assignments
if (currentUser.role !== "admin" && currentUser.role !== "superAdmin") {
return NextResponse.json(
{ error: "Only admins can assign trainers to clients" },
{ status: 403 },
);
}
const body = await request.json();
const { trainerId, clientId } = body;
if (!trainerId || !clientId) {
return NextResponse.json(
{ error: "trainerId and clientId are required" },
{ status: 400 },
);
}
// Verify trainer exists and has trainer role
const trainer = await db.getUserById(trainerId);
if (!trainer) {
return NextResponse.json({ error: "Trainer not found" }, { status: 404 });
}
if (trainer.role !== "trainer" && trainer.role !== "admin") {
return NextResponse.json(
{ error: "User must be a trainer or admin" },
{ status: 400 },
);
}
// Verify client exists
const client = await db.getUserById(clientId);
if (!client) {
return NextResponse.json({ error: "Client not found" }, { status: 404 });
}
if (client.role !== "client") {
return NextResponse.json(
{ error: "User must be a client" },
{ status: 400 },
);
}
if (
currentUser.role !== "superAdmin" &&
(!currentUser.gymId ||
trainer.gymId !== currentUser.gymId ||
client.gymId !== currentUser.gymId)
) {
return NextResponse.json(
{ error: "Cannot assign users from other gyms" },
{ status: 403 },
);
}
// Check if assignment already exists
const existingAssignments = await db.getTrainerClientAssignments(trainerId);
const existingAssignment = existingAssignments.find(
(a) => a.clientId === clientId && a.isActive,
);
if (existingAssignment) {
return NextResponse.json(
{ error: "Trainer is already assigned to this client" },
{ status: 409 },
);
}
// Create the assignment
const assignment = await db.createTrainerClientAssignment({
trainerId,
clientId,
assignedAt: new Date(),
assignedBy: currentUser.id,
isActive: true,
});
log.info("Trainer-client assignment created", {
trainerId,
clientId,
assignedBy: currentUser.id,
});
return NextResponse.json({ assignment }, { status: 201 });
} catch (error) {
log.error("Failed to create trainer-client assignment", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,109 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import { DELETE } from "../route";
jest.mock("@clerk/nextjs/server", () => ({
auth: jest.fn(),
clerkClient: jest.fn(),
}));
jest.mock("@/lib/database/index", () => ({
getDatabase: jest.fn(),
}));
jest.mock("@/lib/sync-user", () => ({
ensureUserSynced: jest.fn(),
}));
jest.mock("@fitai/database", () => ({
db: {
all: jest.fn(),
get: jest.fn(),
run: jest.fn(),
},
sql: jest.fn((strings: TemplateStringsArray) => strings.join("")),
}));
jest.mock("@/lib/logger", () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
describe("DELETE /api/users authz", () => {
const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock;
const mockGetDatabase = require("@/lib/database/index")
.getDatabase as jest.Mock;
const mockEnsureUserSynced = require("@/lib/sync-user")
.ensureUserSynced as jest.Mock;
const mockDb = {
getUserById: jest.fn(),
deleteUser: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetDatabase.mockResolvedValue(mockDb);
});
it("blocks self deletion", async () => {
mockAuth.mockResolvedValue({ userId: "admin_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "admin_1",
role: "admin",
gymId: "gym_a",
});
const request = new NextRequest("http://localhost/api/users?id=admin_1", {
method: "DELETE",
});
const response = await DELETE(request);
expect(response.status).toBe(403);
expect(mockDb.deleteUser).not.toHaveBeenCalled();
});
it("blocks cross-gym deletion for admin", async () => {
mockAuth.mockResolvedValue({ userId: "admin_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "admin_1",
role: "admin",
gymId: "gym_a",
});
mockDb.getUserById.mockResolvedValue({ id: "user_2", gymId: "gym_b" });
const request = new NextRequest("http://localhost/api/users?id=user_2", {
method: "DELETE",
});
const response = await DELETE(request);
expect(response.status).toBe(403);
expect(mockDb.deleteUser).not.toHaveBeenCalled();
});
it("allows superAdmin cross-gym deletion", async () => {
mockAuth.mockResolvedValue({ userId: "super_1" });
mockEnsureUserSynced.mockResolvedValue({
id: "super_1",
role: "superAdmin",
gymId: null,
});
mockDb.getUserById.mockResolvedValue({ id: "user_2", gymId: "gym_b" });
const request = new NextRequest("http://localhost/api/users?id=user_2", {
method: "DELETE",
});
const response = await DELETE(request);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(mockDb.deleteUser).toHaveBeenCalledWith("user_2");
});
});
+2 -4
View File
@@ -85,14 +85,12 @@ export async function POST(request: NextRequest) {
try {
const client = await clerkClient();
// Build publicMetadata - only include gymId if it exists
// Build publicMetadata with consistent field names
const publicMetadata: Record<string, any> = {
role: data.role,
createdBy: userId,
gymId: assignedGymId ?? null,
};
if (assignedGymId) {
publicMetadata.gymId = assignedGymId;
}
// Determine redirect URL based on role
// Staff (admin, trainer, superAdmin) → Admin web app
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
/**
* GET /api/users/me
* Get current authenticated user's information
*/
export async function GET() {
try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
const user = await ensureUserSynced(clerkUserId, db);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
return NextResponse.json({ user });
} catch (error) {
console.error("Failed to get current user:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+79 -11
View File
@@ -45,12 +45,24 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const role = searchParams.get("role");
log.debug("User API called", {
currentUserId: currentUser.id,
currentUserRole: currentUser.role,
currentUserGymId: currentUser.gymId,
});
// Get target gym based on role
const targetGymId =
currentUser.role === "superAdmin"
? (searchParams.get("gymId") ?? undefined)
: (currentUser.gymId ?? undefined);
log.debug("Target gym calculation", {
targetGymId,
currentUserRole: currentUser.role,
currentUserGymId: currentUser.gymId,
});
// Validate gym access for non-superAdmins
if (currentUser.role !== "superAdmin" && !targetGymId) {
return forbiddenResponse("No gym assigned");
@@ -61,6 +73,12 @@ export async function GET(request: NextRequest) {
? await getUsersByGym(targetGymId)
: await db.getAllUsers();
log.debug("Users fetched from database", {
targetGymId,
totalUsers: Array.isArray(users) ? users.length : 0,
sampleGymId: users && users[0] ? (users[0] as any).gymId : null,
});
// Hydrate gymId from raw DB to ensure consistency with writes
const rawUserRows = await rawDb.all(sql`SELECT id, gym_id FROM users`);
const gymById = new Map<string, string | null>(
@@ -90,12 +108,12 @@ export async function GET(request: NextRequest) {
log.debug("Applied role filter", {
role,
usersAfterFilter: Array.isArray(users) ? users.length : 0,
sample:
sampleUser:
users && users[0]
? {
id: users[0].id,
role: users[0].role,
gymId: (users as any)[0].gymId,
gymId: (users[0] as any).gymId,
}
: null,
});
@@ -562,26 +580,76 @@ export async function PUT(request: NextRequest) {
export async function DELETE(request: NextRequest) {
try {
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return unauthorizedResponse();
}
const db = await getDatabase();
const currentUser = await ensureUserSynced(clerkUserId, db);
if (!currentUser) {
return forbiddenResponse("Current user not found");
}
const canDeleteUsers =
currentUser.role === "admin" || currentUser.role === "superAdmin";
if (!canDeleteUsers) {
return forbiddenResponse("Only admins can delete users");
}
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const body = await request.json().catch(() => ({}));
const { ids } = body;
const targetIds: string[] = Array.isArray(ids)
? ids.filter(
(userId: unknown): userId is string => typeof userId === "string",
)
: id
? [id]
: [];
if (targetIds.length === 0) {
return badRequestResponse("User ID or IDs array required");
}
if (targetIds.includes(clerkUserId)) {
return forbiddenResponse("Cannot delete your own account");
}
const targetUsers = await Promise.all(
targetIds.map((targetId) => db.getUserById(targetId)),
);
const missingTargets = targetUsers.filter((user) => !user).length;
if (missingTargets > 0) {
return notFoundResponse("One or more users were not found");
}
if (currentUser.role !== "superAdmin") {
if (!currentUser.gymId) {
return forbiddenResponse("No gym assigned to current user");
}
const hasCrossGymTarget = targetUsers.some(
(targetUser) => targetUser && targetUser.gymId !== currentUser.gymId,
);
if (hasCrossGymTarget) {
return forbiddenResponse("Cannot delete users from other gyms");
}
}
if (ids && Array.isArray(ids)) {
// Bulk delete
await Promise.all(ids.map((userId: string) => db.deleteUser(userId)));
return successResponse({ deleted: ids.length });
} else if (id) {
// Single delete
const user = await db.getUserById(id);
if (!user) {
return notFoundResponse("User not found");
}
await db.deleteUser(id);
return successResponse({ deleted: 1 });
} else {
return badRequestResponse("User ID or IDs array required");
await db.deleteUser(id as string);
return successResponse({ deleted: 1 });
}
} catch (error) {
log.error("Failed to delete user(s)", error);
+6 -6
View File
@@ -63,22 +63,22 @@ export default function AttendancePage() {
className="border-b border-border/50 hover:bg-muted/50 transition-colors"
>
<td className="py-3 px-4 text-sm">
{record.userId.substring(0, 8)}...
{record.userName || record.userId.substring(0, 8) + "..."}
</td>
<td className="py-3 px-4 text-sm capitalize text-muted-foreground">
{record.type}
</td>
<td className="py-3 px-4 text-sm text-muted-foreground">
{new Date(record.checkIn).toLocaleString()}
{new Date(record.checkInTime).toLocaleString()}
</td>
<td className="py-3 px-4 text-sm text-muted-foreground">
{record.checkOut
? new Date(record.checkOut).toLocaleString()
{record.checkOutTime
? new Date(record.checkOutTime).toLocaleString()
: "-"}
</td>
<td className="py-3 px-4">
<Badge variant={record.checkOut ? "gray" : "success"}>
{record.checkOut ? "Completed" : "Active"}
<Badge variant={record.checkOutTime ? "gray" : "success"}>
{record.checkOutTime ? "Completed" : "Active"}
</Badge>
</td>
</tr>
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { UserReport } from "@/components/reports/UserReport";
import { ReportFilters } from "@/components/reports/ReportFilters";
import { Card, CardContent } from "@/components/ui/card";
export default function ReportsPage() {
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [dateRange, setDateRange] = useState<{
startDate: string;
endDate: string;
}>({
startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0],
endDate: new Date().toISOString().split("T")[0],
});
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900">User Reports</h1>
</div>
<ReportFilters
selectedUserId={selectedUserId}
onUserChange={setSelectedUserId}
dateRange={dateRange}
onDateRangeChange={setDateRange}
/>
{selectedUserId ? (
<UserReport
userId={selectedUserId}
startDate={dateRange.startDate}
endDate={dateRange.endDate}
/>
) : (
<Card>
<CardContent className="flex items-center justify-center h-64">
<p className="text-gray-500 text-lg">
Select a user to view their report
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}
+114 -4
View File
@@ -17,6 +17,7 @@ import {
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
import log from "@/lib/logger";
import { MEMBERSHIP_FEATURES } from "@/lib/membership/features";
interface Backup {
name: string;
@@ -199,14 +200,20 @@ export default function SettingsPage() {
setDeletingGym(true);
setGymMessage(null);
try {
await axios.delete(`/api/gyms/${gymId}`);
const response = await axios.delete(`/api/gyms/${gymId}`);
log.info("Delete gym response:", response.data);
setGymMessage({ type: "success", text: "Gym deleted successfully" });
setSelectedGym(null);
setGymStats(null);
fetchGyms();
} catch (error) {
await fetchGyms();
} catch (error: any) {
log.error("Failed to delete gym", error);
setGymMessage({ type: "error", text: "Failed to delete gym" });
const errorMessage =
error.response?.data?.error ||
error.response?.data ||
error.message ||
"Failed to delete gym";
setGymMessage({ type: "error", text: errorMessage });
} finally {
setDeletingGym(false);
}
@@ -552,6 +559,109 @@ export default function SettingsPage() {
</div>
</div>
)}
{/* Membership Feature Access */}
<div className="mt-6">
<h5 className="text-sm font-medium text-slate-700 mb-2">
Membership Feature Access
</h5>
<div className="overflow-x-auto border rounded-lg">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 font-semibold text-slate-900">
Feature
</th>
<th className="px-4 py-3 font-semibold text-slate-900">
Basic
</th>
<th className="px-4 py-3 font-semibold text-slate-900">
Premium
</th>
<th className="px-4 py-3 font-semibold text-slate-900">
VIP
</th>
</tr>
</thead>
<tbody className="divide-y">
<tr>
<td className="px-4 py-3 text-slate-700">
Recommendations per month
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.basic.recommendationsPerMonth}
</td>
<td className="px-4 py-3 text-slate-700">
Unlimited
</td>
<td className="px-4 py-3 text-slate-700">
Unlimited
</td>
</tr>
<tr>
<td className="px-4 py-3 text-slate-700">
Nutrition tracking
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.basic.nutritionTracking
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.premium.nutritionTracking
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.vip.nutritionTracking
? "Yes"
: "No"}
</td>
</tr>
<tr>
<td className="px-4 py-3 text-slate-700">
Hydration tracking
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.basic.hydrationTracking
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.premium.hydrationTracking
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.vip.hydrationTracking
? "Yes"
: "No"}
</td>
</tr>
<tr>
<td className="px-4 py-3 text-slate-700">
Advanced statistics
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.basic.advancedStatistics
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.premium.advancedStatistics
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.vip.advancedStatistics
? "Yes"
: "No"}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center h-64 bg-slate-50 rounded-lg">
+298
View File
@@ -0,0 +1,298 @@
"use client";
import { useState, useEffect } from "react";
import { User, TrainerClientAssignment } from "@fitai/shared";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Plus, UserCheck, UserX } from "lucide-react";
export default function TrainerClientsPage() {
const [trainers, setTrainers] = useState<User[]>([]);
const [clients, setClients] = useState<User[]>([]);
const [assignments, setAssignments] = useState<
(TrainerClientAssignment & { trainer?: User; client?: User })[]
>([]);
const [loading, setLoading] = useState(true);
const [selectedTrainer, setSelectedTrainer] = useState<string>("");
const [selectedClient, setSelectedClient] = useState<string>("");
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const [trainersRes, clientsRes, assignmentsRes] = await Promise.all([
fetch("/api/users?role=trainer"),
fetch("/api/users?role=client"),
fetch("/api/trainer-client"),
]);
const fetchedTrainers: User[] = trainersRes.ok
? (await trainersRes.json()).data?.users || []
: [];
const fetchedClients: User[] = clientsRes.ok
? (await clientsRes.json()).data?.users || []
: [];
setTrainers(fetchedTrainers);
setClients(fetchedClients);
if (assignmentsRes.ok) {
const assignmentsData = await assignmentsRes.json();
const enrichedAssignments = (assignmentsData.assignments || []).map(
(assignment: TrainerClientAssignment) => {
return {
...assignment,
trainer: fetchedTrainers.find(
(t) => t.id === assignment.trainerId,
),
client: fetchedClients.find((c) => c.id === assignment.clientId),
};
},
);
setAssignments(enrichedAssignments);
}
} catch (error) {
console.error("Failed to fetch data:", error);
} finally {
setLoading(false);
}
};
const handleAssign = async () => {
if (!selectedTrainer || !selectedClient) {
alert("Please select both a trainer and a client");
return;
}
try {
const response = await fetch("/api/trainer-client", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
trainerId: selectedTrainer,
clientId: selectedClient,
}),
});
if (response.ok) {
alert("Assignment created successfully!");
setSelectedTrainer("");
setSelectedClient("");
fetchData();
} else {
const error = await response.json();
alert(error.error || "Failed to create assignment");
}
} catch (error) {
console.error("Failed to create assignment:", error);
alert("Failed to create assignment");
}
};
const handleRemoveAssignment = async (id: string) => {
if (!confirm("Are you sure you want to remove this assignment?")) {
return;
}
try {
const response = await fetch(`/api/trainer-client/${id}`, {
method: "DELETE",
});
if (response.ok) {
alert("Assignment removed successfully!");
fetchData();
} else {
const error = await response.json();
alert(error.error || "Failed to remove assignment");
}
} catch (error) {
console.error("Failed to remove assignment:", error);
alert("Failed to remove assignment");
}
};
const getActiveAssignments = () => assignments.filter((a) => a.isActive);
const getInactiveAssignments = () => assignments.filter((a) => !a.isActive);
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-center h-64">
<div className="text-lg">Loading...</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900">
Trainer-Client Assignments
</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Assign Trainer to Client</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Select Trainer</label>
<Select
value={selectedTrainer}
onValueChange={setSelectedTrainer}
>
<SelectTrigger>
<SelectValue placeholder="Choose a trainer..." />
</SelectTrigger>
<SelectContent>
{trainers.map((trainer) => (
<SelectItem key={trainer.id} value={trainer.id}>
{trainer.firstName} {trainer.lastName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Select Client</label>
<Select
value={selectedClient}
onValueChange={setSelectedClient}
>
<SelectTrigger>
<SelectValue placeholder="Choose a client..." />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.firstName} {client.lastName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button onClick={handleAssign} className="w-full">
<Plus className="w-4 h-4 mr-2" />
Assign
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>Active Assignments</CardTitle>
<Badge variant="default">{getActiveAssignments().length}</Badge>
</div>
</CardHeader>
<CardContent>
{getActiveAssignments().length === 0 ? (
<p className="text-gray-500 text-center py-8">
No active assignments
</p>
) : (
<div className="space-y-2">
{getActiveAssignments().map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<UserCheck className="w-4 h-4 text-green-600" />
<span className="font-medium">
{assignment.trainer?.firstName}{" "}
{assignment.trainer?.lastName}
</span>
</div>
<span className="text-gray-400">→</span>
<span>
{assignment.client?.firstName}{" "}
{assignment.client?.lastName}
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500">
Assigned{" "}
{new Date(assignment.assignedAt).toLocaleDateString()}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAssignment(assignment.id)}
>
<UserX className="w-4 h-4 text-red-600" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{getInactiveAssignments().length > 0 && (
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="text-gray-500">
Inactive Assignments
</CardTitle>
<Badge variant="secondary">
{getInactiveAssignments().length}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{getInactiveAssignments().map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between p-4 border rounded-lg bg-gray-50 opacity-60"
>
<div className="flex items-center space-x-4">
<span className="font-medium text-gray-600">
{assignment.trainer?.firstName}{" "}
{assignment.trainer?.lastName}
</span>
<span className="text-gray-400">→</span>
<span className="text-gray-600">
{assignment.client?.firstName}{" "}
{assignment.client?.lastName}
</span>
</div>
<Badge variant="secondary">Inactive</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}
@@ -0,0 +1,132 @@
"use client";
import React from "react";
import { AgCharts } from "ag-charts-react";
import {
AgBarSeriesOptions,
AgChartOptions,
AgLineSeriesOptions,
AgPieSeriesOptions,
} from "ag-charts-community";
export function BarChart({
data,
xKey,
yKey,
color = "#3c82e6",
height = 300,
}: {
data: any[];
xKey: string;
yKey: string;
color?: string;
height?: number;
}) {
const options: AgChartOptions = {
data,
series: [
{
type: "bar",
xKey,
yKey,
fill: color,
} as AgBarSeriesOptions,
],
height,
};
return <AgCharts options={options} />;
}
export function LineChart({
data,
xKey,
yKey,
color = "#3b82f6",
height = 300,
}: {
data: any[];
xKey: string;
yKey: string;
color?: string;
height?: number;
}) {
const options: AgChartOptions = {
data,
series: [
{
type: "line",
xKey,
yKey,
stroke: color,
strokeWidth: 3,
marker: {
size: 6,
fill: color,
stroke: "#ffffff",
strokeWidth: 2,
},
} as AgLineSeriesOptions,
],
height,
};
return <AgCharts options={options} />;
}
export function PieChart({
data,
labelKey,
valueKey,
height = 300,
}: {
data: any[];
labelKey: string;
valueKey: string;
height?: number;
}) {
const options: AgChartOptions = {
data,
series: [
{
type: "pie",
labelKey,
angleKey: valueKey,
} as AgPieSeriesOptions,
],
height,
};
return <AgCharts options={options} />;
}
export function AreaChart({
data,
xKey,
yKey,
color = "#3b82f6",
height = 300,
}: {
data: any[];
xKey: string;
yKey: string;
color?: string;
height?: number;
}) {
const options: AgChartOptions = {
data,
series: [
{
type: "area",
xKey,
yKey,
fill: color,
stroke: color,
strokeWidth: 2,
},
],
height,
};
return <AgCharts options={options} />;
}
@@ -0,0 +1,108 @@
"use client";
import { GoalSummary } from "@fitai/shared";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface GoalsSummaryCardProps {
goals: GoalSummary;
}
export function GoalsSummaryCard({ goals }: GoalsSummaryCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>Fitness Goals</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Summary Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{goals.totalActive}
</div>
<div className="text-xs text-gray-600">Active Goals</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{goals.totalCompleted}
</div>
<div className="text-xs text-gray-600">Completed</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{goals.averageProgress}%
</div>
<div className="text-xs text-gray-600">Avg Progress</div>
</div>
</div>
{/* Active Goals */}
{goals.active.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold">Active Goals</h4>
<div className="space-y-2">
{goals.active.map((goal) => (
<div key={goal.id} className="border rounded-lg p-3">
<div className="flex justify-between items-start mb-2">
<div className="font-medium">{goal.title}</div>
<Badge
variant={
goal.priority === "high" ? "destructive" : "secondary"
}
>
{goal.priority}
</Badge>
</div>
<div className="text-xs text-gray-600 mb-2">
{goal.goalType.replace("_", " ")}
</div>
{/* Progress Bar */}
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${goal.progress}%` }}
/>
</div>
<div className="text-xs text-gray-600 mt-1">
{goal.progress}% complete
</div>
</div>
))}
</div>
</div>
)}
{/* Completed Goals */}
{goals.completed.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold">Completed Goals</h4>
<div className="space-y-1">
{goals.completed.map((goal) => (
<div
key={goal.id}
className="flex justify-between items-center text-sm p-2 bg-green-50 rounded"
>
<span className="font-medium">{goal.title}</span>
<span className="text-xs text-green-700">
✓{" "}
{goal.completedDate
? new Date(goal.completedDate).toLocaleDateString()
: "N/A"}
</span>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{goals.active.length === 0 && goals.completed.length === 0 && (
<div className="text-center text-gray-500 py-8">
No goals found for this period
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,119 @@
"use client";
import { HydrationSummary } from "@fitai/shared";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Legend,
Tooltip,
} from "recharts";
interface HydrationSummaryCardProps {
hydration: {
dailySummaries: HydrationSummary[];
averageDailyWater: number;
totalDays: number;
daysMetGoal: number;
};
}
export function HydrationSummaryCard({ hydration }: HydrationSummaryCardProps) {
const goalMetPercentage =
hydration.totalDays > 0
? Math.round((hydration.daysMetGoal / hydration.totalDays) * 100)
: 0;
const chartData = [
{ name: "Days Met Goal", value: hydration.daysMetGoal, color: "#06b6d4" },
{
name: "Days Under Goal",
value: hydration.totalDays - hydration.daysMetGoal,
color: "#f59e0b",
},
];
return (
<Card>
<CardHeader>
<CardTitle>Hydration Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{(hydration.averageDailyWater / 1000).toFixed(1)}L
</div>
<div className="text-xs text-gray-600">Avg Daily Water</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-cyan-600">
{hydration.totalDays}
</div>
<div className="text-xs text-gray-600">Days Tracked</div>
</div>
</div>
{/* Goal Achievement */}
<div className="text-center">
<div className="text-3xl font-bold text-green-600">
{goalMetPercentage}%
</div>
<div className="text-xs text-gray-600">Days Met Hydration Goal</div>
</div>
{/* Chart */}
{hydration.totalDays > 0 && (
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={70}
paddingAngle={5}
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
)}
{/* Recent Days */}
{hydration.dailySummaries.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold">Recent Days</h4>
<div className="max-h-32 overflow-y-auto space-y-1">
{hydration.dailySummaries
.slice(-5)
.reverse()
.map((day) => (
<div
key={day.date}
className="flex justify-between items-center text-sm"
>
<span className="text-gray-600">{day.date}</span>
<span className="font-medium">
{day.totalWater}ml / {day.waterGoal}ml (
{day.hydrationPercentage}%)
</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,121 @@
"use client";
import { NutritionSummary } from "@fitai/shared";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Legend,
Tooltip,
} from "recharts";
interface NutritionSummaryCardProps {
nutrition: {
dailySummaries: NutritionSummary[];
averageDailyCalories: number;
totalDays: number;
daysMetGoal: number;
};
}
export function NutritionSummaryCard({ nutrition }: NutritionSummaryCardProps) {
const goalMetPercentage =
nutrition.totalDays > 0
? Math.round((nutrition.daysMetGoal / nutrition.totalDays) * 100)
: 0;
const chartData = [
{ name: "Days Met Goal", value: nutrition.daysMetGoal, color: "#10b981" },
{
name: "Days Over/Under",
value: nutrition.totalDays - nutrition.daysMetGoal,
color: "#f59e0b",
},
];
return (
<Card>
<CardHeader>
<CardTitle>Nutrition Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{nutrition.averageDailyCalories.toLocaleString()}
</div>
<div className="text-xs text-gray-600">Avg Daily Calories</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{nutrition.totalDays}
</div>
<div className="text-xs text-gray-600">Days Tracked</div>
</div>
</div>
{/* Goal Achievement */}
<div className="text-center">
<div className="text-3xl font-bold text-purple-600">
{goalMetPercentage}%
</div>
<div className="text-xs text-gray-600">
Days Met Calorie Goal (±10%)
</div>
</div>
{/* Chart */}
{nutrition.totalDays > 0 && (
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={70}
paddingAngle={5}
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
)}
{/* Recent Days */}
{nutrition.dailySummaries.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold">Recent Days</h4>
<div className="max-h-32 overflow-y-auto space-y-1">
{nutrition.dailySummaries
.slice(-5)
.reverse()
.map((day) => (
<div
key={day.date}
className="flex justify-between items-center text-sm"
>
<span className="text-gray-600">{day.date}</span>
<span className="font-medium">
{day.totalCalories.toLocaleString()} /{" "}
{day.calorieGoal.toLocaleString()} cal
</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,130 @@
"use client";
import { RecommendationSummary } from "@fitai/shared";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface RecommendationsCardProps {
recommendations: RecommendationSummary;
}
export function RecommendationsCard({
recommendations,
}: RecommendationsCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>AI Recommendations</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Summary Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{recommendations.totalAccepted}
</div>
<div className="text-xs text-gray-600">Accepted</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-600">
{recommendations.totalPending}
</div>
<div className="text-xs text-gray-600">Pending</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">
{recommendations.totalRejected}
</div>
<div className="text-xs text-gray-600">Rejected</div>
</div>
</div>
{/* Accepted Recommendations */}
{recommendations.accepted.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-green-700">
Accepted Recommendations
</h4>
<div className="space-y-2">
{recommendations.accepted.map((rec) => (
<div
key={rec.id}
className="border border-green-200 bg-green-50 rounded-lg p-3"
>
<div className="flex justify-between items-start mb-2">
<Badge variant="default" className="bg-green-600">
Accepted
</Badge>
<span className="text-xs text-gray-500">
{new Date(rec.generatedAt).toLocaleDateString()}
</span>
</div>
<div className="text-sm">{rec.recommendationText}</div>
</div>
))}
</div>
</div>
)}
{/* Pending Recommendations */}
{recommendations.pending.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-yellow-700">
Pending Recommendations
</h4>
<div className="space-y-2">
{recommendations.pending.map((rec) => (
<div
key={rec.id}
className="border border-yellow-200 bg-yellow-50 rounded-lg p-3"
>
<div className="flex justify-between items-start mb-2">
<Badge variant="secondary">Pending</Badge>
<span className="text-xs text-gray-500">
{new Date(rec.generatedAt).toLocaleDateString()}
</span>
</div>
<div className="text-sm">{rec.recommendationText}</div>
</div>
))}
</div>
</div>
)}
{/* Rejected Recommendations */}
{recommendations.rejected.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-red-700">
Rejected Recommendations
</h4>
<div className="space-y-2">
{recommendations.rejected.map((rec) => (
<div
key={rec.id}
className="border border-red-200 bg-red-50 rounded-lg p-3"
>
<div className="flex justify-between items-start mb-2">
<Badge variant="destructive">Rejected</Badge>
<span className="text-xs text-gray-500">
{new Date(rec.generatedAt).toLocaleDateString()}
</span>
</div>
<div className="text-sm">{rec.recommendationText}</div>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{recommendations.totalAccepted === 0 &&
recommendations.totalPending === 0 &&
recommendations.totalRejected === 0 && (
<div className="text-center text-gray-500 py-8">
No recommendations found for this period
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,234 @@
"use client";
import { useState, useEffect } from "react";
import { User, Client } from "@fitai/shared";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { useUser } from "@clerk/nextjs";
import log from "@/lib/logger";
interface ReportFiltersProps {
selectedUserId: string;
onUserChange: (userId: string) => void;
dateRange: {
startDate: string;
endDate: string;
};
onDateRangeChange: (range: { startDate: string; endDate: string }) => void;
}
export function ReportFilters({
selectedUserId,
onUserChange,
dateRange,
onDateRangeChange,
}: ReportFiltersProps) {
const { user: clerkUser } = useUser();
const [users, setUsers] = useState<(User & { client?: Client | null })[]>([]);
const [loading, setLoading] = useState(true);
const [currentUser, setCurrentUser] = useState<User | null>(null);
useEffect(() => {
fetchCurrentUserAndClients();
}, []);
const fetchCurrentUserAndClients = async () => {
try {
setLoading(true);
const userResponse = await fetch("/api/users/me");
if (!userResponse.ok) {
setLoading(false);
return;
}
const userData = await userResponse.json();
setCurrentUser(userData.user);
const currentRole = userData.user.role;
let allUsers: (User & { client?: Client | null })[] = [];
if (currentRole === "client") {
setUsers([userData.user]);
onUserChange(userData.user.id);
setLoading(false);
return;
}
if (currentRole === "trainer") {
allUsers.push(userData.user);
const assignmentsRes = await fetch("/api/trainer-client");
if (assignmentsRes.ok) {
const assignmentsData = await assignmentsRes.json();
const assignedClientIds = (assignmentsData.assignments || [])
.filter((a: any) => a.isActive)
.map((a: any) => a.clientId);
if (assignedClientIds.length > 0) {
const clientsRes = await fetch(
`/api/users?role=client&ids=${assignedClientIds.join(",")}`,
);
if (clientsRes.ok) {
const clientsData = await clientsRes.json();
allUsers = [...allUsers, ...(clientsData.data?.users || [])];
}
}
}
setUsers(allUsers);
setLoading(false);
return;
}
if (currentRole === "admin") {
allUsers.push(userData.user);
}
if (currentRole === "superAdmin" || currentRole === "admin") {
const [adminsRes, trainersRes, clientsRes] = await Promise.all([
fetch("/api/users?role=admin"),
fetch("/api/users?role=trainer"),
fetch("/api/users?role=client"),
]);
const [adminsData, trainersData, clientsData] = await Promise.all([
adminsRes.ok ? adminsRes.json() : { data: { users: [] } },
trainersRes.ok ? trainersRes.json() : { data: { users: [] } },
clientsRes.ok ? clientsRes.json() : { data: { users: [] } },
]);
allUsers = [
...allUsers,
...(adminsData.data?.users || []),
...(trainersData.data?.users || []),
...(clientsData.data?.users || []),
];
if (currentRole === "superAdmin") {
const superAdminsRes = await fetch("/api/users?role=superAdmin");
if (superAdminsRes.ok) {
const superAdminsData = await superAdminsRes.json();
allUsers = [...(superAdminsData.data?.users || []), ...allUsers];
}
}
setUsers(allUsers);
setLoading(false);
return;
}
setUsers(allUsers);
} catch (error) {
log.error("Failed to fetch users:", error);
} finally {
setLoading(false);
}
};
const handlePresetRange = (days: number) => {
const end = new Date();
const start = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
onDateRangeChange({
startDate: start.toISOString().split("T")[0],
endDate: end.toISOString().split("T")[0],
});
};
return (
<Card>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* User Selector */}
<div className="space-y-2">
<Label htmlFor="user-select">Select User</Label>
<Select
value={selectedUserId}
onValueChange={onUserChange}
disabled={loading}
>
<SelectTrigger id="user-select">
<SelectValue placeholder="Choose a user..." />
</SelectTrigger>
<SelectContent>
{users.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.firstName} {user.lastName} ({user.role})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Start Date */}
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<input
id="start-date"
type="date"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={dateRange.startDate}
onChange={(e) =>
onDateRangeChange({
...dateRange,
startDate: e.target.value,
})
}
/>
</div>
{/* End Date */}
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<input
id="end-date"
type="date"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={dateRange.endDate}
onChange={(e) =>
onDateRangeChange({
...dateRange,
endDate: e.target.value,
})
}
/>
</div>
{/* Preset Ranges */}
<div className="space-y-2">
<Label>Quick Select</Label>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePresetRange(7)}
>
7 Days
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePresetRange(30)}
>
30 Days
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePresetRange(90)}
>
90 Days
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useState, useEffect } from "react";
import { UserReport as UserReportType } from "@fitai/shared";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { WeeklyCheckInsCard } from "./WeeklyCheckInsCard";
import { NutritionSummaryCard } from "./NutritionSummaryCard";
import { HydrationSummaryCard } from "./HydrationSummaryCard";
import { GoalsSummaryCard } from "./GoalsSummaryCard";
import { RecommendationsCard } from "./RecommendationsCard";
import { Button } from "@/components/ui/button";
import { Download } from "lucide-react";
interface UserReportProps {
userId: string;
startDate: string;
endDate: string;
}
export function UserReport({ userId, startDate, endDate }: UserReportProps) {
const [report, setReport] = useState<UserReportType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchReport();
}, [userId, startDate, endDate]);
const fetchReport = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(
`/api/reports/user/${userId}?startDate=${startDate}&endDate=${endDate}`,
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to fetch report");
}
const data = await response.json();
setReport(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load report");
console.error("Report fetch error:", err);
} finally {
setLoading(false);
}
};
const handleExportPDF = () => {
if (!report) return;
const filename = `FitAI_Report_${report.user.firstName}_${report.user.lastName}_${startDate}_${endDate}.pdf`;
const url = `/api/reports/user/${userId}?startDate=${startDate}&endDate=${endDate}&format=pdf`;
// Open in new tab to trigger download
window.open(url, "_blank");
};
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center h-64">
<div className="text-lg text-gray-600">Loading report...</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardContent className="flex items-center justify-center h-64">
<div className="text-lg text-red-600">{error}</div>
</CardContent>
</Card>
);
}
if (!report) {
return (
<Card>
<CardContent className="flex items-center justify-center h-64">
<div className="text-lg text-gray-600">No report data available</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Report Header */}
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle className="text-2xl">
{report.user.firstName} {report.user.lastName}
</CardTitle>
<p className="text-sm text-gray-600 mt-1">
{report.user.email} • {report.client?.membershipType || "N/A"}{" "}
Member
</p>
<p className="text-xs text-gray-500 mt-1">
Report Period: {report.reportPeriod.startDate} to{" "}
{report.reportPeriod.endDate}
</p>
</div>
<Button onClick={handleExportPDF} variant="default">
<Download className="w-4 h-4 mr-2" />
Export PDF
</Button>
</div>
</CardHeader>
</Card>
{/* Report Sections */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<WeeklyCheckInsCard weeklyCheckIns={report.weeklyCheckIns} />
<NutritionSummaryCard nutrition={report.nutrition} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<HydrationSummaryCard hydration={report.hydration} />
<GoalsSummaryCard goals={report.goals} />
</div>
<div className="grid grid-cols-1 gap-6">
<RecommendationsCard recommendations={report.recommendations} />
</div>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import { WeeklyCheckInStats } from "@fitai/shared";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
interface WeeklyCheckInsCardProps {
weeklyCheckIns: WeeklyCheckInStats[];
}
export function WeeklyCheckInsCard({
weeklyCheckIns,
}: WeeklyCheckInsCardProps) {
const totalCheckIns = weeklyCheckIns.reduce(
(sum, week) => sum + week.totalCheckIns,
0,
);
const totalTimeMinutes = weeklyCheckIns.reduce(
(sum, week) => sum + week.totalTimeMinutes,
0,
);
const avgTimeMinutes =
totalCheckIns > 0 ? Math.round(totalTimeMinutes / totalCheckIns) : 0;
const chartData = weeklyCheckIns.map((week) => ({
week: week.weekStart,
checkIns: week.totalCheckIns,
timeMinutes: week.totalTimeMinutes,
}));
return (
<Card>
<CardHeader>
<CardTitle>Weekly Check-ins</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{totalCheckIns}
</div>
<div className="text-xs text-gray-600">Total Check-ins</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{Math.floor(totalTimeMinutes / 60)}h {totalTimeMinutes % 60}m
</div>
<div className="text-xs text-gray-600">Total Time</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{avgTimeMinutes}m
</div>
<div className="text-xs text-gray-600">Avg Duration</div>
</div>
</div>
{chartData.length > 0 ? (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="week" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="checkIns" fill="#3c82e6" name="Check-ins" />
<Bar dataKey="timeMinutes" fill="#10b981" name="Minutes" />
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-64 flex items-center justify-center text-gray-500">
No check-in data available
</div>
)}
{weeklyCheckIns.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold">Weekly Breakdown</h4>
<div className="space-y-1">
{weeklyCheckIns.slice(-4).map((week) => (
<div
key={week.weekStart}
className="flex justify-between items-center text-sm"
>
<span className="text-gray-600">
{week.weekStart} - {week.weekEnd}
</span>
<span className="font-medium">
{week.totalCheckIns} check-ins • {week.totalTimeMinutes} min
</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
),
);
Label.displayName = "Label";
export { Label };
+160
View File
@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
@@ -174,6 +174,10 @@ export function CreateUserModal({
// Reset form
resetModal();
// Add delay to allow Clerk API to propagate invitation
await new Promise((resolve) => setTimeout(resolve, 1000));
onSuccess();
onOpenChange(false);
} catch (error) {
+1 -1
View File
@@ -260,7 +260,7 @@ export function UserGrid({
// },
{
headerName: "Last Visit",
valueGetter: (params) => params.data?.client?.lastVisit,
valueGetter: (params) => params.data?.lastCheckInTime,
filter: "agDateColumnFilter",
sortable: true,
valueFormatter: (params: any) =>
@@ -587,9 +587,9 @@ export function UserManagement({ gymId }: UserManagementProps) {
</p>
<p>
<span className="font-medium">Last Visit:</span>{" "}
{selectedUser.client.lastVisit
{selectedUser.lastCheckInTime
? new Date(
selectedUser.client.lastVisit,
selectedUser.lastCheckInTime,
).toLocaleDateString()
: "Never"}
</p>
+19 -2
View File
@@ -55,8 +55,10 @@ export interface Gym {
export interface AttendanceRecord {
id: string;
userId: string;
checkIn: string;
checkOut?: string;
userName?: string;
userEmail?: string;
checkInTime: Date;
checkOutTime?: Date;
date: string;
type?: string;
}
@@ -365,6 +367,21 @@ export function useInvitations(gymId?: string) {
(res) => res.data?.invitations ?? [],
);
},
refetchInterval: (query) => {
const data = query.state.data;
const hasData = data && data.length > 0;
const fetchCount = query.state.dataUpdateCount;
// Poll every 2 seconds if:
// 1. No invitations returned yet
// 2. Haven't exceeded 5 attempts (10 seconds total)
if (!hasData && fetchCount < 5) {
return 2000;
}
// Stop polling after 10 seconds or when data is present
return false;
},
});
}
+4 -6
View File
@@ -1,11 +1,7 @@
import { clerkClient } from "@clerk/nextjs/server";
import { type UserRole } from "@fitai/shared";
import log from "./logger";
/**
* User roles available in the application
*/
export type UserRole = "admin" | "trainer" | "client";
/**
* Set a user's role in Clerk public metadata
* This will trigger a webhook that syncs the role to the database
@@ -71,7 +67,8 @@ export async function hasRole(
* const isAdmin = await isAdmin('user_abc123');
*/
export async function isAdmin(userId: string): Promise<boolean> {
return hasRole(userId, "admin");
const role = await getUserRole(userId);
return role === "admin" || role === "superAdmin";
}
/**
@@ -161,6 +158,7 @@ export async function getUserCountByRole(): Promise<Record<UserRole, number>> {
const { data: users } = await client.users.getUserList();
const counts: Record<UserRole, number> = {
superAdmin: 0,
admin: 0,
trainer: 0,
client: 0,
+685 -5
View File
@@ -7,6 +7,11 @@ import {
Recommendation,
FitnessGoal,
Notification,
DailyNutrition,
DailyHydration,
MealEntry,
FitnessProfileHistory,
TrainerClientAssignment,
DatabaseConfig,
} from "./types";
import {
@@ -18,6 +23,11 @@ import {
recommendations,
fitnessGoals,
notifications,
dailyNutrition,
dailyHydration,
mealEntries,
fitnessProfileHistory,
trainerClientAssignments,
eq,
and,
desc,
@@ -1318,9 +1328,14 @@ export class DrizzleDatabase implements IDatabase {
membershipStatus: String(
row.membershipStatus,
) as Client["membershipStatus"],
joinDate: new Date(row.joinDate as number | Date),
joinDate:
typeof row.joinDate === "number"
? new Date(row.joinDate * 1000)
: new Date(row.joinDate as Date),
lastVisit: row.lastVisit
? new Date(row.lastVisit as number | Date)
? typeof row.lastVisit === "number"
? new Date(row.lastVisit * 1000)
: new Date(row.lastVisit as Date)
: undefined,
emergencyContact: row.emergencyContactName
? {
@@ -1363,12 +1378,20 @@ export class DrizzleDatabase implements IDatabase {
id: String(row.id),
userId: String(row.userId),
type: String(row.type) as Attendance["type"],
checkInTime: new Date(row.checkInTime as number | Date),
checkInTime:
typeof row.checkInTime === "number"
? new Date(row.checkInTime * 1000)
: new Date(row.checkInTime as Date),
checkOutTime: row.checkOutTime
? new Date(row.checkOutTime as number | Date)
? typeof row.checkOutTime === "number"
? new Date(row.checkOutTime * 1000)
: new Date(row.checkOutTime as Date)
: undefined,
notes: row.notes ? String(row.notes) : undefined,
createdAt: new Date(row.createdAt as number | Date),
createdAt:
typeof row.createdAt === "number"
? new Date(row.createdAt * 1000)
: new Date(row.createdAt as Date),
};
}
@@ -1511,4 +1534,661 @@ export class DrizzleDatabase implements IDatabase {
createdAt,
};
}
// ==================== DAILY NUTRITION OPERATIONS ====================
async createDailyNutrition(
nutrition: Omit<DailyNutrition, "id" | "createdAt" | "updatedAt">,
): Promise<DailyNutrition> {
const id = `nutrition_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const newNutrition = {
id,
...nutrition,
createdAt: now,
updatedAt: now,
};
await this.db.insert(dailyNutrition).values(newNutrition as any);
return this.mapDailyNutrition(newNutrition);
}
async getDailyNutrition(
userId: string,
date: string,
): Promise<DailyNutrition | null> {
const results = await this.db
.select()
.from(dailyNutrition)
.where(
and(eq(dailyNutrition.userId, userId), eq(dailyNutrition.date, date)),
)
.limit(1)
.all();
return results.length > 0 ? this.mapDailyNutrition(results[0]) : null;
}
async getDailyNutritionById(id: string): Promise<DailyNutrition | null> {
const results = await this.db
.select()
.from(dailyNutrition)
.where(eq(dailyNutrition.id, id))
.limit(1)
.all();
return results.length > 0 ? this.mapDailyNutrition(results[0]) : null;
}
async getDailyNutritionRange(
userId: string,
startDate: string,
endDate: string,
): Promise<DailyNutrition[]> {
const results = await this.db
.select()
.from(dailyNutrition)
.where(
and(
eq(dailyNutrition.userId, userId),
gte(dailyNutrition.date, startDate),
lte(dailyNutrition.date, endDate),
),
)
.orderBy(dailyNutrition.date)
.all();
return results.map((row) => this.mapDailyNutrition(row));
}
async updateDailyNutrition(
id: string,
updates: Partial<DailyNutrition>,
): Promise<DailyNutrition | null> {
const { id: _, ...updateData } = updates as any;
if (Object.keys(updateData).length === 0) {
const existing = await this.db
.select()
.from(dailyNutrition)
.where(eq(dailyNutrition.id, id))
.limit(1)
.all();
return existing.length > 0 ? this.mapDailyNutrition(existing[0]) : null;
}
await this.db
.update(dailyNutrition)
.set({ ...updateData, updatedAt: new Date() })
.where(eq(dailyNutrition.id, id))
.run();
const results = await this.db
.select()
.from(dailyNutrition)
.where(eq(dailyNutrition.id, id))
.limit(1)
.all();
return results.length > 0 ? this.mapDailyNutrition(results[0]) : null;
}
async deleteDailyNutrition(id: string): Promise<boolean> {
const result = await this.db
.delete(dailyNutrition)
.where(eq(dailyNutrition.id, id))
.run();
return result.changes > 0;
}
private mapDailyNutrition(row: Record<string, unknown>): DailyNutrition {
const createdAtValue = row.createdAt as number | Date;
const updatedAtValue = row.updatedAt as number | Date;
return {
id: String(row.id),
userId: String(row.userId),
date: String(row.date),
totalCalories: Number(row.totalCalories || 0),
calorieGoal: Number(row.calorieGoal || 2000),
meals: row.meals ? (JSON.parse(String(row.meals)) as any) : undefined,
createdAt:
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000)
: new Date(createdAtValue),
updatedAt:
typeof updatedAtValue === "number"
? new Date(updatedAtValue * 1000)
: new Date(updatedAtValue),
};
}
// ==================== MEAL ENTRY OPERATIONS ====================
async createMealEntry(
meal: Omit<MealEntry, "id" | "createdAt">,
): Promise<MealEntry> {
const id = `meal_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const newMeal = {
id,
...meal,
createdAt: now,
};
await this.db.insert(mealEntries).values(newMeal as any);
return this.mapMealEntry(newMeal);
}
async getMealEntriesByDate(
userId: string,
date: string,
): Promise<MealEntry[]> {
// Parse date string to get start and end of day in seconds
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const startTimestamp = Math.floor(startOfDay.getTime() / 1000);
const endTimestamp = Math.floor(endOfDay.getTime() / 1000);
const results = await this.db
.select()
.from(mealEntries)
.where(
and(
eq(mealEntries.userId, userId),
sql`${mealEntries.timestamp} >= ${startTimestamp}`,
sql`${mealEntries.timestamp} <= ${endTimestamp}`,
),
)
.orderBy(mealEntries.timestamp)
.all();
return results.map((row) => this.mapMealEntry(row));
}
async getMealEntryById(id: string): Promise<MealEntry | null> {
const results = await this.db
.select()
.from(mealEntries)
.where(eq(mealEntries.id, id))
.limit(1)
.all();
return results.length > 0 ? this.mapMealEntry(results[0]) : null;
}
async deleteMealEntry(id: string): Promise<boolean> {
const result = await this.db
.delete(mealEntries)
.where(eq(mealEntries.id, id))
.run();
return result.changes > 0;
}
private mapMealEntry(row: Record<string, unknown>): MealEntry {
const timestampValue = row.timestamp as number | Date;
const createdAtValue = row.createdAt as number | Date;
return {
id: String(row.id),
userId: String(row.userId),
dailyNutritionId: row.dailyNutritionId
? String(row.dailyNutritionId)
: undefined,
mealType: String(row.mealType) as MealEntry["mealType"],
foodName: String(row.foodName),
calories: Number(row.calories),
protein: row.protein ? Number(row.protein) : undefined,
carbs: row.carbs ? Number(row.carbs) : undefined,
fats: row.fats ? Number(row.fats) : undefined,
timestamp:
typeof timestampValue === "number"
? new Date(timestampValue * 1000)
: new Date(timestampValue),
createdAt:
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000)
: new Date(createdAtValue),
};
}
// ==================== DAILY HYDRATION OPERATIONS ====================
async createDailyHydration(
hydration: Omit<DailyHydration, "id" | "createdAt" | "updatedAt">,
): Promise<DailyHydration> {
const id = `hydration_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const newHydration = {
id,
...hydration,
createdAt: now,
updatedAt: now,
};
await this.db.insert(dailyHydration).values(newHydration as any);
return this.mapDailyHydration(newHydration);
}
async getDailyHydration(
userId: string,
date: string,
): Promise<DailyHydration | null> {
const results = await this.db
.select()
.from(dailyHydration)
.where(
and(eq(dailyHydration.userId, userId), eq(dailyHydration.date, date)),
)
.limit(1)
.all();
return results.length > 0 ? this.mapDailyHydration(results[0]) : null;
}
async getDailyHydrationById(id: string): Promise<DailyHydration | null> {
const results = await this.db
.select()
.from(dailyHydration)
.where(eq(dailyHydration.id, id))
.limit(1)
.all();
return results.length > 0 ? this.mapDailyHydration(results[0]) : null;
}
async getDailyHydrationRange(
userId: string,
startDate: string,
endDate: string,
): Promise<DailyHydration[]> {
const results = await this.db
.select()
.from(dailyHydration)
.where(
and(
eq(dailyHydration.userId, userId),
gte(dailyHydration.date, startDate),
lte(dailyHydration.date, endDate),
),
)
.orderBy(dailyHydration.date)
.all();
return results.map((row) => this.mapDailyHydration(row));
}
async updateDailyHydration(
id: string,
updates: Partial<DailyHydration>,
): Promise<DailyHydration | null> {
const { id: _, ...updateData } = updates as any;
if (Object.keys(updateData).length === 0) {
const existing = await this.db
.select()
.from(dailyHydration)
.where(eq(dailyHydration.id, id))
.limit(1)
.all();
return existing.length > 0 ? this.mapDailyHydration(existing[0]) : null;
}
await this.db
.update(dailyHydration)
.set({ ...updateData, updatedAt: new Date() })
.where(eq(dailyHydration.id, id))
.run();
const results = await this.db
.select()
.from(dailyHydration)
.where(eq(dailyHydration.id, id))
.limit(1)
.all();
return results.length > 0 ? this.mapDailyHydration(results[0]) : null;
}
async deleteDailyHydration(id: string): Promise<boolean> {
const result = await this.db
.delete(dailyHydration)
.where(eq(dailyHydration.id, id))
.run();
return result.changes > 0;
}
private mapDailyHydration(row: Record<string, unknown>): DailyHydration {
const createdAtValue = row.createdAt as number | Date;
const updatedAtValue = row.updatedAt as number | Date;
return {
id: String(row.id),
userId: String(row.userId),
date: String(row.date),
totalWater: Number(row.totalWater || 0),
waterGoal: Number(row.waterGoal || 2000),
entries: row.entries
? (JSON.parse(String(row.entries)) as any)
: undefined,
createdAt:
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000)
: new Date(createdAtValue),
updatedAt:
typeof updatedAtValue === "number"
? new Date(updatedAtValue * 1000)
: new Date(updatedAtValue),
};
}
// ==================== FITNESS PROFILE HISTORY OPERATIONS ====================
async createFitnessProfileHistory(
history: Omit<FitnessProfileHistory, "id" | "createdAt">,
): Promise<FitnessProfileHistory> {
const id = `profile_history_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const newHistory = {
id,
...history,
createdAt: now,
};
await this.db.insert(fitnessProfileHistory).values(newHistory as any);
return this.mapFitnessProfileHistory(newHistory);
}
async getFitnessProfileHistory(
userId: string,
startDate?: Date,
endDate?: Date,
): Promise<FitnessProfileHistory[]> {
const conditions = [eq(fitnessProfileHistory.userId, userId)];
if (startDate) {
const startTimestamp = Math.floor(startDate.getTime() / 1000);
conditions.push(
sql`${fitnessProfileHistory.changedAt} >= ${startTimestamp}`,
);
}
if (endDate) {
const endTimestamp = Math.floor(endDate.getTime() / 1000);
conditions.push(
sql`${fitnessProfileHistory.changedAt} <= ${endTimestamp}`,
);
}
const results = await this.db
.select()
.from(fitnessProfileHistory)
.where(and(...conditions))
.orderBy(desc(fitnessProfileHistory.changedAt))
.all();
return results.map((row) => this.mapFitnessProfileHistory(row));
}
async getWeightHistory(
userId: string,
startDate: Date,
endDate: Date,
): Promise<FitnessProfileHistory[]> {
const startTimestamp = Math.floor(startDate.getTime() / 1000);
const endTimestamp = Math.floor(endDate.getTime() / 1000);
const results = await this.db
.select()
.from(fitnessProfileHistory)
.where(
and(
eq(fitnessProfileHistory.userId, userId),
eq(fitnessProfileHistory.changeType, "weight"),
sql`${fitnessProfileHistory.changedAt} >= ${startTimestamp}`,
sql`${fitnessProfileHistory.changedAt} <= ${endTimestamp}`,
),
)
.orderBy(fitnessProfileHistory.changedAt)
.all();
return results.map((row) => this.mapFitnessProfileHistory(row));
}
private mapFitnessProfileHistory(
row: Record<string, unknown>,
): FitnessProfileHistory {
const changedAtValue = row.changedAt as number | Date;
const createdAtValue = row.createdAt as number | Date;
return {
id: String(row.id),
userId: String(row.userId),
fitnessProfileId: String(row.fitnessProfileId),
changeType: String(row.changeType) as FitnessProfileHistory["changeType"],
fieldName: String(row.fieldName),
previousValue: row.previousValue ? String(row.previousValue) : undefined,
newValue: row.newValue ? String(row.newValue) : undefined,
changedAt:
typeof changedAtValue === "number"
? new Date(changedAtValue * 1000)
: new Date(changedAtValue),
createdAt:
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000)
: new Date(createdAtValue),
};
}
// ==================== TRAINER-CLIENT ASSIGNMENT OPERATIONS ====================
async createTrainerClientAssignment(
assignment: Omit<TrainerClientAssignment, "id" | "createdAt" | "updatedAt">,
): Promise<TrainerClientAssignment> {
const id = `assignment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const newAssignment = {
id,
...assignment,
createdAt: now,
updatedAt: now,
};
await this.db.insert(trainerClientAssignments).values(newAssignment as any);
return this.mapTrainerClientAssignment(newAssignment);
}
async getTrainerClientAssignments(
trainerId: string,
): Promise<TrainerClientAssignment[]> {
const results = await this.db
.select()
.from(trainerClientAssignments)
.where(
and(
eq(trainerClientAssignments.trainerId, trainerId),
eq(trainerClientAssignments.isActive, true),
),
)
.orderBy(desc(trainerClientAssignments.assignedAt))
.all();
return results.map((row) => this.mapTrainerClientAssignment(row));
}
async getAllTrainerClientAssignments(): Promise<TrainerClientAssignment[]> {
const results = await this.db
.select()
.from(trainerClientAssignments)
.orderBy(desc(trainerClientAssignments.assignedAt))
.all();
return results.map((row) => this.mapTrainerClientAssignment(row));
}
async getClientTrainerAssignment(
clientId: string,
): Promise<TrainerClientAssignment | null> {
const results = await this.db
.select()
.from(trainerClientAssignments)
.where(
and(
eq(trainerClientAssignments.clientId, clientId),
eq(trainerClientAssignments.isActive, true),
),
)
.limit(1)
.all();
return results.length > 0
? this.mapTrainerClientAssignment(results[0])
: null;
}
async deleteTrainerClientAssignment(id: string): Promise<boolean> {
const result = await this.db
.delete(trainerClientAssignments)
.where(eq(trainerClientAssignments.id, id))
.run();
return result.changes > 0;
}
async deactivateTrainerClientAssignment(
id: string,
): Promise<TrainerClientAssignment | null> {
await this.db
.update(trainerClientAssignments)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(trainerClientAssignments.id, id))
.run();
const results = await this.db
.select()
.from(trainerClientAssignments)
.where(eq(trainerClientAssignments.id, id))
.limit(1)
.all();
return results.length > 0
? this.mapTrainerClientAssignment(results[0])
: null;
}
private mapTrainerClientAssignment(
row: Record<string, unknown>,
): TrainerClientAssignment {
const assignedAtValue = row.assignedAt as number | Date;
const createdAtValue = row.createdAt as number | Date;
const updatedAtValue = row.updatedAt as number | Date;
return {
id: String(row.id),
trainerId: String(row.trainerId),
clientId: String(row.clientId),
assignedAt:
typeof assignedAtValue === "number"
? new Date(assignedAtValue * 1000)
: new Date(assignedAtValue),
assignedBy: row.assignedBy ? String(row.assignedBy) : undefined,
isActive: Boolean(row.isActive),
createdAt:
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000)
: new Date(createdAtValue),
updatedAt:
typeof updatedAtValue === "number"
? new Date(updatedAtValue * 1000)
: new Date(updatedAtValue),
};
}
// ==================== ENHANCED ATTENDANCE OPERATIONS FOR REPORTS ====================
async getAttendanceByWeek(
userId: string,
weekStart: Date,
): Promise<Attendance[]> {
// Calculate week end (Sunday at 23:59:59)
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
const startTimestamp = Math.floor(weekStart.getTime() / 1000);
const endTimestamp = Math.floor(weekEnd.getTime() / 1000);
const results = await this.db
.select()
.from(attendance)
.where(
and(
eq(attendance.userId, userId),
sql`${attendance.checkInTime} >= ${startTimestamp}`,
sql`${attendance.checkInTime} <= ${endTimestamp}`,
),
)
.orderBy(attendance.checkInTime)
.all();
return results.map((row) => this.mapAttendance(row));
}
async calculateWeeklyCheckInStats(
userId: string,
weekStart: Date,
): Promise<{
totalCheckIns: number;
totalTimeSpent: number;
avgSessionDuration: number;
byType: { gym: number; class: number; personal_training: number };
}> {
const attendanceRecords = await this.getAttendanceByWeek(userId, weekStart);
const stats = {
totalCheckIns: attendanceRecords.length,
totalTimeSpent: 0,
avgSessionDuration: 0,
byType: {
gym: 0,
class: 0,
personal_training: 0,
},
};
let completedSessions = 0;
for (const record of attendanceRecords) {
// Count by type
if (record.type === "gym") stats.byType.gym++;
else if (record.type === "class") stats.byType.class++;
else if (record.type === "personal_training")
stats.byType.personal_training++;
// Calculate time spent (only for completed sessions with check-out)
if (record.checkOutTime) {
const duration =
(record.checkOutTime.getTime() - record.checkInTime.getTime()) /
(1000 * 60); // in minutes
stats.totalTimeSpent += duration;
completedSessions++;
}
}
// Calculate average session duration
if (completedSessions > 0) {
stats.avgSessionDuration = stats.totalTimeSpent / completedSessions;
}
return stats;
}
}
+106
View File
@@ -6,6 +6,11 @@ import {
Recommendation,
FitnessGoal,
Notification,
DailyNutrition,
DailyHydration,
MealEntry,
FitnessProfileHistory,
TrainerClientAssignment,
} from "@fitai/shared";
import type { SortConfig, FilterCondition } from "../filtering";
@@ -23,6 +28,11 @@ export type {
Recommendation,
FitnessGoal,
Notification,
DailyNutrition,
DailyHydration,
MealEntry,
FitnessProfileHistory,
TrainerClientAssignment,
};
// Database Interface - allows us to swap implementations
@@ -144,6 +154,7 @@ export interface IDatabase {
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
getRecommendationsByUserIds(userIds: string[]): Promise<Recommendation[]>;
getAllRecommendations(): Promise<Recommendation[]>;
getRecommendationById(id: string): Promise<Recommendation | null>;
updateRecommendation(
id: string,
updates: Partial<Recommendation>,
@@ -188,6 +199,101 @@ export interface IDatabase {
totalRevenue: number;
revenueGrowth: number; // Percentage vs last month
}>;
// Daily Nutrition operations
createDailyNutrition(
nutrition: Omit<DailyNutrition, "id" | "createdAt" | "updatedAt">,
): Promise<DailyNutrition>;
getDailyNutrition(
userId: string,
date: string,
): Promise<DailyNutrition | null>;
getDailyNutritionById(id: string): Promise<DailyNutrition | null>;
getDailyNutritionRange(
userId: string,
startDate: string,
endDate: string,
): Promise<DailyNutrition[]>;
updateDailyNutrition(
id: string,
updates: Partial<DailyNutrition>,
): Promise<DailyNutrition | null>;
deleteDailyNutrition(id: string): Promise<boolean>;
// Meal Entry operations
createMealEntry(
meal: Omit<MealEntry, "id" | "createdAt">,
): Promise<MealEntry>;
getMealEntriesByDate(userId: string, date: string): Promise<MealEntry[]>;
getMealEntryById(id: string): Promise<MealEntry | null>;
deleteMealEntry(id: string): Promise<boolean>;
// Daily Hydration operations
createDailyHydration(
hydration: Omit<DailyHydration, "id" | "createdAt" | "updatedAt">,
): Promise<DailyHydration>;
getDailyHydration(
userId: string,
date: string,
): Promise<DailyHydration | null>;
getDailyHydrationById(id: string): Promise<DailyHydration | null>;
getDailyHydrationRange(
userId: string,
startDate: string,
endDate: string,
): Promise<DailyHydration[]>;
updateDailyHydration(
id: string,
updates: Partial<DailyHydration>,
): Promise<DailyHydration | null>;
deleteDailyHydration(id: string): Promise<boolean>;
// Fitness Profile History operations
createFitnessProfileHistory(
history: Omit<FitnessProfileHistory, "id" | "createdAt">,
): Promise<FitnessProfileHistory>;
getFitnessProfileHistory(
userId: string,
startDate?: Date,
endDate?: Date,
): Promise<FitnessProfileHistory[]>;
getWeightHistory(
userId: string,
startDate: Date,
endDate: Date,
): Promise<FitnessProfileHistory[]>;
// Trainer-Client Assignment operations
createTrainerClientAssignment(
assignment: Omit<TrainerClientAssignment, "id" | "createdAt" | "updatedAt">,
): Promise<TrainerClientAssignment>;
getTrainerClientAssignments(
trainerId: string,
): Promise<TrainerClientAssignment[]>;
getAllTrainerClientAssignments(): Promise<TrainerClientAssignment[]>;
getClientTrainerAssignment(
clientId: string,
): Promise<TrainerClientAssignment | null>;
deleteTrainerClientAssignment(id: string): Promise<boolean>;
deactivateTrainerClientAssignment(
id: string,
): Promise<TrainerClientAssignment | null>;
// Enhanced Attendance operations for reports
getAttendanceByWeek(userId: string, weekStart: Date): Promise<Attendance[]>;
calculateWeeklyCheckInStats(
userId: string,
weekStart: Date,
): Promise<{
totalCheckIns: number;
totalTimeSpent: number; // in minutes
avgSessionDuration: number; // in minutes
byType: {
gym: number;
class: number;
personal_training: number;
};
}>;
}
// Database configuration
+26
View File
@@ -0,0 +1,26 @@
import { getDatabase } from "@/lib/database";
import { getMembershipFeatures } from "./features";
export async function getUserMembershipContext(userId: string): Promise<{
membershipType: "basic" | "premium" | "vip";
features: ReturnType<typeof getMembershipFeatures>;
}> {
const db = await getDatabase();
const user = await db.getUserById(userId);
if (!user || user.role !== "client") {
const membershipType = "vip" as const;
return {
membershipType,
features: getMembershipFeatures(membershipType),
};
}
const client = await db.getClientByUserId(userId);
const membershipType = client?.membershipType ?? "basic";
return {
membershipType,
features: getMembershipFeatures(membershipType),
};
}
+35
View File
@@ -0,0 +1,35 @@
import type { MembershipType } from "@/lib/validation/schemas";
export interface MembershipFeatures {
recommendationsPerMonth: number;
hydrationTracking: boolean;
nutritionTracking: boolean;
advancedStatistics: boolean;
}
export const MEMBERSHIP_FEATURES: Record<MembershipType, MembershipFeatures> = {
basic: {
recommendationsPerMonth: 1,
hydrationTracking: false,
nutritionTracking: false,
advancedStatistics: false,
},
premium: {
recommendationsPerMonth: -1,
hydrationTracking: true,
nutritionTracking: true,
advancedStatistics: true,
},
vip: {
recommendationsPerMonth: -1,
hydrationTracking: true,
nutritionTracking: true,
advancedStatistics: true,
},
};
export function getMembershipFeatures(
membershipType: MembershipType,
): MembershipFeatures {
return MEMBERSHIP_FEATURES[membershipType];
}
@@ -0,0 +1,232 @@
/**
* Migration Script: Create new tables for report generation
*
* This script:
* 1. Creates the following tables:
* - daily_nutrition - Daily calorie tracking
* - meal_entries - Individual meal details
* - daily_hydration - Daily water intake tracking
* - fitness_profile_history - Profile change history
* - trainer_client_assignments - Trainer-client relationships
*
* 2. Fixes gym assignments for users without gymId:
* - Assigns superAdmin to their first gym
* - Assigns other users to gym of their trainer
*
* Run with: node apps/admin/src/lib/migrations/create-report-tables.js
*
* Note: Run this AFTER setting up the base database
*/
const Database = require("better-sqlite3");
// Use absolute path to the database
const dbPath = "/home/echo/dev/prototype/apps/admin/data/fitai.db";
function createReportTables() {
console.log("Starting report tables migration...\n");
console.log(`Database path: ${dbPath}\n`);
const db = new Database(dbPath);
// 1. Create daily_nutrition table
console.log("Creating daily_nutrition table...");
db.exec(`
CREATE TABLE IF NOT EXISTS daily_nutrition (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
date TEXT NOT NULL,
total_calories INTEGER DEFAULT 0,
calorie_goal INTEGER DEFAULT 2000,
meals TEXT DEFAULT '[]',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(user_id, date)
)
`);
console.log(" ✓ daily_nutrition table created");
// 2. Create meal_entries table
console.log("Creating meal_entries table...");
db.exec(`
CREATE TABLE IF NOT EXISTS meal_entries (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
daily_nutrition_id TEXT,
meal_type TEXT NOT NULL,
food_name TEXT NOT NULL,
calories INTEGER NOT NULL,
protein INTEGER,
carbs INTEGER,
fats INTEGER,
timestamp INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
`);
console.log(" ✓ meal_entries table created");
// 3. Create daily_hydration table
console.log("Creating daily_hydration table...");
db.exec(`
CREATE TABLE IF NOT EXISTS daily_hydration (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
date TEXT NOT NULL,
total_water INTEGER DEFAULT 0,
water_goal INTEGER DEFAULT 2000,
entries TEXT DEFAULT '[]',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(user_id, date)
)
`);
console.log(" ✓ daily_hydration table created");
// 4. Create fitness_profile_history table
console.log("Creating fitness_profile_history table...");
db.exec(`
CREATE TABLE IF NOT EXISTS fitness_profile_history (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
fitness_profile_id TEXT NOT NULL,
change_type TEXT NOT NULL,
field_name TEXT NOT NULL,
previous_value TEXT,
new_value TEXT,
changed_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
`);
console.log(" ✓ fitness_profile_history table created");
// 5. Create trainer_client_assignments table
console.log("Creating trainer_client_assignments table...");
db.exec(`
CREATE TABLE IF NOT EXISTS trainer_client_assignments (
id TEXT PRIMARY KEY,
trainer_id TEXT NOT NULL,
client_id TEXT NOT NULL,
assigned_at INTEGER NOT NULL,
assigned_by TEXT,
is_active INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
console.log(" ✓ trainer_client_assignments table created");
// Create indexes for better query performance
console.log("\nCreating indexes...");
db.exec(`
CREATE INDEX IF NOT EXISTS idx_daily_nutrition_user_date
ON daily_nutrition(user_id, date)
`);
console.log(" ✓ Index: daily_nutrition.user_id + date");
db.exec(`
CREATE INDEX IF NOT EXISTS idx_meal_entries_user_timestamp
ON meal_entries(user_id, timestamp)
`);
console.log(" ✓ Index: meal_entries.user_id + timestamp");
db.exec(`
CREATE INDEX IF NOT EXISTS idx_daily_hydration_user_date
ON daily_hydration(user_id, date)
`);
console.log(" ✓ Index: daily_hydration.user_id + date");
db.exec(`
CREATE INDEX IF NOT EXISTS idx_fitness_profile_history_user
ON fitness_profile_history(user_id)
`);
console.log(" ✓ Index: fitness_profile_history.user_id");
db.exec(`
CREATE INDEX IF NOT EXISTS idx_trainer_client_assignments_trainer
ON trainer_client_assignments(trainer_id)
`);
console.log(" ✓ Index: trainer_client_assignments.trainer_id");
db.exec(`
CREATE INDEX IF NOT EXISTS idx_trainer_client_assignments_client
ON trainer_client_assignments(client_id)
`);
console.log(" ✓ Index: trainer_client_assignments.client_id");
db.exec(`
CREATE INDEX IF NOT EXISTS idx_trainer_client_assignments_active
ON trainer_client_assignments(trainer_id, client_id, is_active)
`);
console.log(" ✓ Index: trainer_client_assignments (composite)");
db.close();
console.log("\n=== Migration Complete ===");
console.log("All report generation tables created successfully!");
console.log("\nTables created:");
console.log(" - daily_nutrition");
console.log(" - meal_entries");
console.log(" - daily_hydration");
console.log(" - fitness_profile_history");
console.log(" - trainer_client_assignments");
console.log("\nIndexes created: 7");
// Fix gym assignments for users without gymId
console.log("\n=== Fixing Gym Assignments ===");
const usersWithoutGym = db
.prepare("SELECT id, email, role FROM users WHERE gym_id IS NULL")
.all();
console.log(`Found ${usersWithoutGym.length} users without gymId`);
let fixedCount = 0;
for (const user of usersWithoutGym) {
if (user.role === "superAdmin") {
// Get first gym
const gym = db.prepare("SELECT id FROM gyms LIMIT 1").get();
if (gym) {
db.prepare("UPDATE users SET gym_id = ? WHERE id = ?").run(
gym.id,
user.id,
);
console.log(` ✓ Fixed ${user.email} (superAdmin) -> gym ${gym.id}`);
fixedCount++;
}
} else {
// Try to find gym from trainer_clients table
const trainerClient = db
.prepare(
"SELECT gym_id FROM trainer_clients WHERE trainer_user_id = ? OR client_user_id = ? LIMIT 1",
)
.get(user.id, user.id);
if (trainerClient) {
db.prepare("UPDATE users SET gym_id = ? WHERE id = ?").run(
trainerClient.gym_id,
user.id,
);
console.log(
` ✓ Fixed ${user.email} (${user.role}) -> gym ${trainerClient.gym_id}`,
);
fixedCount++;
} else {
console.log(
` ⚠ Could not fix ${user.email} (${user.role}) - no trainer_clients record`,
);
}
}
}
console.log(`\nFixed ${fixedCount} users without gymId`);
console.log("\nGym assignments update complete!");
}
// Run if called directly
if (require.main === module) {
createReportTables();
}
module.exports = { createReportTables };
@@ -0,0 +1,236 @@
import type { UserReport, User, Client, FitnessProfile } from "@fitai/shared";
// Mock data for testing
const mockUser: User = {
id: "user_123",
email: "john.doe@example.com",
firstName: "John",
lastName: "Doe",
role: "client",
phone: "555-1234",
createdAt: new Date("2024-01-15"),
updatedAt: new Date("2024-01-15"),
};
const mockClient: Client = {
id: "client_123",
userId: "user_123",
membershipType: "premium",
membershipStatus: "active",
joinDate: new Date("2024-01-15"),
};
const mockProfile: FitnessProfile = {
id: "profile_123",
userId: "user_123",
height: 175,
weight: 70,
age: 30,
gender: "male",
activityLevel: "moderately_active",
createdAt: new Date("2024-01-15"),
updatedAt: new Date("2024-01-15"),
};
const mockReport: UserReport = {
userId: "user_123",
user: mockUser,
client: mockClient,
fitnessProfile: mockProfile,
reportPeriod: {
startDate: "2024-01-01",
endDate: "2024-01-31",
},
weeklyCheckIns: [
{
weekStart: "2024-01-01",
weekEnd: "2024-01-07",
totalCheckIns: 4,
totalTimeMinutes: 240,
averageDurationMinutes: 60,
checkInsByType: [
{ type: "gym", count: 3 },
{ type: "class", count: 1 },
],
},
{
weekStart: "2024-01-08",
weekEnd: "2024-01-14",
totalCheckIns: 5,
totalTimeMinutes: 300,
averageDurationMinutes: 60,
checkInsByType: [
{ type: "gym", count: 4 },
{ type: "class", count: 1 },
],
},
],
nutrition: {
dailySummaries: [
{
date: "2024-01-01",
totalCalories: 2000,
calorieGoal: 2200,
caloriesDelta: -200,
mealsCount: 3,
},
{
date: "2024-01-02",
totalCalories: 2300,
calorieGoal: 2200,
caloriesDelta: 100,
mealsCount: 4,
},
],
averageDailyCalories: 2150,
totalDays: 2,
daysMetGoal: 1,
},
hydration: {
dailySummaries: [
{
date: "2024-01-01",
totalWater: 2000,
waterGoal: 2500,
hydrationPercentage: 80,
},
{
date: "2024-01-02",
totalWater: 2600,
waterGoal: 2500,
hydrationPercentage: 104,
},
],
averageDailyWater: 2300,
totalDays: 2,
daysMetGoal: 1,
},
goals: {
active: [
{
id: "goal_1",
userId: "user_123",
goalType: "weight_target",
title: "Lose 5kg",
description: "Lose weight for summer",
targetValue: 65,
currentValue: 70,
unit: "kg",
startDate: new Date("2024-01-01"),
status: "active",
progress: 0,
priority: "high",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
},
],
completed: [
{
id: "goal_2",
userId: "user_123",
goalType: "strength_milestone",
title: "Bench press 100kg",
targetValue: 100,
currentValue: 100,
unit: "kg",
startDate: new Date("2023-11-01"),
completedDate: new Date("2023-12-15"),
status: "completed",
progress: 100,
priority: "medium",
createdAt: new Date("2023-11-01"),
updatedAt: new Date("2023-12-15"),
},
],
totalActive: 1,
totalCompleted: 1,
averageProgress: 0,
},
profileHistory: [
{
changeType: "weight",
fieldName: "weight",
previousValue: "72",
newValue: "70",
changedAt: new Date("2024-01-15"),
},
],
recommendations: {
accepted: [
{
id: "rec_1",
userId: "user_123",
fitnessProfileId: "profile_123",
recommendationText: "Increase protein intake to 150g per day",
activityPlan: "High protein diet",
dietPlan: "Protein focused",
status: "approved",
generatedAt: new Date("2024-01-10"),
approvedAt: new Date("2024-01-11"),
approvedBy: "user_trainer",
createdAt: new Date("2024-01-10"),
updatedAt: new Date("2024-01-11"),
},
],
rejected: [],
pending: [
{
id: "rec_2",
userId: "user_123",
fitnessProfileId: "profile_123",
recommendationText: "Try HIIT workouts 3 times per week",
activityPlan: "HIIT training",
dietPlan: "No change",
status: "pending",
generatedAt: new Date("2024-01-20"),
createdAt: new Date("2024-01-20"),
updatedAt: new Date("2024-01-20"),
},
],
totalAccepted: 1,
totalRejected: 0,
totalPending: 1,
},
generatedAt: new Date(),
};
// Test function
export async function testPDFGeneration() {
try {
const { generateReportPDFBase64 } = await import(
"@/lib/pdf/report-helpers"
);
console.log("Generating PDF from mock report...");
const pdfBase64 = generateReportPDFBase64(mockReport);
console.log(`PDF generated successfully!`);
console.log(`PDF size: ${Math.round(pdfBase64.length / 1024)} KB`);
// Verify it's a valid base64 string
if (pdfBase64.startsWith("JVBERi0")) {
console.log("✓ PDF is valid (starts with PDF header)");
} else {
console.log("✗ PDF is invalid");
}
return true;
} catch (error) {
console.error("✗ PDF generation failed:", error);
return false;
}
}
// Run if executed directly
if (typeof window === "undefined" && process.argv[1]?.includes("test-pdf")) {
testPDFGeneration()
.then((success) => {
process.exit(success ? 0 : 1);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}
export default testPDFGeneration;
+316
View File
@@ -0,0 +1,316 @@
import jsPDF from "jspdf";
export interface ChartData {
labels: string[];
datasets: {
label: string;
data: number[];
color?: string;
}[];
}
export interface ChartConfig {
title?: string;
width: number;
height: number;
}
/**
* Generate a bar chart image as a data URL
*/
export function generateBarChart(data: ChartData, config: ChartConfig): string {
const canvas = createCanvas(config.width, config.height);
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not get canvas context");
}
const padding = 40;
const chartWidth = config.width - padding * 2;
const chartHeight = config.height - padding * 2;
const barWidth = chartWidth / data.labels.length - 10;
// Clear canvas
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, config.width, config.height);
// Add title if provided
if (config.title) {
ctx.fillStyle = "#000000";
ctx.font = "bold 14px Arial";
ctx.textAlign = "center";
ctx.fillText(config.title, config.width / 2, 20);
}
// Calculate max value
const maxValue = Math.max(
...data.datasets.flatMap((dataset) => dataset.data),
);
// Draw bars
data.datasets.forEach((dataset, datasetIndex) => {
const color = dataset.color || getDefaultColor(datasetIndex);
ctx.fillStyle = color;
dataset.data.forEach((value, index) => {
const x =
padding +
index * (barWidth + 10) +
(datasetIndex * barWidth) / data.datasets.length;
const barHeight = (value / maxValue) * chartHeight;
const y = config.height - padding - barHeight;
ctx.fillRect(x, y, barWidth / data.datasets.length - 2, barHeight);
// Draw value on top
ctx.fillStyle = "#000000";
ctx.font = "10px Arial";
ctx.textAlign = "center";
ctx.fillText(
value.toString(),
x + barWidth / data.datasets.length / 2,
y - 5,
);
ctx.fillStyle = color;
});
});
// Draw labels
ctx.fillStyle = "#000000";
ctx.font = "11px Arial";
ctx.textAlign = "center";
data.labels.forEach((label, index) => {
const x = padding + index * (barWidth + 10) + barWidth / 2;
ctx.fillText(label, x, config.height - padding + 15);
});
// Draw axes
ctx.strokeStyle = "#cccccc";
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, config.height - padding);
ctx.lineTo(config.width - padding, config.height - padding);
ctx.stroke();
return canvas.toDataURL("image/png");
}
/**
* Generate a line chart image as a data URL
*/
export function generateLineChart(
data: ChartData,
config: ChartConfig,
): string {
const canvas = createCanvas(config.width, config.height);
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not get canvas context");
}
const padding = 40;
const chartWidth = config.width - padding * 2;
const chartHeight = config.height - padding * 2;
// Clear canvas
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, config.width, config.height);
// Add title if provided
if (config.title) {
ctx.fillStyle = "#000000";
ctx.font = "bold 14px Arial";
ctx.textAlign = "center";
ctx.fillText(config.title, config.width / 2, 20);
}
// Calculate max value
const maxValue = Math.max(
...data.datasets.flatMap((dataset) => dataset.data),
1,
);
// Draw grid lines
ctx.strokeStyle = "#f0f0f0";
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = padding + (i * chartHeight) / 5;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(config.width - padding, y);
ctx.stroke();
// Draw value labels
ctx.fillStyle = "#666666";
ctx.font = "10px Arial";
ctx.textAlign = "right";
const value = Math.round(maxValue - (i * maxValue) / 5);
ctx.fillText(value.toString(), padding - 5, y + 3);
}
// Draw lines
data.datasets.forEach((dataset, datasetIndex) => {
const color = dataset.color || getDefaultColor(datasetIndex);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
dataset.data.forEach((value, index) => {
const x = padding + (index * chartWidth) / (data.labels.length - 1);
const y = config.height - padding - (value / maxValue) * chartHeight;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Draw points
ctx.fillStyle = color;
dataset.data.forEach((value, index) => {
const x = padding + (index * chartWidth) / (data.labels.length - 1);
const y = config.height - padding - (value / maxValue) * chartHeight;
ctx.beginPath();
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
});
});
// Draw labels
ctx.fillStyle = "#000000";
ctx.font = "11px Arial";
ctx.textAlign = "center";
data.labels.forEach((label, index) => {
const x = padding + (index * chartWidth) / (data.labels.length - 1);
ctx.fillText(label, x, config.height - padding + 15);
});
// Draw axes
ctx.strokeStyle = "#cccccc";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, config.height - padding);
ctx.lineTo(config.width - padding, config.height - padding);
ctx.stroke();
return canvas.toDataURL("image/png");
}
/**
* Generate a pie chart image as a data URL
*/
export function generatePieChart(data: ChartData, config: ChartConfig): string {
const canvas = createCanvas(config.width, config.height);
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not get canvas context");
}
const centerX = config.width / 2;
const centerY = config.height / 2;
const radius = Math.min(config.width, config.height) / 2 - 40;
// Clear canvas
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, config.width, config.height);
// Add title if provided
if (config.title) {
ctx.fillStyle = "#000000";
ctx.font = "bold 14px Arial";
ctx.textAlign = "center";
ctx.fillText(config.title, config.width / 2, 20);
}
// Calculate total
const total = data.datasets[0]?.data.reduce((sum, val) => sum + val, 0) || 0;
// Draw pie slices
let startAngle = 0;
data.datasets[0]?.data.forEach((value, index) => {
const sliceAngle = (value / total) * 2 * Math.PI;
const color = data.datasets[0].color || getDefaultColor(index);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fill();
// Draw label
const midAngle = startAngle + sliceAngle / 2;
const labelX = centerX + (radius + 20) * Math.cos(midAngle);
const labelY = centerY + (radius + 20) * Math.sin(midAngle);
ctx.fillStyle = "#000000";
ctx.font = "11px Arial";
ctx.textAlign = "center";
ctx.fillText(`${data.labels[index]}: ${value}`, labelX, labelY);
startAngle += sliceAngle;
});
return canvas.toDataURL("image/png");
}
/**
* Helper function to create a canvas element
*/
function createCanvas(width: number, height: number): HTMLCanvasElement {
if (typeof document !== "undefined") {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas;
}
// Fallback for server-side rendering
const Canvas = require("canvas");
return new Canvas(width, height);
}
/**
* Get default color for a dataset index
*/
function getDefaultColor(index: number): string {
const colors = [
"#3c82e1", // primary
"#2ea643", // success
"#f0a500", // warning
"#d73232", // destructive
"#8b5cf6", // purple
"#06b6d4", // cyan
];
return colors[index % colors.length];
}
/**
* Add chart image to PDF
*/
export function addChartToPDF(
pdf: jsPDF,
imageData: string,
x: number,
y: number,
width?: number,
height?: number,
): void {
const imgWidth = width || 150;
const imgHeight = height || 80;
pdf.addImage(imageData, "PNG", x, y, imgWidth, imgHeight);
}
export default {
generateBarChart,
generateLineChart,
generatePieChart,
addChartToPDF,
};
+15
View File
@@ -0,0 +1,15 @@
export { default as PDFGenerator } from "./pdf-generator";
export {
default as ChartGenerator,
generateBarChart,
generateLineChart,
generatePieChart,
addChartToPDF,
type ChartData,
type ChartConfig,
} from "./chart-generator";
export {
generateReportPDF,
generateReportPDFBase64,
saveReportPDF,
} from "./report-helpers";
+821
View File
@@ -0,0 +1,821 @@
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import type { UserReport } from "@fitai/shared";
export class PDFGenerator {
private doc: jsPDF;
private pageWidth: number;
private pageHeight: number;
private margin: { top: number; right: number; bottom: number; left: number };
private currentY: number = 0;
// Theme colors (from globals.css)
private colors = {
primary: [60, 130, 225] as [number, number, number],
primaryDark: [40, 80, 180] as [number, number, number],
success: [46, 160, 67] as [number, number, number],
warning: [240, 185, 11] as [number, number, number],
destructive: [215, 50, 50] as [number, number, number],
text: [30, 30, 30] as [number, number, number],
textLight: [100, 100, 100] as [number, number, number],
background: [245, 245, 245] as [number, number, number],
white: [255, 255, 255] as [number, number, number],
};
constructor() {
this.doc = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "a4",
});
this.pageWidth = this.doc.internal.pageSize.getWidth();
this.pageHeight = this.doc.internal.pageSize.getHeight();
this.margin = { top: 20, right: 20, bottom: 20, left: 20 };
}
/**
* Generate a complete user report PDF
*/
generateUserReport(report: UserReport): jsPDF {
this.addHeader(report);
this.addUserInfo(report);
this.addReportPeriod(report);
this.addWeeklyCheckIns(report.weeklyCheckIns);
this.addNutritionSummary(report.nutrition);
this.addHydrationSummary(report.hydration);
this.addGoalsSummary(report.goals);
this.addProfileHistory(report.profileHistory);
this.addRecommendations(report.recommendations);
this.addFooter();
return this.doc;
}
/**
* Save PDF to file
*/
save(filename: string): void {
this.doc.save(filename);
}
/**
* Get PDF as blob for API response
*/
toBlob(): Blob {
return this.doc.output("blob");
}
/**
* Get PDF as base64 for API response
*/
toBase64(): string {
return this.doc.output("datauristring").split(",")[1];
}
/**
* Add header with title and FitAI branding
*/
private addHeader(report: UserReport): void {
this.currentY = this.margin.top;
// Header background
this.doc.setFillColor(...this.colors.primary);
this.doc.rect(0, 0, this.pageWidth, 35, "F");
// Title
this.doc.setTextColor(...this.colors.white);
this.doc.setFontSize(24);
this.doc.setFont("helvetica", "bold");
this.doc.text("FitAI User Report", this.margin.left, 20);
// Subtitle
this.doc.setFontSize(10);
this.doc.setFont("helvetica", "normal");
this.doc.text(
`Generated: ${new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}`,
this.margin.left,
28,
);
// FitAI logo text
this.doc.setFontSize(14);
this.doc.setFont("helvetica", "bold");
this.doc.text("FitAI", this.pageWidth - this.margin.right - 20, 20);
this.currentY = 45;
}
/**
* Add user information section
*/
private addUserInfo(report: UserReport): void {
this.checkPageBreak(30);
this.doc.setTextColor(...this.colors.primary);
this.doc.setFontSize(14);
this.doc.setFont("helvetica", "bold");
this.doc.text("User Information", this.margin.left, this.currentY);
this.currentY += 8;
const user = report.user;
const client = report.client;
const profile = report.fitnessProfile;
const userInfo = [
["Name", `${user.firstName} ${user.lastName}`],
["Email", user.email],
["Phone", user.phone || "N/A"],
["Role", user.role],
[
"Member Since",
user.createdAt ? new Date(user.createdAt).toLocaleDateString() : "N/A",
],
];
if (client) {
userInfo.push([
"Membership",
`${client.membershipType} - ${client.membershipStatus}`,
]);
userInfo.push([
"Join Date",
new Date(client.joinDate).toLocaleDateString(),
]);
}
if (profile) {
userInfo.push([
"Fitness Profile",
`Height: ${profile.height || "N/A"}cm, Weight: ${profile.weight || "N/A"}kg`,
]);
}
autoTable(this.doc, {
startY: this.currentY,
body: userInfo,
theme: "plain",
margin: { left: this.margin.left, right: this.margin.right },
columnStyles: {
0: { fontStyle: "bold", cellWidth: 40, fontSize: 10 },
1: { cellWidth: "auto", fontSize: 10 },
},
styles: {
cellPadding: 3,
textColor: this.colors.text,
},
tableWidth: "auto" as any,
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
}
/**
* Add report period section
*/
private addReportPeriod(report: UserReport): void {
this.checkPageBreak(25);
this.doc.setTextColor(...this.colors.primary);
this.doc.setFontSize(14);
this.doc.setFont("helvetica", "bold");
this.doc.text("Report Period", this.margin.left, this.currentY);
this.currentY += 8;
const periodInfo = [
["Start Date", report.reportPeriod.startDate],
["End Date", report.reportPeriod.endDate],
[
"Duration",
`${Math.ceil(
(new Date(report.reportPeriod.endDate).getTime() -
new Date(report.reportPeriod.startDate).getTime()) /
(1000 * 60 * 60 * 24),
)} days`,
],
];
autoTable(this.doc, {
startY: this.currentY,
body: periodInfo,
theme: "plain",
margin: { left: this.margin.left, right: this.margin.right },
columnStyles: {
0: { fontStyle: "bold", cellWidth: 40, fontSize: 10 },
1: { cellWidth: "auto", fontSize: 10 },
},
styles: {
cellPadding: 3,
textColor: this.colors.text,
},
tableWidth: "auto" as any,
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
}
/**
* Add weekly check-ins section
*/
private addWeeklyCheckIns(
weeklyCheckIns: UserReport["weeklyCheckIns"],
): void {
this.checkPageBreak(50);
this.doc.setTextColor(...this.colors.primary);
this.doc.setFontSize(14);
this.doc.setFont("helvetica", "bold");
this.doc.text("Weekly Check-ins", this.margin.left, this.currentY);
this.currentY += 8;
if (weeklyCheckIns.length === 0) {
this.doc.setTextColor(...this.colors.textLight);
this.doc.setFontSize(10);
this.doc.setFont("helvetica", "italic");
this.doc.text(
"No check-in data available for this period.",
this.margin.left,
this.currentY,
);
this.currentY += 15;
return;
}
const tableData = weeklyCheckIns.map((week) => [
week.weekStart,
week.weekEnd,
week.totalCheckIns.toString(),
`${week.totalTimeMinutes} min`,
`${week.averageDurationMinutes} min`,
]);
autoTable(this.doc, {
startY: this.currentY,
head: [
["Week Start", "Week End", "Check-ins", "Total Time", "Avg Duration"],
],
body: tableData,
theme: "striped",
headStyles: {
fillColor: this.colors.primary,
textColor: this.colors.white,
fontStyle: "bold",
fontSize: 9,
},
bodyStyles: {
fontSize: 9,
textColor: this.colors.text,
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: this.margin.left, right: this.margin.right },
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
}
/**
* Add nutrition summary section
*/
private addNutritionSummary(nutrition: UserReport["nutrition"]): void {
this.checkPageBreak(60);
this.doc.setTextColor(...this.colors.primary);
this.doc.setFontSize(14);
this.doc.setFont("helvetica", "bold");
this.doc.text("Nutrition Summary", this.margin.left, this.currentY);
this.currentY += 8;
const summaryStats = [
["Total Days Tracked", nutrition.totalDays.toString()],
[
"Average Daily Calories",
nutrition.averageDailyCalories.toLocaleString(),
],
[
"Days Met Goal (±10%)",
`${nutrition.daysMetGoal} / ${nutrition.totalDays}`,
],
];
autoTable(this.doc, {
startY: this.currentY,
body: summaryStats,
theme: "plain",
margin: { left: this.margin.left, right: this.margin.right },
columnStyles: {
0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 },
1: { cellWidth: "auto", fontSize: 10 },
},
styles: {
cellPadding: 3,
textColor: this.colors.text,
},
tableWidth: "auto" as any,
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
// Add daily breakdown if available
if (
nutrition.dailySummaries.length > 0 &&
nutrition.dailySummaries.length <= 14
) {
this.doc.setTextColor(...this.colors.textLight);
this.doc.setFontSize(10);
this.doc.setFont("helvetica", "italic");
this.doc.text("Daily Breakdown", this.margin.left, this.currentY);
this.currentY += 5;
const dailyData = nutrition.dailySummaries.map((day) => [
day.date,
day.totalCalories.toLocaleString(),
day.calorieGoal.toLocaleString(),
day.caloriesDelta > 0
? `+${day.caloriesDelta}`
: day.caloriesDelta.toString(),
day.mealsCount.toString(),
]);
autoTable(this.doc, {
startY: this.currentY,
head: [["Date", "Calories", "Goal", "Delta", "Meals"]],
body: dailyData,
theme: "striped",
headStyles: {
fillColor: this.colors.primary,
textColor: this.colors.white,
fontStyle: "bold",
fontSize: 8,
},
bodyStyles: {
fontSize: 8,
textColor: this.colors.text,
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: this.margin.left, right: this.margin.right },
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
} else if (nutrition.dailySummaries.length > 14) {
this.doc.setTextColor(...this.colors.textLight);
this.doc.setFontSize(9);
this.doc.text(
`Daily breakdown (${nutrition.dailySummaries.length} days) - See dashboard for detailed view.`,
this.margin.left,
this.currentY,
);
this.currentY += 15;
}
}
/**
* Add hydration summary section
*/
private addHydrationSummary(hydration: UserReport["hydration"]): void {
this.checkPageBreak(50);
this.doc.setTextColor(...this.colors.primary);
this.doc.setFontSize(14);
this.doc.setFont("helvetica", "bold");
this.doc.text("Hydration Summary", this.margin.left, this.currentY);
this.currentY += 8;
const summaryStats = [
["Total Days Tracked", hydration.totalDays.toString()],
["Average Daily Water", `${hydration.averageDailyWater} ml`],
["Days Met Goal", `${hydration.daysMetGoal} / ${hydration.totalDays}`],
];
autoTable(this.doc, {
startY: this.currentY,
body: summaryStats,
theme: "plain",
margin: { left: this.margin.left, right: this.margin.right },
columnStyles: {
0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 },
1: { cellWidth: "auto", fontSize: 10 },
},
styles: {
cellPadding: 3,
textColor: this.colors.text,
},
tableWidth: "auto" as any,
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
// Add daily breakdown if available
if (
hydration.dailySummaries.length > 0 &&
hydration.dailySummaries.length <= 14
) {
this.doc.setTextColor(...this.colors.textLight);
this.doc.setFontSize(10);
this.doc.setFont("helvetica", "italic");
this.doc.text("Daily Breakdown", this.margin.left, this.currentY);
this.currentY += 5;
const dailyData = hydration.dailySummaries.map((day) => [
day.date,
`${day.totalWater} ml`,
`${day.waterGoal} ml`,
`${day.hydrationPercentage}%`,
]);
autoTable(this.doc, {
startY: this.currentY,
head: [["Date", "Total Water", "Goal", "Achievement"]],
body: dailyData,
theme: "striped",
headStyles: {
fillColor: this.colors.primary,
textColor: this.colors.white,
fontStyle: "bold",
fontSize: 8,
},
bodyStyles: {
fontSize: 8,
textColor: this.colors.text,
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: this.margin.left, right: this.margin.right },
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
} else if (hydration.dailySummaries.length > 14) {
this.doc.setTextColor(...this.colors.textLight);
this.doc.setFontSize(9);
this.doc.text(
`Daily breakdown (${hydration.dailySummaries.length} days) - See dashboard for detailed view.`,
this.margin.left,
this.currentY,
);
this.currentY += 15;
}
}
/**
* Add goals summary section
*/
private addGoalsSummary(goals: UserReport["goals"]): void {
this.checkPageBreak(60);
this.doc.setTextColor(...this.colors.primary);
this.doc.setFontSize(14);
this.doc.setFont("helvetica", "bold");
this.doc.text("Fitness Goals", this.margin.left, this.currentY);
this.currentY += 8;
const summaryStats = [
["Active Goals", goals.totalActive.toString()],
["Completed Goals", goals.totalCompleted.toString()],
["Average Progress", `${goals.averageProgress}%`],
];
autoTable(this.doc, {
startY: this.currentY,
body: summaryStats,
theme: "plain",
margin: { left: this.margin.left, right: this.margin.right },
columnStyles: {
0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 },
1: { cellWidth: "auto", fontSize: 10 },
},
styles: {
cellPadding: 3,
textColor: this.colors.text,
},
tableWidth: "auto" as any,
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
// Add active goals
if (goals.active.length > 0) {
this.doc.setTextColor(...this.colors.success);
this.doc.setFontSize(10);
this.doc.setFont("helvetica", "bold");
this.doc.text("Active Goals", this.margin.left, this.currentY);
this.currentY += 5;
const activeGoals = goals.active.map((goal) => [
goal.title,
goal.goalType,
`${goal.progress}%`,
goal.priority,
]);
autoTable(this.doc, {
startY: this.currentY,
head: [["Title", "Type", "Progress", "Priority"]],
body: activeGoals,
theme: "striped",
headStyles: {
fillColor: this.colors.success,
textColor: this.colors.white,
fontStyle: "bold",
fontSize: 8,
},
bodyStyles: {
fontSize: 8,
textColor: this.colors.text,
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: this.margin.left, right: this.margin.right },
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
}
// Add completed goals
if (goals.completed.length > 0) {
this.doc.setTextColor(...this.colors.primary);
this.doc.setFontSize(10);
this.doc.setFont("helvetica", "bold");
this.doc.text("Completed Goals", this.margin.left, this.currentY);
this.currentY += 5;
const completedGoals = goals.completed.map((goal) => [
goal.title,
goal.goalType,
goal.completedDate
? new Date(goal.completedDate).toLocaleDateString()
: "N/A",
]);
autoTable(this.doc, {
startY: this.currentY,
head: [["Title", "Type", "Completed Date"]],
body: completedGoals,
theme: "striped",
headStyles: {
fillColor: this.colors.primary,
textColor: this.colors.white,
fontStyle: "bold",
fontSize: 8,
},
bodyStyles: {
fontSize: 8,
textColor: this.colors.text,
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: this.margin.left, right: this.margin.right },
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
}
}
/**
* Add profile history section
*/
private addProfileHistory(history: UserReport["profileHistory"]): void {
this.checkPageBreak(50);
this.doc.setTextColor(...this.colors.primary);
this.doc.setFontSize(14);
this.doc.setFont("helvetica", "bold");
this.doc.text("Fitness Profile Changes", this.margin.left, this.currentY);
this.currentY += 8;
if (history.length === 0) {
this.doc.setTextColor(...this.colors.textLight);
this.doc.setFontSize(10);
this.doc.setFont("helvetica", "italic");
this.doc.text(
"No profile changes recorded during this period.",
this.margin.left,
this.currentY,
);
this.currentY += 15;
return;
}
const historyData = history.map((item) => [
item.fieldName,
item.changeType,
item.previousValue || "N/A",
item.newValue || "N/A",
new Date(item.changedAt).toLocaleDateString(),
]);
autoTable(this.doc, {
startY: this.currentY,
head: [["Field", "Change Type", "Previous", "New", "Date"]],
body: historyData,
theme: "striped",
headStyles: {
fillColor: this.colors.primary,
textColor: this.colors.white,
fontStyle: "bold",
fontSize: 8,
},
bodyStyles: {
fontSize: 8,
textColor: this.colors.text,
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: this.margin.left, right: this.margin.right },
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
}
/**
* Add recommendations section
*/
private addRecommendations(
recommendations: UserReport["recommendations"],
): void {
this.checkPageBreak(60);
this.doc.setTextColor(...this.colors.primary);
this.doc.setFontSize(14);
this.doc.setFont("helvetica", "bold");
this.doc.text("AI Recommendations", this.margin.left, this.currentY);
this.currentY += 8;
const summaryStats = [
["Accepted", recommendations.totalAccepted.toString()],
["Rejected", recommendations.totalRejected.toString()],
["Pending", recommendations.totalPending.toString()],
];
autoTable(this.doc, {
startY: this.currentY,
body: summaryStats,
theme: "plain",
margin: { left: this.margin.left, right: this.margin.right },
columnStyles: {
0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 },
1: { cellWidth: "auto", fontSize: 10 },
},
styles: {
cellPadding: 3,
textColor: this.colors.text,
},
tableWidth: "auto" as any,
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
// Add accepted recommendations
if (recommendations.accepted.length > 0) {
this.doc.setTextColor(...this.colors.success);
this.doc.setFontSize(10);
this.doc.setFont("helvetica", "bold");
this.doc.text(
"Accepted Recommendations",
this.margin.left,
this.currentY,
);
this.currentY += 5;
const acceptedRecs = recommendations.accepted.map((rec) => [
rec.recommendationText.substring(0, 80) +
(rec.recommendationText.length > 80 ? "..." : ""),
new Date(rec.generatedAt).toLocaleDateString(),
]);
autoTable(this.doc, {
startY: this.currentY,
head: [["Recommendation", "Generated"]],
body: acceptedRecs,
theme: "striped",
headStyles: {
fillColor: this.colors.success,
textColor: this.colors.white,
fontStyle: "bold",
fontSize: 8,
},
bodyStyles: {
fontSize: 8,
textColor: this.colors.text,
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: this.margin.left, right: this.margin.right },
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 10;
}
// Add pending recommendations
if (recommendations.pending.length > 0) {
this.doc.setTextColor(...this.colors.warning);
this.doc.setFontSize(10);
this.doc.setFont("helvetica", "bold");
this.doc.text("Pending Recommendations", this.margin.left, this.currentY);
this.currentY += 5;
const pendingRecs = recommendations.pending.map((rec) => [
rec.recommendationText.substring(0, 80) +
(rec.recommendationText.length > 80 ? "..." : ""),
new Date(rec.generatedAt).toLocaleDateString(),
]);
autoTable(this.doc, {
startY: this.currentY,
head: [["Recommendation", "Generated"]],
body: pendingRecs,
theme: "striped",
headStyles: {
fillColor: this.colors.warning,
textColor: this.colors.text,
fontStyle: "bold",
fontSize: 8,
},
bodyStyles: {
fontSize: 8,
textColor: this.colors.text,
},
alternateRowStyles: {
fillColor: [250, 250, 250],
},
margin: { left: this.margin.left, right: this.margin.right },
});
this.currentY = (this.doc as any).lastAutoTable.finalY + 15;
}
}
/**
* Add footer to each page
*/
private addFooter(): void {
const pageCount = this.doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
this.doc.setPage(i);
// Footer line
this.doc.setDrawColor(...this.colors.primary);
this.doc.setLineWidth(0.5);
this.doc.line(
this.margin.left,
this.pageHeight - 15,
this.pageWidth - this.margin.right,
this.pageHeight - 15,
);
// Footer text
this.doc.setTextColor(...this.colors.textLight);
this.doc.setFontSize(8);
this.doc.setFont("helvetica", "normal");
this.doc.text(
`FitAI User Report - Page ${i} of ${pageCount}`,
this.margin.left,
this.pageHeight - 10,
);
this.doc.text(
"Generated by FitAI",
this.pageWidth - this.margin.right - 30,
this.pageHeight - 10,
);
}
}
/**
* Check if we need a page break
*/
private checkPageBreak(requiredSpace: number): void {
if (this.currentY + requiredSpace > this.pageHeight - this.margin.bottom) {
this.doc.addPage();
this.currentY = this.margin.top;
}
}
}
export default PDFGenerator;
+35
View File
@@ -0,0 +1,35 @@
import type { UserReport } from "@fitai/shared";
import PDFGenerator from "./pdf-generator";
/**
* Generate a PDF report from a UserReport object
*/
export function generateReportPDF(report: UserReport): Blob {
const generator = new PDFGenerator();
generator.generateUserReport(report);
return generator.toBlob();
}
/**
* Generate a PDF report and return as base64 string
*/
export function generateReportPDFBase64(report: UserReport): string {
const generator = new PDFGenerator();
generator.generateUserReport(report);
return generator.toBase64();
}
/**
* Generate a PDF report and save to file
*/
export function saveReportPDF(report: UserReport, filename: string): void {
const generator = new PDFGenerator();
generator.generateUserReport(report);
generator.save(filename);
}
export default {
generateReportPDF,
generateReportPDFBase64,
saveReportPDF,
};
+153
View File
@@ -0,0 +1,153 @@
/**
* ISO Week Calculation Utilities
*
* Helper functions for ISO 8601 week calculations
* Week starts on Monday and ends on Sunday
*/
/**
* Get the Monday of the ISO week for a given date
*/
export function getWeekStart(date: Date): Date {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
d.setDate(diff);
d.setHours(0, 0, 0, 0);
return d;
}
/**
* Get the Sunday of the ISO week for a given date
*/
export function getWeekEnd(date: Date): Date {
const monday = getWeekStart(date);
const sunday = new Date(monday);
sunday.setDate(sunday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
return sunday;
}
/**
* Get ISO week number (1-53)
*/
export function getISOWeek(date: Date): number {
const d = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
);
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
/**
* Format date as YYYY-MM-DD
*/
export function formatDate(date: Date): string {
return date.toISOString().split("T")[0];
}
/**
* Get all weeks in a date range
*/
export function getWeeksInRange(
startDate: string,
endDate: string,
): Array<{
weekStart: string;
weekEnd: string;
weekNumber: number;
}> {
const weeks: Array<{
weekStart: string;
weekEnd: string;
weekNumber: number;
}> = [];
const start = new Date(startDate);
const end = new Date(endDate);
const current = getWeekStart(start);
while (current <= end) {
const weekStart = getWeekStart(current);
const weekEnd = getWeekEnd(current);
weeks.push({
weekStart: formatDate(weekStart),
weekEnd: formatDate(weekEnd),
weekNumber: getISOWeek(current),
});
current.setDate(current.getDate() + 7);
}
return weeks;
}
/**
* Test ISO week calculations
*/
export function testISOWeekCalculations(): void {
console.log("ISO Week Calculation Tests\n");
const testDates = [
{
date: "2024-01-01",
expected: { monday: "2024-01-01", sunday: "2024-01-07", week: 1 },
},
{
date: "2024-01-02",
expected: { monday: "2024-01-01", sunday: "2024-01-07", week: 1 },
},
{
date: "2024-01-07",
expected: { monday: "2024-01-01", sunday: "2024-01-07", week: 1 },
},
{
date: "2024-01-08",
expected: { monday: "2024-01-08", sunday: "2024-01-14", week: 2 },
},
{
date: "2024-12-31",
expected: { monday: "2024-12-30", sunday: "2025-01-05", week: 1 },
}, // Week 1 of 2025
];
testDates.forEach(({ date, expected }) => {
const d = new Date(date);
const monday = formatDate(getWeekStart(d));
const sunday = formatDate(getWeekEnd(d));
const week = getISOWeek(d);
const mondayMatch = monday === expected.monday;
const sundayMatch = sunday === expected.sunday;
const weekMatch = week === expected.week;
console.log(`Date: ${date}`);
console.log(
` Monday: ${monday} ${mondayMatch ? "✓" : "✗ (expected: " + expected.monday + ")"}`,
);
console.log(
` Sunday: ${sunday} ${sundayMatch ? "✓" : "✗ (expected: " + expected.sunday + ")"}`,
);
console.log(
` Week: ${week} ${weekMatch ? "✓" : "✗ (expected: " + expected.week + ")"}`,
);
console.log();
});
}
// Run tests if executed directly
if (typeof require !== "undefined" && require.main === module) {
testISOWeekCalculations();
}
export default {
getWeekStart,
getWeekEnd,
getISOWeek,
formatDate,
getWeeksInRange,
testISOWeekCalculations,
};
-9
View File
@@ -25,14 +25,6 @@ const isPublicRoute = createRouteMatcher([
const isApiRoute = createRouteMatcher(["/api/(.*)"]);
export default clerkMiddleware(async (auth, req) => {
// Log for debugging
const authHeader = req.headers.get("authorization");
if (authHeader) {
log.debug("Authorization header present", {
preview: authHeader.substring(0, 20) + "...",
});
}
// Don't protect public routes
if (isPublicRoute(req)) {
log.debug("Public route, skipping auth");
@@ -42,7 +34,6 @@ export default clerkMiddleware(async (auth, req) => {
// 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)) {
log.debug("API route, auth will be checked in handler");
return;
}
@@ -1,31 +0,0 @@
# Values used to calculate the hash in this folder name.
# Should not depend on the absolute path of the project itself.
# - AGP: 8.11.0.
# - $NDK is the path to NDK 27.1.12297006.
# - $PROJECT is the path to the parent folder of the root Gradle build file.
# - $ABI is the ABI to be built with. The specific value doesn't contribute to the value of the hash.
# - $HASH is the hash value computed from this text.
# - $CMAKE is the path to CMake 3.22.1.
# - $NINJA is the path to Ninja.
-H/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid/cmake-utils/default-app-setup
-DCMAKE_SYSTEM_NAME=Android
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON
-DCMAKE_SYSTEM_VERSION=26
-DANDROID_PLATFORM=android-26
-DANDROID_ABI=$ABI
-DCMAKE_ANDROID_ARCH_ABI=$ABI
-DANDROID_NDK=$NDK
-DCMAKE_ANDROID_NDK=$NDK
-DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake
-DCMAKE_MAKE_PROGRAM=$NINJA
-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$PROJECT/app/build/intermediates/cxx/Debug/$HASH/obj/$ABI
-DCMAKE_RUNTIME_OUTPUT_DIRECTORY=$PROJECT/app/build/intermediates/cxx/Debug/$HASH/obj/$ABI
-DCMAKE_BUILD_TYPE=Debug
-DCMAKE_FIND_ROOT_PATH=$PROJECT/app/.cxx/Debug/$HASH/prefab/$ABI/prefab
-B$PROJECT/app/.cxx/Debug/$HASH/$ABI
-GNinja
-DPROJECT_BUILD_DIR=$PROJECT/app/build
-DPROJECT_ROOT_DIR=$PROJECT
-DREACT_ANDROID_DIR=/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid
-DANDROID_STL=c++_shared
-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON
@@ -1,27 +0,0 @@
if(NOT TARGET ReactAndroid::hermestooling)
add_library(ReactAndroid::hermestooling SHARED IMPORTED)
set_target_properties(ReactAndroid::hermestooling PROPERTIES
IMPORTED_LOCATION "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/hermestooling/libs/android.x86/libhermestooling.so"
INTERFACE_INCLUDE_DIRECTORIES "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/hermestooling/include"
INTERFACE_LINK_LIBRARIES ""
)
endif()
if(NOT TARGET ReactAndroid::jsi)
add_library(ReactAndroid::jsi SHARED IMPORTED)
set_target_properties(ReactAndroid::jsi PROPERTIES
IMPORTED_LOCATION "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/jsi/libs/android.x86/libjsi.so"
INTERFACE_INCLUDE_DIRECTORIES "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/jsi/include"
INTERFACE_LINK_LIBRARIES ""
)
endif()
if(NOT TARGET ReactAndroid::reactnative)
add_library(ReactAndroid::reactnative SHARED IMPORTED)
set_target_properties(ReactAndroid::reactnative PROPERTIES
IMPORTED_LOCATION "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/reactnative/libs/android.x86/libreactnative.so"
INTERFACE_INCLUDE_DIRECTORIES "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/reactnative/include"
INTERFACE_LINK_LIBRARIES ""
)
endif()
@@ -1,9 +0,0 @@
set(PACKAGE_VERSION 0.81.5)
if("${PACKAGE_VERSION}" VERSION_LESS "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_COMPATIBLE FALSE)
else()
set(PACKAGE_VERSION_COMPATIBLE TRUE)
if("${PACKAGE_VERSION}" VERSION_EQUAL "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_EXACT TRUE)
endif()
endif()
@@ -1,9 +0,0 @@
if(NOT TARGET fbjni::fbjni)
add_library(fbjni::fbjni SHARED IMPORTED)
set_target_properties(fbjni::fbjni PROPERTIES
IMPORTED_LOCATION "/home/echo/.gradle/caches/8.14.3/transforms/488fe6e480efb97ec75859f6e274282f/transformed/fbjni-0.7.0/prefab/modules/fbjni/libs/android.x86/libfbjni.so"
INTERFACE_INCLUDE_DIRECTORIES "/home/echo/.gradle/caches/8.14.3/transforms/488fe6e480efb97ec75859f6e274282f/transformed/fbjni-0.7.0/prefab/modules/fbjni/include"
INTERFACE_LINK_LIBRARIES ""
)
endif()
@@ -1,9 +0,0 @@
set(PACKAGE_VERSION 3.22.1)
if("${PACKAGE_VERSION}" VERSION_LESS "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_COMPATIBLE FALSE)
else()
set(PACKAGE_VERSION_COMPATIBLE TRUE)
if("${PACKAGE_VERSION}" VERSION_EQUAL "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_EXACT TRUE)
endif()
endif()
@@ -1,9 +0,0 @@
if(NOT TARGET hermes-engine::libhermes)
add_library(hermes-engine::libhermes SHARED IMPORTED)
set_target_properties(hermes-engine::libhermes PROPERTIES
IMPORTED_LOCATION "/home/echo/.gradle/caches/8.14.3/transforms/f3d279cf13499f4c93e2b512ac9499e6/transformed/hermes-android-0.81.5-debug/prefab/modules/libhermes/libs/android.x86/libhermes.so"
INTERFACE_INCLUDE_DIRECTORIES "/home/echo/.gradle/caches/8.14.3/transforms/f3d279cf13499f4c93e2b512ac9499e6/transformed/hermes-android-0.81.5-debug/prefab/modules/libhermes/include"
INTERFACE_LINK_LIBRARIES ""
)
endif()
@@ -1,9 +0,0 @@
set(PACKAGE_VERSION 0.81.5)
if("${PACKAGE_VERSION}" VERSION_LESS "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_COMPATIBLE FALSE)
else()
set(PACKAGE_VERSION_COMPATIBLE TRUE)
if("${PACKAGE_VERSION}" VERSION_EQUAL "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_EXACT TRUE)
endif()
endif()
@@ -1,27 +0,0 @@
if(NOT TARGET ReactAndroid::hermestooling)
add_library(ReactAndroid::hermestooling SHARED IMPORTED)
set_target_properties(ReactAndroid::hermestooling PROPERTIES
IMPORTED_LOCATION "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/hermestooling/libs/android.x86_64/libhermestooling.so"
INTERFACE_INCLUDE_DIRECTORIES "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/hermestooling/include"
INTERFACE_LINK_LIBRARIES ""
)
endif()
if(NOT TARGET ReactAndroid::jsi)
add_library(ReactAndroid::jsi SHARED IMPORTED)
set_target_properties(ReactAndroid::jsi PROPERTIES
IMPORTED_LOCATION "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/jsi/libs/android.x86_64/libjsi.so"
INTERFACE_INCLUDE_DIRECTORIES "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/jsi/include"
INTERFACE_LINK_LIBRARIES ""
)
endif()
if(NOT TARGET ReactAndroid::reactnative)
add_library(ReactAndroid::reactnative SHARED IMPORTED)
set_target_properties(ReactAndroid::reactnative PROPERTIES
IMPORTED_LOCATION "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/reactnative/libs/android.x86_64/libreactnative.so"
INTERFACE_INCLUDE_DIRECTORIES "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/reactnative/include"
INTERFACE_LINK_LIBRARIES ""
)
endif()
@@ -1,9 +0,0 @@
set(PACKAGE_VERSION 0.81.5)
if("${PACKAGE_VERSION}" VERSION_LESS "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_COMPATIBLE FALSE)
else()
set(PACKAGE_VERSION_COMPATIBLE TRUE)
if("${PACKAGE_VERSION}" VERSION_EQUAL "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_EXACT TRUE)
endif()
endif()
@@ -1,9 +0,0 @@
if(NOT TARGET fbjni::fbjni)
add_library(fbjni::fbjni SHARED IMPORTED)
set_target_properties(fbjni::fbjni PROPERTIES
IMPORTED_LOCATION "/home/echo/.gradle/caches/8.14.3/transforms/488fe6e480efb97ec75859f6e274282f/transformed/fbjni-0.7.0/prefab/modules/fbjni/libs/android.x86_64/libfbjni.so"
INTERFACE_INCLUDE_DIRECTORIES "/home/echo/.gradle/caches/8.14.3/transforms/488fe6e480efb97ec75859f6e274282f/transformed/fbjni-0.7.0/prefab/modules/fbjni/include"
INTERFACE_LINK_LIBRARIES ""
)
endif()
@@ -1,9 +0,0 @@
set(PACKAGE_VERSION 3.22.1)
if("${PACKAGE_VERSION}" VERSION_LESS "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_COMPATIBLE FALSE)
else()
set(PACKAGE_VERSION_COMPATIBLE TRUE)
if("${PACKAGE_VERSION}" VERSION_EQUAL "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_EXACT TRUE)
endif()
endif()
@@ -1,9 +0,0 @@
if(NOT TARGET hermes-engine::libhermes)
add_library(hermes-engine::libhermes SHARED IMPORTED)
set_target_properties(hermes-engine::libhermes PROPERTIES
IMPORTED_LOCATION "/home/echo/.gradle/caches/8.14.3/transforms/f3d279cf13499f4c93e2b512ac9499e6/transformed/hermes-android-0.81.5-debug/prefab/modules/libhermes/libs/android.x86_64/libhermes.so"
INTERFACE_INCLUDE_DIRECTORIES "/home/echo/.gradle/caches/8.14.3/transforms/f3d279cf13499f4c93e2b512ac9499e6/transformed/hermes-android-0.81.5-debug/prefab/modules/libhermes/include"
INTERFACE_LINK_LIBRARIES ""
)
endif()
@@ -1,9 +0,0 @@
set(PACKAGE_VERSION 0.81.5)
if("${PACKAGE_VERSION}" VERSION_LESS "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_COMPATIBLE FALSE)
else()
set(PACKAGE_VERSION_COMPATIBLE TRUE)
if("${PACKAGE_VERSION}" VERSION_EQUAL "${PACKAGE_FIND_VERSION}")
set(PACKAGE_VERSION_EXACT TRUE)
endif()
endif()
@@ -1,851 +0,0 @@
{
"inputs" :
[
{
"path" : "CMakeLists.txt"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineSystem.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/android.toolchain.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/android-legacy.toolchain.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/abis.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/platforms.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android-Determine.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/hooks/pre/Android-Determine.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeSystem.cmake.in"
},
{
"isGenerated" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/x86/CMakeFiles/3.22.1-g37088a8/CMakeSystem.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/android.toolchain.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeSystemSpecificInitialize.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android-Initialize.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/hooks/pre/Android-Initialize.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineCCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android-Determine-C.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android/Determine-Compiler.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/hooks/pre/Determine-Compiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineCompilerId.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCompilerIdDetection.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/ADSP-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/ARMCC-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/ARMClang-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/AppleClang-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang-DetermineCompilerInternal.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Borland-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Bruce-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang-DetermineCompilerInternal.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Compaq-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Cray-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Embarcadero-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Fujitsu-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/FujitsuClang-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/GHS-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/GNU-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/HP-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/IAR-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Intel-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/IntelLLVM-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/MSVC-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/NVHPC-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/NVIDIA-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/OpenWatcom-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/PGI-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/PathScale-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/SCO-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/SDCC-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/SunPro-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/TI-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/TinyCC-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/VisualAge-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/IBMCPP-C-DetermineVersionInternal.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Watcom-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/XL-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/IBMCPP-C-DetermineVersionInternal.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/XLClang-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/zOS-C-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/IBMCPP-C-DetermineVersionInternal.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeFindBinUtils.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang-FindBinUtils.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCCompiler.cmake.in"
},
{
"isGenerated" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/x86/CMakeFiles/3.22.1-g37088a8/CMakeCCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineCXXCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android-Determine-CXX.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android/Determine-Compiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineCompilerId.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCompilerIdDetection.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/ADSP-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/ARMCC-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/ARMClang-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/AppleClang-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang-DetermineCompilerInternal.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Borland-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang-DetermineCompilerInternal.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Comeau-CXX-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Compaq-CXX-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Cray-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Embarcadero-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Fujitsu-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/FujitsuClang-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/GHS-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/GNU-CXX-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/HP-CXX-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/IAR-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Intel-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/IntelLLVM-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/MSVC-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/NVHPC-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/NVIDIA-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/OpenWatcom-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/PGI-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/PathScale-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/SCO-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/SunPro-CXX-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/TI-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/VisualAge-CXX-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/IBMCPP-CXX-DetermineVersionInternal.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Watcom-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/XL-CXX-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/IBMCPP-CXX-DetermineVersionInternal.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/XLClang-CXX-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/zOS-CXX-DetermineCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/IBMCPP-CXX-DetermineVersionInternal.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeFindBinUtils.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang-FindBinUtils.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCXXCompiler.cmake.in"
},
{
"isGenerated" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/x86/CMakeFiles/3.22.1-g37088a8/CMakeCXXCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeSystemSpecificInformation.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeGenericSystem.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeInitializeConfigs.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/hooks/pre/Android.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Linux.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/UnixPaths.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCInformation.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeLanguageInformation.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang-C.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/CMakeCommonCompilerMacros.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/GNU.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/CMakeCommonCompilerMacros.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android-Clang-C.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android-Clang.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/hooks/pre/Android-Clang.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/flags.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCommonLanguageInclude.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeTestCCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeTestCompilerCommon.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineCompilerABI.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeParseImplicitIncludeInfo.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeParseImplicitLinkInfo.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeParseLibraryArchitecture.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeTestCompilerCommon.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCCompilerABI.c"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineCompileFeatures.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Internal/FeatureTesting.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCCompiler.cmake.in"
},
{
"isGenerated" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/x86/CMakeFiles/3.22.1-g37088a8/CMakeCCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCXXInformation.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeLanguageInformation.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang-CXX.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Compiler/Clang.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android-Clang-CXX.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Platform/Android-Clang.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCommonLanguageInclude.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeTestCXXCompiler.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeTestCompilerCommon.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineCompilerABI.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeParseImplicitIncludeInfo.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeParseImplicitLinkInfo.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeParseLibraryArchitecture.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeTestCompilerCommon.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCXXCompilerABI.cpp"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeDetermineCompileFeatures.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/Internal/FeatureTesting.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CMakeCXXCompiler.cmake.in"
},
{
"isGenerated" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/x86/CMakeFiles/3.22.1-g37088a8/CMakeCXXCompiler.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid/cmake-utils/folly-flags.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactCommon/cmake-utils/react-native-flags.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CheckIPOSupported.cmake"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CheckIPOSupported/CMakeLists-CXX.txt.in"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CheckIPOSupported/foo.cpp"
},
{
"isCMake" : true,
"isExternal" : true,
"path" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22/Modules/CheckIPOSupported/main.cpp"
},
{
"isExternal" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/prefab/x86/prefab/lib/i686-linux-android/cmake/ReactAndroid/ReactAndroidConfigVersion.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/prefab/x86/prefab/lib/i686-linux-android/cmake/ReactAndroid/ReactAndroidConfig.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/prefab/x86/prefab/lib/i686-linux-android/cmake/fbjni/fbjniConfigVersion.cmake"
},
{
"isExternal" : true,
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/prefab/x86/prefab/lib/i686-linux-android/cmake/fbjni/fbjniConfig.cmake"
}
],
"kind" : "cmakeFiles",
"paths" :
{
"build" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/x86",
"source" : "/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid/cmake-utils/default-app-setup"
},
"version" :
{
"major" : 1,
"minor" : 0
}
}
@@ -1,60 +0,0 @@
{
"configurations" :
[
{
"directories" :
[
{
"build" : ".",
"jsonFile" : "directory-.-Debug-f5ebdc15457944623624.json",
"minimumCMakeVersion" :
{
"string" : "3.13"
},
"projectIndex" : 0,
"source" : ".",
"targetIndexes" :
[
0
]
}
],
"name" : "Debug",
"projects" :
[
{
"directoryIndexes" :
[
0
],
"name" : "appmodules",
"targetIndexes" :
[
0
]
}
],
"targets" :
[
{
"directoryIndex" : 0,
"id" : "appmodules::@6890427a1f51a3e7e1df",
"jsonFile" : "target-appmodules-Debug-9ef032710fe355da9383.json",
"name" : "appmodules",
"projectIndex" : 0
}
]
}
],
"kind" : "codemodel",
"paths" :
{
"build" : "/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/x86",
"source" : "/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid/cmake-utils/default-app-setup"
},
"version" :
{
"major" : 2,
"minor" : 3
}
}
@@ -1,14 +0,0 @@
{
"backtraceGraph" :
{
"commands" : [],
"files" : [],
"nodes" : []
},
"installers" : [],
"paths" :
{
"build" : ".",
"source" : "."
}
}
@@ -1,92 +0,0 @@
{
"cmake" :
{
"generator" :
{
"multiConfig" : false,
"name" : "Ninja"
},
"paths" :
{
"cmake" : "/home/echo/Android/Sdk/cmake/3.22.1/bin/cmake",
"cpack" : "/home/echo/Android/Sdk/cmake/3.22.1/bin/cpack",
"ctest" : "/home/echo/Android/Sdk/cmake/3.22.1/bin/ctest",
"root" : "/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22"
},
"version" :
{
"isDirty" : false,
"major" : 3,
"minor" : 22,
"patch" : 1,
"string" : "3.22.1-g37088a8",
"suffix" : "g37088a8"
}
},
"objects" :
[
{
"jsonFile" : "codemodel-v2-d483cb7bfe026ad3a64f.json",
"kind" : "codemodel",
"version" :
{
"major" : 2,
"minor" : 3
}
},
{
"jsonFile" : "cache-v2-099ee692bdea7ed85643.json",
"kind" : "cache",
"version" :
{
"major" : 2,
"minor" : 0
}
},
{
"jsonFile" : "cmakeFiles-v1-b99aeefcc68e41c89ebf.json",
"kind" : "cmakeFiles",
"version" :
{
"major" : 1,
"minor" : 0
}
}
],
"reply" :
{
"client-agp" :
{
"cache-v2" :
{
"jsonFile" : "cache-v2-099ee692bdea7ed85643.json",
"kind" : "cache",
"version" :
{
"major" : 2,
"minor" : 0
}
},
"cmakeFiles-v1" :
{
"jsonFile" : "cmakeFiles-v1-b99aeefcc68e41c89ebf.json",
"kind" : "cmakeFiles",
"version" :
{
"major" : 1,
"minor" : 0
}
},
"codemodel-v2" :
{
"jsonFile" : "codemodel-v2-d483cb7bfe026ad3a64f.json",
"kind" : "codemodel",
"version" :
{
"major" : 2,
"minor" : 3
}
}
}
}
}
@@ -1,226 +0,0 @@
{
"artifacts" :
[
{
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/build/intermediates/cxx/Debug/2z472e3c/obj/x86/libappmodules.so"
}
],
"backtrace" : 3,
"backtraceGraph" :
{
"commands" :
[
"add_library",
"include",
"target_link_libraries",
"target_compile_options",
"target_compile_reactnative_options",
"target_compile_definitions",
"target_include_directories"
],
"files" :
[
"/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake",
"CMakeLists.txt",
"/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactCommon/cmake-utils/react-native-flags.cmake"
],
"nodes" :
[
{
"file" : 1
},
{
"command" : 1,
"file" : 1,
"line" : 31,
"parent" : 0
},
{
"file" : 0,
"parent" : 1
},
{
"command" : 0,
"file" : 0,
"line" : 64,
"parent" : 2
},
{
"command" : 2,
"file" : 0,
"line" : 81,
"parent" : 2
},
{
"command" : 4,
"file" : 0,
"line" : 71,
"parent" : 2
},
{
"command" : 3,
"file" : 2,
"line" : 30,
"parent" : 5
},
{
"command" : 5,
"file" : 2,
"line" : 33,
"parent" : 5
},
{
"command" : 6,
"file" : 0,
"line" : 66,
"parent" : 2
}
]
},
"compileGroups" :
[
{
"compileCommandFragments" :
[
{
"fragment" : "-g -DANDROID -fdata-sections -ffunction-sections -funwind-tables -fstack-protector-strong -no-canonical-prefixes -D__BIONIC_NO_PAGE_SIZE_MACRO -D_FORTIFY_SOURCE=2 -Wformat -Werror=format-security -fno-limit-debug-info -fPIC"
},
{
"backtrace" : 6,
"fragment" : "-Wall"
},
{
"backtrace" : 6,
"fragment" : "-Werror"
},
{
"backtrace" : 6,
"fragment" : "-fexceptions"
},
{
"backtrace" : 6,
"fragment" : "-frtti"
},
{
"backtrace" : 6,
"fragment" : "-std=c++20"
},
{
"backtrace" : 6,
"fragment" : "-DFOLLY_NO_CONFIG=1"
},
{
"backtrace" : 6,
"fragment" : "-DLOG_TAG=\\\"ReactNative\\\""
}
],
"defines" :
[
{
"backtrace" : 7,
"define" : "RN_SERIALIZABLE_STATE"
},
{
"define" : "appmodules_EXPORTS"
}
],
"includes" :
[
{
"backtrace" : 8,
"path" : "/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid/cmake-utils/default-app-setup"
},
{
"backtrace" : 8,
"path" : "/home/echo/dev/prototype/apps/mobile/android/app/build/generated/autolinking/src/main/jni"
},
{
"backtrace" : 4,
"isSystem" : true,
"path" : "/home/echo/.gradle/caches/8.14.3/transforms/488fe6e480efb97ec75859f6e274282f/transformed/fbjni-0.7.0/prefab/modules/fbjni/include"
},
{
"backtrace" : 4,
"isSystem" : true,
"path" : "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/jsi/include"
},
{
"backtrace" : 4,
"isSystem" : true,
"path" : "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/reactnative/include"
}
],
"language" : "CXX",
"sourceIndexes" :
[
0
],
"sysroot" :
{
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot"
}
}
],
"id" : "appmodules::@6890427a1f51a3e7e1df",
"link" :
{
"commandFragments" :
[
{
"fragment" : "-Wl,--build-id=sha1 -Wl,--no-rosegment -Wl,--no-undefined-version -Wl,--fatal-warnings -Wl,--no-undefined -Qunused-arguments",
"role" : "flags"
},
{
"backtrace" : 4,
"fragment" : "/home/echo/.gradle/caches/8.14.3/transforms/488fe6e480efb97ec75859f6e274282f/transformed/fbjni-0.7.0/prefab/modules/fbjni/libs/android.x86/libfbjni.so",
"role" : "libraries"
},
{
"backtrace" : 4,
"fragment" : "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/jsi/libs/android.x86/libjsi.so",
"role" : "libraries"
},
{
"backtrace" : 4,
"fragment" : "/home/echo/.gradle/caches/8.14.3/transforms/e5d515112484fe4dddc05785469cc380/transformed/react-android-0.81.5-debug/prefab/modules/reactnative/libs/android.x86/libreactnative.so",
"role" : "libraries"
},
{
"fragment" : "-latomic -lm",
"role" : "libraries"
}
],
"language" : "CXX",
"sysroot" :
{
"path" : "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot"
}
},
"name" : "appmodules",
"nameOnDisk" : "libappmodules.so",
"paths" :
{
"build" : ".",
"source" : "."
},
"sourceGroups" :
[
{
"name" : "Source Files",
"sourceIndexes" :
[
0
]
}
],
"sources" :
[
{
"backtrace" : 3,
"compileGroupIndex" : 0,
"path" : "OnLoad.cpp",
"sourceGroupIndex" : 0
}
],
"type" : "SHARED_LIBRARY"
}
@@ -1,420 +0,0 @@
# This is the CMakeCache file.
# For build in directory: /home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/x86
# It was generated by CMake: /home/echo/Android/Sdk/cmake/3.22.1/bin/cmake
# You can edit this file to change values found and used by cmake.
# If you do not want to change any of the values, simply exit the editor.
# If you do want to change a value, simply edit, save, and exit the editor.
# The syntax for the file is as follows:
# KEY:TYPE=VALUE
# KEY is the name of a variable in the cache.
# TYPE is a hint to GUIs for the type of VALUE, DO NOT EDIT TYPE!.
# VALUE is the current value for the KEY.
########################
# EXTERNAL cache entries
########################
//No help, variable specified on the command line.
ANDROID_ABI:UNINITIALIZED=x86
//No help, variable specified on the command line.
ANDROID_NDK:UNINITIALIZED=/home/echo/Android/Sdk/ndk/27.1.12297006
//No help, variable specified on the command line.
ANDROID_PLATFORM:UNINITIALIZED=android-26
//No help, variable specified on the command line.
ANDROID_STL:UNINITIALIZED=c++_shared
//No help, variable specified on the command line.
ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES:UNINITIALIZED=ON
//Path to a program.
CCACHE_FOUND:FILEPATH=CCACHE_FOUND-NOTFOUND
//Path to a program.
CMAKE_ADDR2LINE:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line
//No help, variable specified on the command line.
CMAKE_ANDROID_ARCH_ABI:UNINITIALIZED=x86
//No help, variable specified on the command line.
CMAKE_ANDROID_NDK:UNINITIALIZED=/home/echo/Android/Sdk/ndk/27.1.12297006
//Archiver
CMAKE_AR:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar
//Flags used by the compiler during all build types.
CMAKE_ASM_FLAGS:STRING=
//Flags used by the compiler during debug builds.
CMAKE_ASM_FLAGS_DEBUG:STRING=
//Flags used by the compiler during release builds.
CMAKE_ASM_FLAGS_RELEASE:STRING=
//Choose the type of build, options are: None Debug Release RelWithDebInfo
// MinSizeRel ...
CMAKE_BUILD_TYPE:STRING=Debug
//LLVM archiver
CMAKE_CXX_COMPILER_AR:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar
//Generate index for LLVM archive
CMAKE_CXX_COMPILER_RANLIB:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ranlib
//Flags used by the compiler during all build types.
CMAKE_CXX_FLAGS:STRING=
//Flags used by the compiler during debug builds.
CMAKE_CXX_FLAGS_DEBUG:STRING=
//Flags used by the CXX compiler during MINSIZEREL builds.
CMAKE_CXX_FLAGS_MINSIZEREL:STRING=-Os -DNDEBUG
//Flags used by the compiler during release builds.
CMAKE_CXX_FLAGS_RELEASE:STRING=
//Flags used by the CXX compiler during RELWITHDEBINFO builds.
CMAKE_CXX_FLAGS_RELWITHDEBINFO:STRING=-O2 -g -DNDEBUG
//Libraries linked by default with all C++ applications.
CMAKE_CXX_STANDARD_LIBRARIES:STRING=-latomic -lm
//LLVM archiver
CMAKE_C_COMPILER_AR:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar
//Generate index for LLVM archive
CMAKE_C_COMPILER_RANLIB:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ranlib
//Flags used by the compiler during all build types.
CMAKE_C_FLAGS:STRING=
//Flags used by the compiler during debug builds.
CMAKE_C_FLAGS_DEBUG:STRING=
//Flags used by the C compiler during MINSIZEREL builds.
CMAKE_C_FLAGS_MINSIZEREL:STRING=-Os -DNDEBUG
//Flags used by the compiler during release builds.
CMAKE_C_FLAGS_RELEASE:STRING=
//Flags used by the C compiler during RELWITHDEBINFO builds.
CMAKE_C_FLAGS_RELWITHDEBINFO:STRING=-O2 -g -DNDEBUG
//Libraries linked by default with all C applications.
CMAKE_C_STANDARD_LIBRARIES:STRING=-latomic -lm
//Path to a program.
CMAKE_DLLTOOL:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-dlltool
//Flags used by the linker.
CMAKE_EXE_LINKER_FLAGS:STRING=
//Flags used by the linker during DEBUG builds.
CMAKE_EXE_LINKER_FLAGS_DEBUG:STRING=
//Flags used by the linker during MINSIZEREL builds.
CMAKE_EXE_LINKER_FLAGS_MINSIZEREL:STRING=
//Flags used by the linker during RELEASE builds.
CMAKE_EXE_LINKER_FLAGS_RELEASE:STRING=
//Flags used by the linker during RELWITHDEBINFO builds.
CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO:STRING=
//No help, variable specified on the command line.
CMAKE_EXPORT_COMPILE_COMMANDS:UNINITIALIZED=ON
//No help, variable specified on the command line.
CMAKE_FIND_ROOT_PATH:UNINITIALIZED=/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/prefab/x86/prefab
//Install path prefix, prepended onto install directories.
CMAKE_INSTALL_PREFIX:PATH=/usr/local
//No help, variable specified on the command line.
CMAKE_LIBRARY_OUTPUT_DIRECTORY:UNINITIALIZED=/home/echo/dev/prototype/apps/mobile/android/app/build/intermediates/cxx/Debug/2z472e3c/obj/x86
//Path to a program.
CMAKE_LINKER:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/ld.lld
//No help, variable specified on the command line.
CMAKE_MAKE_PROGRAM:UNINITIALIZED=/home/echo/Android/Sdk/cmake/3.22.1/bin/ninja
//Flags used by the linker during the creation of modules.
CMAKE_MODULE_LINKER_FLAGS:STRING=
//Flags used by the linker during the creation of modules during
// DEBUG builds.
CMAKE_MODULE_LINKER_FLAGS_DEBUG:STRING=
//Flags used by the linker during the creation of modules during
// MINSIZEREL builds.
CMAKE_MODULE_LINKER_FLAGS_MINSIZEREL:STRING=
//Flags used by the linker during the creation of modules during
// RELEASE builds.
CMAKE_MODULE_LINKER_FLAGS_RELEASE:STRING=
//Flags used by the linker during the creation of modules during
// RELWITHDEBINFO builds.
CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO:STRING=
//Path to a program.
CMAKE_NM:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm
//Path to a program.
CMAKE_OBJCOPY:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy
//Path to a program.
CMAKE_OBJDUMP:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objdump
//Value Computed by CMake
CMAKE_PROJECT_DESCRIPTION:STATIC=
//Value Computed by CMake
CMAKE_PROJECT_HOMEPAGE_URL:STATIC=
//Value Computed by CMake
CMAKE_PROJECT_NAME:STATIC=appmodules
//Ranlib
CMAKE_RANLIB:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ranlib
//Path to a program.
CMAKE_READELF:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf
//No help, variable specified on the command line.
CMAKE_RUNTIME_OUTPUT_DIRECTORY:UNINITIALIZED=/home/echo/dev/prototype/apps/mobile/android/app/build/intermediates/cxx/Debug/2z472e3c/obj/x86
//Flags used by the linker during the creation of dll's.
CMAKE_SHARED_LINKER_FLAGS:STRING=
//Flags used by the linker during the creation of shared libraries
// during DEBUG builds.
CMAKE_SHARED_LINKER_FLAGS_DEBUG:STRING=
//Flags used by the linker during the creation of shared libraries
// during MINSIZEREL builds.
CMAKE_SHARED_LINKER_FLAGS_MINSIZEREL:STRING=
//Flags used by the linker during the creation of shared libraries
// during RELEASE builds.
CMAKE_SHARED_LINKER_FLAGS_RELEASE:STRING=
//Flags used by the linker during the creation of shared libraries
// during RELWITHDEBINFO builds.
CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO:STRING=
//If set, runtime paths are not added when installing shared libraries,
// but are added when building.
CMAKE_SKIP_INSTALL_RPATH:BOOL=NO
//If set, runtime paths are not added when using shared libraries.
CMAKE_SKIP_RPATH:BOOL=NO
//Flags used by the linker during the creation of static libraries
// during all build types.
CMAKE_STATIC_LINKER_FLAGS:STRING=
//Flags used by the linker during the creation of static libraries
// during DEBUG builds.
CMAKE_STATIC_LINKER_FLAGS_DEBUG:STRING=
//Flags used by the linker during the creation of static libraries
// during MINSIZEREL builds.
CMAKE_STATIC_LINKER_FLAGS_MINSIZEREL:STRING=
//Flags used by the linker during the creation of static libraries
// during RELEASE builds.
CMAKE_STATIC_LINKER_FLAGS_RELEASE:STRING=
//Flags used by the linker during the creation of static libraries
// during RELWITHDEBINFO builds.
CMAKE_STATIC_LINKER_FLAGS_RELWITHDEBINFO:STRING=
//Strip
CMAKE_STRIP:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
//No help, variable specified on the command line.
CMAKE_SYSTEM_NAME:UNINITIALIZED=Android
//No help, variable specified on the command line.
CMAKE_SYSTEM_VERSION:UNINITIALIZED=26
//The CMake toolchain file
CMAKE_TOOLCHAIN_FILE:FILEPATH=/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/android.toolchain.cmake
//If this value is on, makefiles will be generated without the
// .SILENT directive, and all commands will be echoed to the console
// during the make. This is useful for debugging only. With Visual
// Studio IDE projects all commands are done without /nologo.
CMAKE_VERBOSE_MAKEFILE:BOOL=FALSE
//No help, variable specified on the command line.
PROJECT_BUILD_DIR:UNINITIALIZED=/home/echo/dev/prototype/apps/mobile/android/app/build
//No help, variable specified on the command line.
PROJECT_ROOT_DIR:UNINITIALIZED=/home/echo/dev/prototype/apps/mobile/android
//No help, variable specified on the command line.
REACT_ANDROID_DIR:UNINITIALIZED=/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid
//The directory containing a CMake configuration file for ReactAndroid.
ReactAndroid_DIR:PATH=/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/prefab/x86/prefab/lib/i686-linux-android/cmake/ReactAndroid
//Value Computed by CMake
appmodules_BINARY_DIR:STATIC=/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/x86
//Value Computed by CMake
appmodules_IS_TOP_LEVEL:STATIC=ON
//Value Computed by CMake
appmodules_SOURCE_DIR:STATIC=/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid/cmake-utils/default-app-setup
//The directory containing a CMake configuration file for fbjni.
fbjni_DIR:PATH=/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/prefab/x86/prefab/lib/i686-linux-android/cmake/fbjni
########################
# INTERNAL cache entries
########################
//ADVANCED property for variable: CMAKE_ADDR2LINE
CMAKE_ADDR2LINE-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_AR
CMAKE_AR-ADVANCED:INTERNAL=1
//This is the directory where this CMakeCache.txt was created
CMAKE_CACHEFILE_DIR:INTERNAL=/home/echo/dev/prototype/apps/mobile/android/app/.cxx/Debug/2z472e3c/x86
//Major version of cmake used to create the current loaded cache
CMAKE_CACHE_MAJOR_VERSION:INTERNAL=3
//Minor version of cmake used to create the current loaded cache
CMAKE_CACHE_MINOR_VERSION:INTERNAL=22
//Patch version of cmake used to create the current loaded cache
CMAKE_CACHE_PATCH_VERSION:INTERNAL=1
//Path to CMake executable.
CMAKE_COMMAND:INTERNAL=/home/echo/Android/Sdk/cmake/3.22.1/bin/cmake
//Path to cpack program executable.
CMAKE_CPACK_COMMAND:INTERNAL=/home/echo/Android/Sdk/cmake/3.22.1/bin/cpack
//Path to ctest program executable.
CMAKE_CTEST_COMMAND:INTERNAL=/home/echo/Android/Sdk/cmake/3.22.1/bin/ctest
//ADVANCED property for variable: CMAKE_CXX_COMPILER_AR
CMAKE_CXX_COMPILER_AR-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_CXX_COMPILER_RANLIB
CMAKE_CXX_COMPILER_RANLIB-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_CXX_FLAGS
CMAKE_CXX_FLAGS-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_CXX_FLAGS_DEBUG
CMAKE_CXX_FLAGS_DEBUG-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_CXX_FLAGS_MINSIZEREL
CMAKE_CXX_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_CXX_FLAGS_RELEASE
CMAKE_CXX_FLAGS_RELEASE-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_CXX_FLAGS_RELWITHDEBINFO
CMAKE_CXX_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_CXX_STANDARD_LIBRARIES
CMAKE_CXX_STANDARD_LIBRARIES-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_C_COMPILER_AR
CMAKE_C_COMPILER_AR-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_C_COMPILER_RANLIB
CMAKE_C_COMPILER_RANLIB-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_C_FLAGS
CMAKE_C_FLAGS-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_C_FLAGS_DEBUG
CMAKE_C_FLAGS_DEBUG-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_C_FLAGS_MINSIZEREL
CMAKE_C_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_C_FLAGS_RELEASE
CMAKE_C_FLAGS_RELEASE-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_C_FLAGS_RELWITHDEBINFO
CMAKE_C_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_C_STANDARD_LIBRARIES
CMAKE_C_STANDARD_LIBRARIES-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_DLLTOOL
CMAKE_DLLTOOL-ADVANCED:INTERNAL=1
//Executable file format
CMAKE_EXECUTABLE_FORMAT:INTERNAL=ELF
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS
CMAKE_EXE_LINKER_FLAGS-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_DEBUG
CMAKE_EXE_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_MINSIZEREL
CMAKE_EXE_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_RELEASE
CMAKE_EXE_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO
CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
//Name of external makefile project generator.
CMAKE_EXTRA_GENERATOR:INTERNAL=
//Name of generator.
CMAKE_GENERATOR:INTERNAL=Ninja
//Generator instance identifier.
CMAKE_GENERATOR_INSTANCE:INTERNAL=
//Name of generator platform.
CMAKE_GENERATOR_PLATFORM:INTERNAL=
//Name of generator toolset.
CMAKE_GENERATOR_TOOLSET:INTERNAL=
//Source directory with the top level CMakeLists.txt file for this
// project
CMAKE_HOME_DIRECTORY:INTERNAL=/home/echo/dev/prototype/apps/mobile/node_modules/react-native/ReactAndroid/cmake-utils/default-app-setup
//Install .so files without execute permission.
CMAKE_INSTALL_SO_NO_EXE:INTERNAL=0
//ADVANCED property for variable: CMAKE_LINKER
CMAKE_LINKER-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS
CMAKE_MODULE_LINKER_FLAGS-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_DEBUG
CMAKE_MODULE_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_MINSIZEREL
CMAKE_MODULE_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_RELEASE
CMAKE_MODULE_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO
CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_NM
CMAKE_NM-ADVANCED:INTERNAL=1
//number of local generators
CMAKE_NUMBER_OF_MAKEFILES:INTERNAL=1
//ADVANCED property for variable: CMAKE_OBJCOPY
CMAKE_OBJCOPY-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_OBJDUMP
CMAKE_OBJDUMP-ADVANCED:INTERNAL=1
//Platform information initialized
CMAKE_PLATFORM_INFO_INITIALIZED:INTERNAL=1
//ADVANCED property for variable: CMAKE_RANLIB
CMAKE_RANLIB-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_READELF
CMAKE_READELF-ADVANCED:INTERNAL=1
//Path to CMake installation.
CMAKE_ROOT:INTERNAL=/home/echo/Android/Sdk/cmake/3.22.1/share/cmake-3.22
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS
CMAKE_SHARED_LINKER_FLAGS-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_DEBUG
CMAKE_SHARED_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_MINSIZEREL
CMAKE_SHARED_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_RELEASE
CMAKE_SHARED_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO
CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_SKIP_INSTALL_RPATH
CMAKE_SKIP_INSTALL_RPATH-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_SKIP_RPATH
CMAKE_SKIP_RPATH-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS
CMAKE_STATIC_LINKER_FLAGS-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_DEBUG
CMAKE_STATIC_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_MINSIZEREL
CMAKE_STATIC_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_RELEASE
CMAKE_STATIC_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_RELWITHDEBINFO
CMAKE_STATIC_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
//ADVANCED property for variable: CMAKE_STRIP
CMAKE_STRIP-ADVANCED:INTERNAL=1
//uname command
CMAKE_UNAME:INTERNAL=/usr/bin/uname
//ADVANCED property for variable: CMAKE_VERBOSE_MAKEFILE
CMAKE_VERBOSE_MAKEFILE-ADVANCED:INTERNAL=1
@@ -1,72 +0,0 @@
set(CMAKE_C_COMPILER "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/clang")
set(CMAKE_C_COMPILER_ARG1 "")
set(CMAKE_C_COMPILER_ID "Clang")
set(CMAKE_C_COMPILER_VERSION "18.0.2")
set(CMAKE_C_COMPILER_VERSION_INTERNAL "")
set(CMAKE_C_COMPILER_WRAPPER "")
set(CMAKE_C_STANDARD_COMPUTED_DEFAULT "17")
set(CMAKE_C_EXTENSIONS_COMPUTED_DEFAULT "ON")
set(CMAKE_C_COMPILE_FEATURES "c_std_90;c_function_prototypes;c_std_99;c_restrict;c_variadic_macros;c_std_11;c_static_assert;c_std_17;c_std_23")
set(CMAKE_C90_COMPILE_FEATURES "c_std_90;c_function_prototypes")
set(CMAKE_C99_COMPILE_FEATURES "c_std_99;c_restrict;c_variadic_macros")
set(CMAKE_C11_COMPILE_FEATURES "c_std_11;c_static_assert")
set(CMAKE_C17_COMPILE_FEATURES "c_std_17")
set(CMAKE_C23_COMPILE_FEATURES "c_std_23")
set(CMAKE_C_PLATFORM_ID "Linux")
set(CMAKE_C_SIMULATE_ID "")
set(CMAKE_C_COMPILER_FRONTEND_VARIANT "GNU")
set(CMAKE_C_SIMULATE_VERSION "")
set(CMAKE_AR "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar")
set(CMAKE_C_COMPILER_AR "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar")
set(CMAKE_RANLIB "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ranlib")
set(CMAKE_C_COMPILER_RANLIB "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ranlib")
set(CMAKE_LINKER "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/ld.lld")
set(CMAKE_MT "")
set(CMAKE_COMPILER_IS_GNUCC )
set(CMAKE_C_COMPILER_LOADED 1)
set(CMAKE_C_COMPILER_WORKS TRUE)
set(CMAKE_C_ABI_COMPILED TRUE)
set(CMAKE_C_COMPILER_ENV_VAR "CC")
set(CMAKE_C_COMPILER_ID_RUN 1)
set(CMAKE_C_SOURCE_FILE_EXTENSIONS c;m)
set(CMAKE_C_IGNORE_EXTENSIONS h;H;o;O;obj;OBJ;def;DEF;rc;RC)
set(CMAKE_C_LINKER_PREFERENCE 10)
# Save compiler ABI information.
set(CMAKE_C_SIZEOF_DATA_PTR "4")
set(CMAKE_C_COMPILER_ABI "ELF")
set(CMAKE_C_BYTE_ORDER "LITTLE_ENDIAN")
set(CMAKE_C_LIBRARY_ARCHITECTURE "")
if(CMAKE_C_SIZEOF_DATA_PTR)
set(CMAKE_SIZEOF_VOID_P "${CMAKE_C_SIZEOF_DATA_PTR}")
endif()
if(CMAKE_C_COMPILER_ABI)
set(CMAKE_INTERNAL_PLATFORM_ABI "${CMAKE_C_COMPILER_ABI}")
endif()
if(CMAKE_C_LIBRARY_ARCHITECTURE)
set(CMAKE_LIBRARY_ARCHITECTURE "")
endif()
set(CMAKE_C_CL_SHOWINCLUDES_PREFIX "")
if(CMAKE_C_CL_SHOWINCLUDES_PREFIX)
set(CMAKE_CL_SHOWINCLUDES_PREFIX "${CMAKE_C_CL_SHOWINCLUDES_PREFIX}")
endif()
set(CMAKE_C_IMPLICIT_INCLUDE_DIRECTORIES "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/lib/clang/18/include;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include/i686-linux-android;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include")
set(CMAKE_C_IMPLICIT_LINK_LIBRARIES "-l:libunwind.a;dl;c;-l:libunwind.a;dl")
set(CMAKE_C_IMPLICIT_LINK_DIRECTORIES "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/lib/clang/18/lib/linux/i386;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/i686-linux-android/26;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/i686-linux-android;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib")
set(CMAKE_C_IMPLICIT_LINK_FRAMEWORK_DIRECTORIES "")
@@ -1,83 +0,0 @@
set(CMAKE_CXX_COMPILER "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/clang++")
set(CMAKE_CXX_COMPILER_ARG1 "")
set(CMAKE_CXX_COMPILER_ID "Clang")
set(CMAKE_CXX_COMPILER_VERSION "18.0.2")
set(CMAKE_CXX_COMPILER_VERSION_INTERNAL "")
set(CMAKE_CXX_COMPILER_WRAPPER "")
set(CMAKE_CXX_STANDARD_COMPUTED_DEFAULT "17")
set(CMAKE_CXX_EXTENSIONS_COMPUTED_DEFAULT "ON")
set(CMAKE_CXX_COMPILE_FEATURES "cxx_std_98;cxx_template_template_parameters;cxx_std_11;cxx_alias_templates;cxx_alignas;cxx_alignof;cxx_attributes;cxx_auto_type;cxx_constexpr;cxx_decltype;cxx_decltype_incomplete_return_types;cxx_default_function_template_args;cxx_defaulted_functions;cxx_defaulted_move_initializers;cxx_delegating_constructors;cxx_deleted_functions;cxx_enum_forward_declarations;cxx_explicit_conversions;cxx_extended_friend_declarations;cxx_extern_templates;cxx_final;cxx_func_identifier;cxx_generalized_initializers;cxx_inheriting_constructors;cxx_inline_namespaces;cxx_lambdas;cxx_local_type_template_args;cxx_long_long_type;cxx_noexcept;cxx_nonstatic_member_init;cxx_nullptr;cxx_override;cxx_range_for;cxx_raw_string_literals;cxx_reference_qualified_functions;cxx_right_angle_brackets;cxx_rvalue_references;cxx_sizeof_member;cxx_static_assert;cxx_strong_enums;cxx_thread_local;cxx_trailing_return_types;cxx_unicode_literals;cxx_uniform_initialization;cxx_unrestricted_unions;cxx_user_literals;cxx_variadic_macros;cxx_variadic_templates;cxx_std_14;cxx_aggregate_default_initializers;cxx_attribute_deprecated;cxx_binary_literals;cxx_contextual_conversions;cxx_decltype_auto;cxx_digit_separators;cxx_generic_lambdas;cxx_lambda_init_captures;cxx_relaxed_constexpr;cxx_return_type_deduction;cxx_variable_templates;cxx_std_17;cxx_std_20;cxx_std_23")
set(CMAKE_CXX98_COMPILE_FEATURES "cxx_std_98;cxx_template_template_parameters")
set(CMAKE_CXX11_COMPILE_FEATURES "cxx_std_11;cxx_alias_templates;cxx_alignas;cxx_alignof;cxx_attributes;cxx_auto_type;cxx_constexpr;cxx_decltype;cxx_decltype_incomplete_return_types;cxx_default_function_template_args;cxx_defaulted_functions;cxx_defaulted_move_initializers;cxx_delegating_constructors;cxx_deleted_functions;cxx_enum_forward_declarations;cxx_explicit_conversions;cxx_extended_friend_declarations;cxx_extern_templates;cxx_final;cxx_func_identifier;cxx_generalized_initializers;cxx_inheriting_constructors;cxx_inline_namespaces;cxx_lambdas;cxx_local_type_template_args;cxx_long_long_type;cxx_noexcept;cxx_nonstatic_member_init;cxx_nullptr;cxx_override;cxx_range_for;cxx_raw_string_literals;cxx_reference_qualified_functions;cxx_right_angle_brackets;cxx_rvalue_references;cxx_sizeof_member;cxx_static_assert;cxx_strong_enums;cxx_thread_local;cxx_trailing_return_types;cxx_unicode_literals;cxx_uniform_initialization;cxx_unrestricted_unions;cxx_user_literals;cxx_variadic_macros;cxx_variadic_templates")
set(CMAKE_CXX14_COMPILE_FEATURES "cxx_std_14;cxx_aggregate_default_initializers;cxx_attribute_deprecated;cxx_binary_literals;cxx_contextual_conversions;cxx_decltype_auto;cxx_digit_separators;cxx_generic_lambdas;cxx_lambda_init_captures;cxx_relaxed_constexpr;cxx_return_type_deduction;cxx_variable_templates")
set(CMAKE_CXX17_COMPILE_FEATURES "cxx_std_17")
set(CMAKE_CXX20_COMPILE_FEATURES "cxx_std_20")
set(CMAKE_CXX23_COMPILE_FEATURES "cxx_std_23")
set(CMAKE_CXX_PLATFORM_ID "Linux")
set(CMAKE_CXX_SIMULATE_ID "")
set(CMAKE_CXX_COMPILER_FRONTEND_VARIANT "GNU")
set(CMAKE_CXX_SIMULATE_VERSION "")
set(CMAKE_AR "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar")
set(CMAKE_CXX_COMPILER_AR "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar")
set(CMAKE_RANLIB "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ranlib")
set(CMAKE_CXX_COMPILER_RANLIB "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ranlib")
set(CMAKE_LINKER "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/ld.lld")
set(CMAKE_MT "")
set(CMAKE_COMPILER_IS_GNUCXX )
set(CMAKE_CXX_COMPILER_LOADED 1)
set(CMAKE_CXX_COMPILER_WORKS TRUE)
set(CMAKE_CXX_ABI_COMPILED TRUE)
set(CMAKE_CXX_COMPILER_ENV_VAR "CXX")
set(CMAKE_CXX_COMPILER_ID_RUN 1)
set(CMAKE_CXX_SOURCE_FILE_EXTENSIONS C;M;c++;cc;cpp;cxx;m;mm;mpp;CPP;ixx;cppm)
set(CMAKE_CXX_IGNORE_EXTENSIONS inl;h;hpp;HPP;H;o;O;obj;OBJ;def;DEF;rc;RC)
foreach (lang C OBJC OBJCXX)
if (CMAKE_${lang}_COMPILER_ID_RUN)
foreach(extension IN LISTS CMAKE_${lang}_SOURCE_FILE_EXTENSIONS)
list(REMOVE_ITEM CMAKE_CXX_SOURCE_FILE_EXTENSIONS ${extension})
endforeach()
endif()
endforeach()
set(CMAKE_CXX_LINKER_PREFERENCE 30)
set(CMAKE_CXX_LINKER_PREFERENCE_PROPAGATES 1)
# Save compiler ABI information.
set(CMAKE_CXX_SIZEOF_DATA_PTR "4")
set(CMAKE_CXX_COMPILER_ABI "ELF")
set(CMAKE_CXX_BYTE_ORDER "LITTLE_ENDIAN")
set(CMAKE_CXX_LIBRARY_ARCHITECTURE "")
if(CMAKE_CXX_SIZEOF_DATA_PTR)
set(CMAKE_SIZEOF_VOID_P "${CMAKE_CXX_SIZEOF_DATA_PTR}")
endif()
if(CMAKE_CXX_COMPILER_ABI)
set(CMAKE_INTERNAL_PLATFORM_ABI "${CMAKE_CXX_COMPILER_ABI}")
endif()
if(CMAKE_CXX_LIBRARY_ARCHITECTURE)
set(CMAKE_LIBRARY_ARCHITECTURE "")
endif()
set(CMAKE_CXX_CL_SHOWINCLUDES_PREFIX "")
if(CMAKE_CXX_CL_SHOWINCLUDES_PREFIX)
set(CMAKE_CL_SHOWINCLUDES_PREFIX "${CMAKE_CXX_CL_SHOWINCLUDES_PREFIX}")
endif()
set(CMAKE_CXX_IMPLICIT_INCLUDE_DIRECTORIES "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include/c++/v1;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/lib/clang/18/include;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include/i686-linux-android;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include")
set(CMAKE_CXX_IMPLICIT_LINK_LIBRARIES "c++;m;-l:libunwind.a;dl;c;-l:libunwind.a;dl")
set(CMAKE_CXX_IMPLICIT_LINK_DIRECTORIES "/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/lib/clang/18/lib/linux/i386;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/i686-linux-android/26;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/i686-linux-android;/home/echo/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib")
set(CMAKE_CXX_IMPLICIT_LINK_FRAMEWORK_DIRECTORIES "")
@@ -1,15 +0,0 @@
set(CMAKE_HOST_SYSTEM "Linux-6.17.8-arch1-1")
set(CMAKE_HOST_SYSTEM_NAME "Linux")
set(CMAKE_HOST_SYSTEM_VERSION "6.17.8-arch1-1")
set(CMAKE_HOST_SYSTEM_PROCESSOR "x86_64")
include("/home/echo/Android/Sdk/ndk/27.1.12297006/build/cmake/android.toolchain.cmake")
set(CMAKE_SYSTEM "Android-1")
set(CMAKE_SYSTEM_NAME "Android")
set(CMAKE_SYSTEM_VERSION "1")
set(CMAKE_SYSTEM_PROCESSOR "i686")
set(CMAKE_CROSSCOMPILING "TRUE")
set(CMAKE_SYSTEM_LOADED 1)
@@ -1,803 +0,0 @@
#ifdef __cplusplus
# error "A C++ compiler has been selected for C."
#endif
#if defined(__18CXX)
# define ID_VOID_MAIN
#endif
#if defined(__CLASSIC_C__)
/* cv-qualifiers did not exist in K&R C */
# define const
# define volatile
#endif
#if !defined(__has_include)
/* If the compiler does not have __has_include, pretend the answer is
always no. */
# define __has_include(x) 0
#endif
/* Version number components: V=Version, R=Revision, P=Patch
Version date components: YYYY=Year, MM=Month, DD=Day */
#if defined(__INTEL_COMPILER) || defined(__ICC)
# define COMPILER_ID "Intel"
# if defined(_MSC_VER)
# define SIMULATE_ID "MSVC"
# endif
# if defined(__GNUC__)
# define SIMULATE_ID "GNU"
# endif
/* __INTEL_COMPILER = VRP prior to 2021, and then VVVV for 2021 and later,
except that a few beta releases use the old format with V=2021. */
# if __INTEL_COMPILER < 2021 || __INTEL_COMPILER == 202110 || __INTEL_COMPILER == 202111
# define COMPILER_VERSION_MAJOR DEC(__INTEL_COMPILER/100)
# define COMPILER_VERSION_MINOR DEC(__INTEL_COMPILER/10 % 10)
# if defined(__INTEL_COMPILER_UPDATE)
# define COMPILER_VERSION_PATCH DEC(__INTEL_COMPILER_UPDATE)
# else
# define COMPILER_VERSION_PATCH DEC(__INTEL_COMPILER % 10)
# endif
# else
# define COMPILER_VERSION_MAJOR DEC(__INTEL_COMPILER)
# define COMPILER_VERSION_MINOR DEC(__INTEL_COMPILER_UPDATE)
/* The third version component from --version is an update index,
but no macro is provided for it. */
# define COMPILER_VERSION_PATCH DEC(0)
# endif
# if defined(__INTEL_COMPILER_BUILD_DATE)
/* __INTEL_COMPILER_BUILD_DATE = YYYYMMDD */
# define COMPILER_VERSION_TWEAK DEC(__INTEL_COMPILER_BUILD_DATE)
# endif
# if defined(_MSC_VER)
/* _MSC_VER = VVRR */
# define SIMULATE_VERSION_MAJOR DEC(_MSC_VER / 100)
# define SIMULATE_VERSION_MINOR DEC(_MSC_VER % 100)
# endif
# if defined(__GNUC__)
# define SIMULATE_VERSION_MAJOR DEC(__GNUC__)
# elif defined(__GNUG__)
# define SIMULATE_VERSION_MAJOR DEC(__GNUG__)
# endif
# if defined(__GNUC_MINOR__)
# define SIMULATE_VERSION_MINOR DEC(__GNUC_MINOR__)
# endif
# if defined(__GNUC_PATCHLEVEL__)
# define SIMULATE_VERSION_PATCH DEC(__GNUC_PATCHLEVEL__)
# endif
#elif (defined(__clang__) && defined(__INTEL_CLANG_COMPILER)) || defined(__INTEL_LLVM_COMPILER)
# define COMPILER_ID "IntelLLVM"
#if defined(_MSC_VER)
# define SIMULATE_ID "MSVC"
#endif
#if defined(__GNUC__)
# define SIMULATE_ID "GNU"
#endif
/* __INTEL_LLVM_COMPILER = VVVVRP prior to 2021.2.0, VVVVRRPP for 2021.2.0 and
* later. Look for 6 digit vs. 8 digit version number to decide encoding.
* VVVV is no smaller than the current year when a version is released.
*/
#if __INTEL_LLVM_COMPILER < 1000000L
# define COMPILER_VERSION_MAJOR DEC(__INTEL_LLVM_COMPILER/100)
# define COMPILER_VERSION_MINOR DEC(__INTEL_LLVM_COMPILER/10 % 10)
# define COMPILER_VERSION_PATCH DEC(__INTEL_LLVM_COMPILER % 10)
#else
# define COMPILER_VERSION_MAJOR DEC(__INTEL_LLVM_COMPILER/10000)
# define COMPILER_VERSION_MINOR DEC(__INTEL_LLVM_COMPILER/100 % 100)
# define COMPILER_VERSION_PATCH DEC(__INTEL_LLVM_COMPILER % 100)
#endif
#if defined(_MSC_VER)
/* _MSC_VER = VVRR */
# define SIMULATE_VERSION_MAJOR DEC(_MSC_VER / 100)
# define SIMULATE_VERSION_MINOR DEC(_MSC_VER % 100)
#endif
#if defined(__GNUC__)
# define SIMULATE_VERSION_MAJOR DEC(__GNUC__)
#elif defined(__GNUG__)
# define SIMULATE_VERSION_MAJOR DEC(__GNUG__)
#endif
#if defined(__GNUC_MINOR__)
# define SIMULATE_VERSION_MINOR DEC(__GNUC_MINOR__)
#endif
#if defined(__GNUC_PATCHLEVEL__)
# define SIMULATE_VERSION_PATCH DEC(__GNUC_PATCHLEVEL__)
#endif
#elif defined(__PATHCC__)
# define COMPILER_ID "PathScale"
# define COMPILER_VERSION_MAJOR DEC(__PATHCC__)
# define COMPILER_VERSION_MINOR DEC(__PATHCC_MINOR__)
# if defined(__PATHCC_PATCHLEVEL__)
# define COMPILER_VERSION_PATCH DEC(__PATHCC_PATCHLEVEL__)
# endif
#elif defined(__BORLANDC__) && defined(__CODEGEARC_VERSION__)
# define COMPILER_ID "Embarcadero"
# define COMPILER_VERSION_MAJOR HEX(__CODEGEARC_VERSION__>>24 & 0x00FF)
# define COMPILER_VERSION_MINOR HEX(__CODEGEARC_VERSION__>>16 & 0x00FF)
# define COMPILER_VERSION_PATCH DEC(__CODEGEARC_VERSION__ & 0xFFFF)
#elif defined(__BORLANDC__)
# define COMPILER_ID "Borland"
/* __BORLANDC__ = 0xVRR */
# define COMPILER_VERSION_MAJOR HEX(__BORLANDC__>>8)
# define COMPILER_VERSION_MINOR HEX(__BORLANDC__ & 0xFF)
#elif defined(__WATCOMC__) && __WATCOMC__ < 1200
# define COMPILER_ID "Watcom"
/* __WATCOMC__ = VVRR */
# define COMPILER_VERSION_MAJOR DEC(__WATCOMC__ / 100)
# define COMPILER_VERSION_MINOR DEC((__WATCOMC__ / 10) % 10)
# if (__WATCOMC__ % 10) > 0
# define COMPILER_VERSION_PATCH DEC(__WATCOMC__ % 10)
# endif
#elif defined(__WATCOMC__)
# define COMPILER_ID "OpenWatcom"
/* __WATCOMC__ = VVRP + 1100 */
# define COMPILER_VERSION_MAJOR DEC((__WATCOMC__ - 1100) / 100)
# define COMPILER_VERSION_MINOR DEC((__WATCOMC__ / 10) % 10)
# if (__WATCOMC__ % 10) > 0
# define COMPILER_VERSION_PATCH DEC(__WATCOMC__ % 10)
# endif
#elif defined(__SUNPRO_C)
# define COMPILER_ID "SunPro"
# if __SUNPRO_C >= 0x5100
/* __SUNPRO_C = 0xVRRP */
# define COMPILER_VERSION_MAJOR HEX(__SUNPRO_C>>12)
# define COMPILER_VERSION_MINOR HEX(__SUNPRO_C>>4 & 0xFF)
# define COMPILER_VERSION_PATCH HEX(__SUNPRO_C & 0xF)
# else
/* __SUNPRO_CC = 0xVRP */
# define COMPILER_VERSION_MAJOR HEX(__SUNPRO_C>>8)
# define COMPILER_VERSION_MINOR HEX(__SUNPRO_C>>4 & 0xF)
# define COMPILER_VERSION_PATCH HEX(__SUNPRO_C & 0xF)
# endif
#elif defined(__HP_cc)
# define COMPILER_ID "HP"
/* __HP_cc = VVRRPP */
# define COMPILER_VERSION_MAJOR DEC(__HP_cc/10000)
# define COMPILER_VERSION_MINOR DEC(__HP_cc/100 % 100)
# define COMPILER_VERSION_PATCH DEC(__HP_cc % 100)
#elif defined(__DECC)
# define COMPILER_ID "Compaq"
/* __DECC_VER = VVRRTPPPP */
# define COMPILER_VERSION_MAJOR DEC(__DECC_VER/10000000)
# define COMPILER_VERSION_MINOR DEC(__DECC_VER/100000 % 100)
# define COMPILER_VERSION_PATCH DEC(__DECC_VER % 10000)
#elif defined(__IBMC__) && defined(__COMPILER_VER__)
# define COMPILER_ID "zOS"
/* __IBMC__ = VRP */
# define COMPILER_VERSION_MAJOR DEC(__IBMC__/100)
# define COMPILER_VERSION_MINOR DEC(__IBMC__/10 % 10)
# define COMPILER_VERSION_PATCH DEC(__IBMC__ % 10)
#elif defined(__ibmxl__) && defined(__clang__)
# define COMPILER_ID "XLClang"
# define COMPILER_VERSION_MAJOR DEC(__ibmxl_version__)
# define COMPILER_VERSION_MINOR DEC(__ibmxl_release__)
# define COMPILER_VERSION_PATCH DEC(__ibmxl_modification__)
# define COMPILER_VERSION_TWEAK DEC(__ibmxl_ptf_fix_level__)
#elif defined(__IBMC__) && !defined(__COMPILER_VER__) && __IBMC__ >= 800
# define COMPILER_ID "XL"
/* __IBMC__ = VRP */
# define COMPILER_VERSION_MAJOR DEC(__IBMC__/100)
# define COMPILER_VERSION_MINOR DEC(__IBMC__/10 % 10)
# define COMPILER_VERSION_PATCH DEC(__IBMC__ % 10)
#elif defined(__IBMC__) && !defined(__COMPILER_VER__) && __IBMC__ < 800
# define COMPILER_ID "VisualAge"
/* __IBMC__ = VRP */
# define COMPILER_VERSION_MAJOR DEC(__IBMC__/100)
# define COMPILER_VERSION_MINOR DEC(__IBMC__/10 % 10)
# define COMPILER_VERSION_PATCH DEC(__IBMC__ % 10)
#elif defined(__NVCOMPILER)
# define COMPILER_ID "NVHPC"
# define COMPILER_VERSION_MAJOR DEC(__NVCOMPILER_MAJOR__)
# define COMPILER_VERSION_MINOR DEC(__NVCOMPILER_MINOR__)
# if defined(__NVCOMPILER_PATCHLEVEL__)
# define COMPILER_VERSION_PATCH DEC(__NVCOMPILER_PATCHLEVEL__)
# endif
#elif defined(__PGI)
# define COMPILER_ID "PGI"
# define COMPILER_VERSION_MAJOR DEC(__PGIC__)
# define COMPILER_VERSION_MINOR DEC(__PGIC_MINOR__)
# if defined(__PGIC_PATCHLEVEL__)
# define COMPILER_VERSION_PATCH DEC(__PGIC_PATCHLEVEL__)
# endif
#elif defined(_CRAYC)
# define COMPILER_ID "Cray"
# define COMPILER_VERSION_MAJOR DEC(_RELEASE_MAJOR)
# define COMPILER_VERSION_MINOR DEC(_RELEASE_MINOR)
#elif defined(__TI_COMPILER_VERSION__)
# define COMPILER_ID "TI"
/* __TI_COMPILER_VERSION__ = VVVRRRPPP */
# define COMPILER_VERSION_MAJOR DEC(__TI_COMPILER_VERSION__/1000000)
# define COMPILER_VERSION_MINOR DEC(__TI_COMPILER_VERSION__/1000 % 1000)
# define COMPILER_VERSION_PATCH DEC(__TI_COMPILER_VERSION__ % 1000)
#elif defined(__CLANG_FUJITSU)
# define COMPILER_ID "FujitsuClang"
# define COMPILER_VERSION_MAJOR DEC(__FCC_major__)
# define COMPILER_VERSION_MINOR DEC(__FCC_minor__)
# define COMPILER_VERSION_PATCH DEC(__FCC_patchlevel__)
# define COMPILER_VERSION_INTERNAL_STR __clang_version__
#elif defined(__FUJITSU)
# define COMPILER_ID "Fujitsu"
# if defined(__FCC_version__)
# define COMPILER_VERSION __FCC_version__
# elif defined(__FCC_major__)
# define COMPILER_VERSION_MAJOR DEC(__FCC_major__)
# define COMPILER_VERSION_MINOR DEC(__FCC_minor__)
# define COMPILER_VERSION_PATCH DEC(__FCC_patchlevel__)
# endif
# if defined(__fcc_version)
# define COMPILER_VERSION_INTERNAL DEC(__fcc_version)
# elif defined(__FCC_VERSION)
# define COMPILER_VERSION_INTERNAL DEC(__FCC_VERSION)
# endif
#elif defined(__ghs__)
# define COMPILER_ID "GHS"
/* __GHS_VERSION_NUMBER = VVVVRP */
# ifdef __GHS_VERSION_NUMBER
# define COMPILER_VERSION_MAJOR DEC(__GHS_VERSION_NUMBER / 100)
# define COMPILER_VERSION_MINOR DEC(__GHS_VERSION_NUMBER / 10 % 10)
# define COMPILER_VERSION_PATCH DEC(__GHS_VERSION_NUMBER % 10)
# endif
#elif defined(__TINYC__)
# define COMPILER_ID "TinyCC"
#elif defined(__BCC__)
# define COMPILER_ID "Bruce"
#elif defined(__SCO_VERSION__)
# define COMPILER_ID "SCO"
#elif defined(__ARMCC_VERSION) && !defined(__clang__)
# define COMPILER_ID "ARMCC"
#if __ARMCC_VERSION >= 1000000
/* __ARMCC_VERSION = VRRPPPP */
# define COMPILER_VERSION_MAJOR DEC(__ARMCC_VERSION/1000000)
# define COMPILER_VERSION_MINOR DEC(__ARMCC_VERSION/10000 % 100)
# define COMPILER_VERSION_PATCH DEC(__ARMCC_VERSION % 10000)
#else
/* __ARMCC_VERSION = VRPPPP */
# define COMPILER_VERSION_MAJOR DEC(__ARMCC_VERSION/100000)
# define COMPILER_VERSION_MINOR DEC(__ARMCC_VERSION/10000 % 10)
# define COMPILER_VERSION_PATCH DEC(__ARMCC_VERSION % 10000)
#endif
#elif defined(__clang__) && defined(__apple_build_version__)
# define COMPILER_ID "AppleClang"
# if defined(_MSC_VER)
# define SIMULATE_ID "MSVC"
# endif
# define COMPILER_VERSION_MAJOR DEC(__clang_major__)
# define COMPILER_VERSION_MINOR DEC(__clang_minor__)
# define COMPILER_VERSION_PATCH DEC(__clang_patchlevel__)
# if defined(_MSC_VER)
/* _MSC_VER = VVRR */
# define SIMULATE_VERSION_MAJOR DEC(_MSC_VER / 100)
# define SIMULATE_VERSION_MINOR DEC(_MSC_VER % 100)
# endif
# define COMPILER_VERSION_TWEAK DEC(__apple_build_version__)
#elif defined(__clang__) && defined(__ARMCOMPILER_VERSION)
# define COMPILER_ID "ARMClang"
# define COMPILER_VERSION_MAJOR DEC(__ARMCOMPILER_VERSION/1000000)
# define COMPILER_VERSION_MINOR DEC(__ARMCOMPILER_VERSION/10000 % 100)
# define COMPILER_VERSION_PATCH DEC(__ARMCOMPILER_VERSION % 10000)
# define COMPILER_VERSION_INTERNAL DEC(__ARMCOMPILER_VERSION)
#elif defined(__clang__)
# define COMPILER_ID "Clang"
# if defined(_MSC_VER)
# define SIMULATE_ID "MSVC"
# endif
# define COMPILER_VERSION_MAJOR DEC(__clang_major__)
# define COMPILER_VERSION_MINOR DEC(__clang_minor__)
# define COMPILER_VERSION_PATCH DEC(__clang_patchlevel__)
# if defined(_MSC_VER)
/* _MSC_VER = VVRR */
# define SIMULATE_VERSION_MAJOR DEC(_MSC_VER / 100)
# define SIMULATE_VERSION_MINOR DEC(_MSC_VER % 100)
# endif
#elif defined(__GNUC__)
# define COMPILER_ID "GNU"
# define COMPILER_VERSION_MAJOR DEC(__GNUC__)
# if defined(__GNUC_MINOR__)
# define COMPILER_VERSION_MINOR DEC(__GNUC_MINOR__)
# endif
# if defined(__GNUC_PATCHLEVEL__)
# define COMPILER_VERSION_PATCH DEC(__GNUC_PATCHLEVEL__)
# endif
#elif defined(_MSC_VER)
# define COMPILER_ID "MSVC"
/* _MSC_VER = VVRR */
# define COMPILER_VERSION_MAJOR DEC(_MSC_VER / 100)
# define COMPILER_VERSION_MINOR DEC(_MSC_VER % 100)
# if defined(_MSC_FULL_VER)
# if _MSC_VER >= 1400
/* _MSC_FULL_VER = VVRRPPPPP */
# define COMPILER_VERSION_PATCH DEC(_MSC_FULL_VER % 100000)
# else
/* _MSC_FULL_VER = VVRRPPPP */
# define COMPILER_VERSION_PATCH DEC(_MSC_FULL_VER % 10000)
# endif
# endif
# if defined(_MSC_BUILD)
# define COMPILER_VERSION_TWEAK DEC(_MSC_BUILD)
# endif
#elif defined(__VISUALDSPVERSION__) || defined(__ADSPBLACKFIN__) || defined(__ADSPTS__) || defined(__ADSP21000__)
# define COMPILER_ID "ADSP"
#if defined(__VISUALDSPVERSION__)
/* __VISUALDSPVERSION__ = 0xVVRRPP00 */
# define COMPILER_VERSION_MAJOR HEX(__VISUALDSPVERSION__>>24)
# define COMPILER_VERSION_MINOR HEX(__VISUALDSPVERSION__>>16 & 0xFF)
# define COMPILER_VERSION_PATCH HEX(__VISUALDSPVERSION__>>8 & 0xFF)
#endif
#elif defined(__IAR_SYSTEMS_ICC__) || defined(__IAR_SYSTEMS_ICC)
# define COMPILER_ID "IAR"
# if defined(__VER__) && defined(__ICCARM__)
# define COMPILER_VERSION_MAJOR DEC((__VER__) / 1000000)
# define COMPILER_VERSION_MINOR DEC(((__VER__) / 1000) % 1000)
# define COMPILER_VERSION_PATCH DEC((__VER__) % 1000)
# define COMPILER_VERSION_INTERNAL DEC(__IAR_SYSTEMS_ICC__)
# elif defined(__VER__) && (defined(__ICCAVR__) || defined(__ICCRX__) || defined(__ICCRH850__) || defined(__ICCRL78__) || defined(__ICC430__) || defined(__ICCRISCV__) || defined(__ICCV850__) || defined(__ICC8051__) || defined(__ICCSTM8__))
# define COMPILER_VERSION_MAJOR DEC((__VER__) / 100)
# define COMPILER_VERSION_MINOR DEC((__VER__) - (((__VER__) / 100)*100))
# define COMPILER_VERSION_PATCH DEC(__SUBVERSION__)
# define COMPILER_VERSION_INTERNAL DEC(__IAR_SYSTEMS_ICC__)
# endif
#elif defined(__SDCC_VERSION_MAJOR) || defined(SDCC)
# define COMPILER_ID "SDCC"
# if defined(__SDCC_VERSION_MAJOR)
# define COMPILER_VERSION_MAJOR DEC(__SDCC_VERSION_MAJOR)
# define COMPILER_VERSION_MINOR DEC(__SDCC_VERSION_MINOR)
# define COMPILER_VERSION_PATCH DEC(__SDCC_VERSION_PATCH)
# else
/* SDCC = VRP */
# define COMPILER_VERSION_MAJOR DEC(SDCC/100)
# define COMPILER_VERSION_MINOR DEC(SDCC/10 % 10)
# define COMPILER_VERSION_PATCH DEC(SDCC % 10)
# endif
/* These compilers are either not known or too old to define an
identification macro. Try to identify the platform and guess that
it is the native compiler. */
#elif defined(__hpux) || defined(__hpua)
# define COMPILER_ID "HP"
#else /* unknown compiler */
# define COMPILER_ID ""
#endif
/* Construct the string literal in pieces to prevent the source from
getting matched. Store it in a pointer rather than an array
because some compilers will just produce instructions to fill the
array rather than assigning a pointer to a static array. */
char const* info_compiler = "INFO" ":" "compiler[" COMPILER_ID "]";
#ifdef SIMULATE_ID
char const* info_simulate = "INFO" ":" "simulate[" SIMULATE_ID "]";
#endif
#ifdef __QNXNTO__
char const* qnxnto = "INFO" ":" "qnxnto[]";
#endif
#if defined(__CRAYXT_COMPUTE_LINUX_TARGET)
char const *info_cray = "INFO" ":" "compiler_wrapper[CrayPrgEnv]";
#endif
#define STRINGIFY_HELPER(X) #X
#define STRINGIFY(X) STRINGIFY_HELPER(X)
/* Identify known platforms by name. */
#if defined(__linux) || defined(__linux__) || defined(linux)
# define PLATFORM_ID "Linux"
#elif defined(__MSYS__)
# define PLATFORM_ID "MSYS"
#elif defined(__CYGWIN__)
# define PLATFORM_ID "Cygwin"
#elif defined(__MINGW32__)
# define PLATFORM_ID "MinGW"
#elif defined(__APPLE__)
# define PLATFORM_ID "Darwin"
#elif defined(_WIN32) || defined(__WIN32__) || defined(WIN32)
# define PLATFORM_ID "Windows"
#elif defined(__FreeBSD__) || defined(__FreeBSD)
# define PLATFORM_ID "FreeBSD"
#elif defined(__NetBSD__) || defined(__NetBSD)
# define PLATFORM_ID "NetBSD"
#elif defined(__OpenBSD__) || defined(__OPENBSD)
# define PLATFORM_ID "OpenBSD"
#elif defined(__sun) || defined(sun)
# define PLATFORM_ID "SunOS"
#elif defined(_AIX) || defined(__AIX) || defined(__AIX__) || defined(__aix) || defined(__aix__)
# define PLATFORM_ID "AIX"
#elif defined(__hpux) || defined(__hpux__)
# define PLATFORM_ID "HP-UX"
#elif defined(__HAIKU__)
# define PLATFORM_ID "Haiku"
#elif defined(__BeOS) || defined(__BEOS__) || defined(_BEOS)
# define PLATFORM_ID "BeOS"
#elif defined(__QNX__) || defined(__QNXNTO__)
# define PLATFORM_ID "QNX"
#elif defined(__tru64) || defined(_tru64) || defined(__TRU64__)
# define PLATFORM_ID "Tru64"
#elif defined(__riscos) || defined(__riscos__)
# define PLATFORM_ID "RISCos"
#elif defined(__sinix) || defined(__sinix__) || defined(__SINIX__)
# define PLATFORM_ID "SINIX"
#elif defined(__UNIX_SV__)
# define PLATFORM_ID "UNIX_SV"
#elif defined(__bsdos__)
# define PLATFORM_ID "BSDOS"
#elif defined(_MPRAS) || defined(MPRAS)
# define PLATFORM_ID "MP-RAS"
#elif defined(__osf) || defined(__osf__)
# define PLATFORM_ID "OSF1"
#elif defined(_SCO_SV) || defined(SCO_SV) || defined(sco_sv)
# define PLATFORM_ID "SCO_SV"
#elif defined(__ultrix) || defined(__ultrix__) || defined(_ULTRIX)
# define PLATFORM_ID "ULTRIX"
#elif defined(__XENIX__) || defined(_XENIX) || defined(XENIX)
# define PLATFORM_ID "Xenix"
#elif defined(__WATCOMC__)
# if defined(__LINUX__)
# define PLATFORM_ID "Linux"
# elif defined(__DOS__)
# define PLATFORM_ID "DOS"
# elif defined(__OS2__)
# define PLATFORM_ID "OS2"
# elif defined(__WINDOWS__)
# define PLATFORM_ID "Windows3x"
# elif defined(__VXWORKS__)
# define PLATFORM_ID "VxWorks"
# else /* unknown platform */
# define PLATFORM_ID
# endif
#elif defined(__INTEGRITY)
# if defined(INT_178B)
# define PLATFORM_ID "Integrity178"
# else /* regular Integrity */
# define PLATFORM_ID "Integrity"
# endif
#else /* unknown platform */
# define PLATFORM_ID
#endif
/* For windows compilers MSVC and Intel we can determine
the architecture of the compiler being used. This is because
the compilers do not have flags that can change the architecture,
but rather depend on which compiler is being used
*/
#if defined(_WIN32) && defined(_MSC_VER)
# if defined(_M_IA64)
# define ARCHITECTURE_ID "IA64"
# elif defined(_M_ARM64EC)
# define ARCHITECTURE_ID "ARM64EC"
# elif defined(_M_X64) || defined(_M_AMD64)
# define ARCHITECTURE_ID "x64"
# elif defined(_M_IX86)
# define ARCHITECTURE_ID "X86"
# elif defined(_M_ARM64)
# define ARCHITECTURE_ID "ARM64"
# elif defined(_M_ARM)
# if _M_ARM == 4
# define ARCHITECTURE_ID "ARMV4I"
# elif _M_ARM == 5
# define ARCHITECTURE_ID "ARMV5I"
# else
# define ARCHITECTURE_ID "ARMV" STRINGIFY(_M_ARM)
# endif
# elif defined(_M_MIPS)
# define ARCHITECTURE_ID "MIPS"
# elif defined(_M_SH)
# define ARCHITECTURE_ID "SHx"
# else /* unknown architecture */
# define ARCHITECTURE_ID ""
# endif
#elif defined(__WATCOMC__)
# if defined(_M_I86)
# define ARCHITECTURE_ID "I86"
# elif defined(_M_IX86)
# define ARCHITECTURE_ID "X86"
# else /* unknown architecture */
# define ARCHITECTURE_ID ""
# endif
#elif defined(__IAR_SYSTEMS_ICC__) || defined(__IAR_SYSTEMS_ICC)
# if defined(__ICCARM__)
# define ARCHITECTURE_ID "ARM"
# elif defined(__ICCRX__)
# define ARCHITECTURE_ID "RX"
# elif defined(__ICCRH850__)
# define ARCHITECTURE_ID "RH850"
# elif defined(__ICCRL78__)
# define ARCHITECTURE_ID "RL78"
# elif defined(__ICCRISCV__)
# define ARCHITECTURE_ID "RISCV"
# elif defined(__ICCAVR__)
# define ARCHITECTURE_ID "AVR"
# elif defined(__ICC430__)
# define ARCHITECTURE_ID "MSP430"
# elif defined(__ICCV850__)
# define ARCHITECTURE_ID "V850"
# elif defined(__ICC8051__)
# define ARCHITECTURE_ID "8051"
# elif defined(__ICCSTM8__)
# define ARCHITECTURE_ID "STM8"
# else /* unknown architecture */
# define ARCHITECTURE_ID ""
# endif
#elif defined(__ghs__)
# if defined(__PPC64__)
# define ARCHITECTURE_ID "PPC64"
# elif defined(__ppc__)
# define ARCHITECTURE_ID "PPC"
# elif defined(__ARM__)
# define ARCHITECTURE_ID "ARM"
# elif defined(__x86_64__)
# define ARCHITECTURE_ID "x64"
# elif defined(__i386__)
# define ARCHITECTURE_ID "X86"
# else /* unknown architecture */
# define ARCHITECTURE_ID ""
# endif
#elif defined(__TI_COMPILER_VERSION__)
# if defined(__TI_ARM__)
# define ARCHITECTURE_ID "ARM"
# elif defined(__MSP430__)
# define ARCHITECTURE_ID "MSP430"
# elif defined(__TMS320C28XX__)
# define ARCHITECTURE_ID "TMS320C28x"
# elif defined(__TMS320C6X__) || defined(_TMS320C6X)
# define ARCHITECTURE_ID "TMS320C6x"
# else /* unknown architecture */
# define ARCHITECTURE_ID ""
# endif
#else
# define ARCHITECTURE_ID
#endif
/* Convert integer to decimal digit literals. */
#define DEC(n) \
('0' + (((n) / 10000000)%10)), \
('0' + (((n) / 1000000)%10)), \
('0' + (((n) / 100000)%10)), \
('0' + (((n) / 10000)%10)), \
('0' + (((n) / 1000)%10)), \
('0' + (((n) / 100)%10)), \
('0' + (((n) / 10)%10)), \
('0' + ((n) % 10))
/* Convert integer to hex digit literals. */
#define HEX(n) \
('0' + ((n)>>28 & 0xF)), \
('0' + ((n)>>24 & 0xF)), \
('0' + ((n)>>20 & 0xF)), \
('0' + ((n)>>16 & 0xF)), \
('0' + ((n)>>12 & 0xF)), \
('0' + ((n)>>8 & 0xF)), \
('0' + ((n)>>4 & 0xF)), \
('0' + ((n) & 0xF))
/* Construct a string literal encoding the version number. */
#ifdef COMPILER_VERSION
char const* info_version = "INFO" ":" "compiler_version[" COMPILER_VERSION "]";
/* Construct a string literal encoding the version number components. */
#elif defined(COMPILER_VERSION_MAJOR)
char const info_version[] = {
'I', 'N', 'F', 'O', ':',
'c','o','m','p','i','l','e','r','_','v','e','r','s','i','o','n','[',
COMPILER_VERSION_MAJOR,
# ifdef COMPILER_VERSION_MINOR
'.', COMPILER_VERSION_MINOR,
# ifdef COMPILER_VERSION_PATCH
'.', COMPILER_VERSION_PATCH,
# ifdef COMPILER_VERSION_TWEAK
'.', COMPILER_VERSION_TWEAK,
# endif
# endif
# endif
']','\0'};
#endif
/* Construct a string literal encoding the internal version number. */
#ifdef COMPILER_VERSION_INTERNAL
char const info_version_internal[] = {
'I', 'N', 'F', 'O', ':',
'c','o','m','p','i','l','e','r','_','v','e','r','s','i','o','n','_',
'i','n','t','e','r','n','a','l','[',
COMPILER_VERSION_INTERNAL,']','\0'};
#elif defined(COMPILER_VERSION_INTERNAL_STR)
char const* info_version_internal = "INFO" ":" "compiler_version_internal[" COMPILER_VERSION_INTERNAL_STR "]";
#endif
/* Construct a string literal encoding the version number components. */
#ifdef SIMULATE_VERSION_MAJOR
char const info_simulate_version[] = {
'I', 'N', 'F', 'O', ':',
's','i','m','u','l','a','t','e','_','v','e','r','s','i','o','n','[',
SIMULATE_VERSION_MAJOR,
# ifdef SIMULATE_VERSION_MINOR
'.', SIMULATE_VERSION_MINOR,
# ifdef SIMULATE_VERSION_PATCH
'.', SIMULATE_VERSION_PATCH,
# ifdef SIMULATE_VERSION_TWEAK
'.', SIMULATE_VERSION_TWEAK,
# endif
# endif
# endif
']','\0'};
#endif
/* Construct the string literal in pieces to prevent the source from
getting matched. Store it in a pointer rather than an array
because some compilers will just produce instructions to fill the
array rather than assigning a pointer to a static array. */
char const* info_platform = "INFO" ":" "platform[" PLATFORM_ID "]";
char const* info_arch = "INFO" ":" "arch[" ARCHITECTURE_ID "]";
#if !defined(__STDC__) && !defined(__clang__)
# if defined(_MSC_VER) || defined(__ibmxl__) || defined(__IBMC__)
# define C_VERSION "90"
# else
# define C_VERSION
# endif
#elif __STDC_VERSION__ > 201710L
# define C_VERSION "23"
#elif __STDC_VERSION__ >= 201710L
# define C_VERSION "17"
#elif __STDC_VERSION__ >= 201000L
# define C_VERSION "11"
#elif __STDC_VERSION__ >= 199901L
# define C_VERSION "99"
#else
# define C_VERSION "90"
#endif
const char* info_language_standard_default =
"INFO" ":" "standard_default[" C_VERSION "]";
const char* info_language_extensions_default = "INFO" ":" "extensions_default["
/* !defined(_MSC_VER) to exclude Clang's MSVC compatibility mode. */
#if (defined(__clang__) || defined(__GNUC__) || \
defined(__TI_COMPILER_VERSION__)) && \
!defined(__STRICT_ANSI__) && !defined(_MSC_VER)
"ON"
#else
"OFF"
#endif
"]";
/*--------------------------------------------------------------------------*/
#ifdef ID_VOID_MAIN
void main() {}
#else
# if defined(__CLASSIC_C__)
int main(argc, argv) int argc; char *argv[];
# else
int main(int argc, char* argv[])
# endif
{
int require = 0;
require += info_compiler[argc];
require += info_platform[argc];
require += info_arch[argc];
#ifdef COMPILER_VERSION_MAJOR
require += info_version[argc];
#endif
#ifdef COMPILER_VERSION_INTERNAL
require += info_version_internal[argc];
#endif
#ifdef SIMULATE_ID
require += info_simulate[argc];
#endif
#ifdef SIMULATE_VERSION_MAJOR
require += info_simulate_version[argc];
#endif
#if defined(__CRAYXT_COMPUTE_LINUX_TARGET)
require += info_cray[argc];
#endif
require += info_language_standard_default[argc];
require += info_language_extensions_default[argc];
(void)argv;
return require;
}
#endif
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff. Show more