Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178ad3fa90 | ||
|
|
ef9f39e564 | ||
|
|
73218402f6 | ||
|
|
c877577fba | ||
|
|
e119f0923c | ||
|
|
a65b3cac08 | ||
|
|
0825bb3d65 | ||
|
|
6740dcb18f | ||
|
|
12d6c07186 | ||
|
|
5010a579d6 | ||
|
|
2cff8eafbd | ||
|
|
275248fc35 | ||
|
|
4c2e97b66d | ||
|
|
21afb085e3 | ||
|
|
ca64a100b6 | ||
|
|
3c3dfb6cd6 | ||
|
|
871f33bf5a | ||
|
|
c5dde63355 | ||
|
|
cd13333b52 | ||
|
|
a620921202 | ||
|
|
ed14c57749 | ||
|
|
7ada05da6a | ||
|
|
50ece15089 | ||
|
|
091cb5ba85 | ||
|
|
ebfd633a11 | ||
|
|
1f4800c055 | ||
|
|
0ddac10c59 | ||
|
|
573690ab02 | ||
|
|
efbfa58c10 | ||
|
|
7ad1e5133e | ||
|
|
9c3d3f5b72 | ||
|
|
ff9f3d582a | ||
|
|
60d7a7963d | ||
|
|
0eede3fa91 | ||
|
|
b1f84722af | ||
|
|
34e88bdde5 | ||
|
|
aa662a9b74 | ||
|
|
80110acbf7 | ||
|
|
c36cad9c54 | ||
|
|
2ecb8a3515 | ||
|
|
8275da687b | ||
|
|
10b58245f5 | ||
|
|
272c9b36dd | ||
|
|
e586662c19 | ||
|
|
06973ccfb2 | ||
|
|
74f0d0dbed | ||
|
|
1be2de05fa | ||
|
|
1be7dc2858 | ||
|
|
36f52b42c6 | ||
|
|
3e30fae173 | ||
|
|
d6a77fcd23 | ||
|
|
ffd3aabc55 | ||
|
|
8cac57ed67 | ||
|
|
d4fae890bb | ||
|
|
0817e8e72b | ||
|
|
b1f01208fa |
No files matched your search
@@ -14,6 +14,10 @@ RESEND_API_KEY=re_your_resend_api_key_here
|
||||
EMAIL_FROM=FitAI <noreply@yourdomain.com>
|
||||
EMAIL_REPLY_TO=support@yourdomain.com
|
||||
|
||||
# Admin App URL (for invitation redirects)
|
||||
# Set to your production URL in production
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Database (optional - defaults to ./fitai.db)
|
||||
DATABASE_PATH=./fitai.db
|
||||
|
||||
|
||||
Binary file not shown.
Generated
+531
-5
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
import { getAuthContext } from "@/lib/auth/context";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
/**
|
||||
* POST /api/invitations/[id]/resend
|
||||
*
|
||||
* Resend an invitation with same parameters
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: invitationId } = await params;
|
||||
const authContext = await getAuthContext();
|
||||
|
||||
// Fetch pending invitations to find the one being resent
|
||||
const client = await clerkClient();
|
||||
const invitationList = await client.invitations.getInvitationList({
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
// Find the invitation
|
||||
const invitation = invitationList.data.find(
|
||||
(inv) => inv.id === invitationId,
|
||||
);
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invitation not found or already processed" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
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 (createdBy !== userId && !canManageByRole) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Forbidden - You can only resend invitations you created or manage within your scope",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create new invitation with same parameters
|
||||
const role = metadata?.role;
|
||||
|
||||
// Determine redirect URL based on role
|
||||
const isStaffRole = ["admin", "trainer", "superAdmin"].includes(role);
|
||||
const redirectUrl = isStaffRole
|
||||
? `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/sign-up`
|
||||
: undefined;
|
||||
|
||||
const newInvitation = await client.invitations.createInvitation({
|
||||
emailAddress: invitation.emailAddress,
|
||||
publicMetadata: invitation.publicMetadata || undefined,
|
||||
redirectUrl,
|
||||
ignoreExisting: true,
|
||||
});
|
||||
|
||||
log.info("Invitation resent", {
|
||||
originalId: invitationId,
|
||||
newId: newInvitation.id,
|
||||
email: invitation.emailAddress,
|
||||
resentBy: userId,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
invitation: {
|
||||
id: newInvitation.id,
|
||||
email: newInvitation.emailAddress,
|
||||
status: newInvitation.status,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("POST /api/invitations/[id]/resend error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to resend invitation" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
import { getAuthContext } from "@/lib/auth/context";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
/**
|
||||
* DELETE /api/invitations/[id]
|
||||
*
|
||||
* Revoke a pending invitation (creator-only permission)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: invitationId } = await params;
|
||||
const authContext = await getAuthContext();
|
||||
|
||||
// Fetch pending invitations to find and verify the one being revoked
|
||||
const client = await clerkClient();
|
||||
const invitationList = await client.invitations.getInvitationList({
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
// Find the invitation
|
||||
const invitation = invitationList.data.find(
|
||||
(inv) => inv.id === invitationId,
|
||||
);
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invitation not found or already processed" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
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 (createdBy !== userId && !canManageByRole) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Forbidden - You can only cancel invitations you created or manage within your scope",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Revoke the invitation
|
||||
await client.invitations.revokeInvitation(invitationId);
|
||||
|
||||
log.info("Invitation revoked", {
|
||||
invitationId,
|
||||
revokedBy: userId,
|
||||
email: invitation.emailAddress,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: { success: true },
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("DELETE /api/invitations/[id] error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to revoke invitation" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
import { getAuthContext } from "@/lib/auth/context";
|
||||
import { getInvitableRoles, validateGymAccess } from "@/lib/auth/permissions";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
/**
|
||||
* GET /api/invitations
|
||||
*
|
||||
* Fetch pending invitations with gym and creator filtering.
|
||||
* Users can only see invitations they created, scoped to their gym access.
|
||||
*
|
||||
* Query params:
|
||||
* - gymId: Optional gym filter (superAdmin only)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get auth context
|
||||
const authContext = await getAuthContext();
|
||||
const { role, gymId: userGymId } = authContext;
|
||||
|
||||
// Get query params
|
||||
const { searchParams } = new URL(request.url);
|
||||
const gymIdParam = searchParams.get("gymId");
|
||||
|
||||
// Validate gym access
|
||||
const targetGymId = gymIdParam || undefined;
|
||||
const accessError = validateGymAccess(role, userGymId, targetGymId);
|
||||
if (accessError) return accessError;
|
||||
|
||||
// Fetch all pending invitations from Clerk
|
||||
const client = await clerkClient();
|
||||
const invitationList = await client.invitations.getInvitationList({
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
// Filter invitations based on:
|
||||
// 1. Creator (only show invitations created by current user)
|
||||
// 2. Gym (apply gym-scoping rules)
|
||||
const filteredInvitations = invitationList.data
|
||||
.filter((inv) => {
|
||||
const metadata = inv.publicMetadata as any;
|
||||
|
||||
// Creator filter: only show if user created it
|
||||
if (metadata?.createdBy !== userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Gym filter: SuperAdmin can see all, others only their gym
|
||||
if (role === "superAdmin") {
|
||||
// If gymId param provided, filter by it
|
||||
if (targetGymId && (metadata?.gymId ?? null) !== targetGymId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
// Non-superAdmins: must match their gym (normalize null/undefined)
|
||||
return (metadata?.gymId ?? null) === (userGymId ?? null);
|
||||
}
|
||||
})
|
||||
.map((inv) => ({
|
||||
id: inv.id,
|
||||
emailAddress: inv.emailAddress,
|
||||
publicMetadata: inv.publicMetadata,
|
||||
status: inv.status,
|
||||
url: inv.url,
|
||||
createdAt: inv.createdAt,
|
||||
updatedAt: inv.updatedAt,
|
||||
revoked: inv.revoked,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
data: { invitations: filteredInvitations },
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("GET /api/invitations error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch invitations" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/invitations
|
||||
@@ -47,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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -84,12 +84,29 @@ export async function POST(request: NextRequest) {
|
||||
if (data.sendInvitation && data.email) {
|
||||
try {
|
||||
const client = await clerkClient();
|
||||
|
||||
// Build publicMetadata with consistent field names
|
||||
const publicMetadata: Record<string, any> = {
|
||||
role: data.role,
|
||||
createdBy: userId,
|
||||
gymId: assignedGymId ?? null,
|
||||
};
|
||||
|
||||
// Determine redirect URL based on role
|
||||
// Staff (admin, trainer, superAdmin) → Admin web app
|
||||
// Clients → Mobile app (handled by Clerk's default)
|
||||
const isStaffRole = ["admin", "trainer", "superAdmin"].includes(
|
||||
data.role,
|
||||
);
|
||||
const redirectUrl = isStaffRole
|
||||
? `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/sign-up`
|
||||
: undefined; // Clients use default Clerk redirect
|
||||
|
||||
const invitation = await client.invitations.createInvitation({
|
||||
emailAddress: data.email,
|
||||
publicMetadata: {
|
||||
role: data.role,
|
||||
gymId: assignedGymId,
|
||||
},
|
||||
publicMetadata,
|
||||
redirectUrl,
|
||||
ignoreExisting: true, // Don't fail if invitation already exists
|
||||
});
|
||||
|
||||
log.info("Clerk invitation sent", {
|
||||
@@ -97,6 +114,7 @@ export async function POST(request: NextRequest) {
|
||||
role: data.role,
|
||||
gymId: assignedGymId,
|
||||
invitationId: invitation.id,
|
||||
redirectUrl: redirectUrl || "default (mobile app)",
|
||||
});
|
||||
|
||||
// Send custom invitation email (in addition to Clerk's)
|
||||
@@ -117,7 +135,22 @@ export async function POST(request: NextRequest) {
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
log.error("Failed to send Clerk invitation", error);
|
||||
|
||||
// Extract Clerk-specific error details
|
||||
const clerkError = error as any;
|
||||
|
||||
log.error("Failed to send Clerk invitation", error, {
|
||||
email: data.email,
|
||||
role: data.role,
|
||||
gymId: assignedGymId,
|
||||
publicMetadata: assignedGymId
|
||||
? { role: data.role, gymId: assignedGymId }
|
||||
: { role: data.role },
|
||||
clerkStatus: clerkError?.status,
|
||||
clerkErrors: clerkError?.errors,
|
||||
clerkTraceId: clerkError?.clerkTraceId,
|
||||
});
|
||||
|
||||
return errorResponse(`Failed to send invitation: ${errorMessage}`, {
|
||||
status: 500,
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -414,14 +432,15 @@ export async function PUT(request: NextRequest) {
|
||||
return forbiddenResponse(`Not authorized to assign role '${role}'`);
|
||||
}
|
||||
|
||||
// Authorization: trainers cannot reassign users to different gyms
|
||||
// Authorization: trainers and admins cannot reassign users to different gyms
|
||||
// Only superAdmins can reassign gyms
|
||||
if (
|
||||
requesterRole === "trainer" &&
|
||||
requesterRole !== "superAdmin" &&
|
||||
gymId !== undefined &&
|
||||
gymId !== existingUser.gymId
|
||||
) {
|
||||
return forbiddenResponse(
|
||||
"Trainers cannot reassign users to different gyms",
|
||||
"Only superAdmins can reassign users to different gyms",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -561,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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
useRevokeInvitation,
|
||||
useResendInvitation,
|
||||
type Invitation,
|
||||
} from "@/hooks/use-api";
|
||||
import { Copy, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
interface InvitationsGridProps {
|
||||
invitations: Invitation[];
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export function InvitationsGrid({
|
||||
invitations,
|
||||
onRefetch,
|
||||
}: InvitationsGridProps) {
|
||||
const revokeInvitation = useRevokeInvitation();
|
||||
const resendInvitation = useResendInvitation();
|
||||
const [actioningId, setActioningId] = useState<string | null>(null);
|
||||
|
||||
const handleCopyUrl = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success("Invitation link copied to clipboard");
|
||||
} catch (error) {
|
||||
toast.error("Failed to copy link");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (invitation: Invitation) => {
|
||||
if (!confirm(`Cancel invitation for ${invitation.emailAddress}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActioningId(invitation.id);
|
||||
try {
|
||||
await revokeInvitation.mutateAsync(invitation.id);
|
||||
toast.success("Invitation cancelled");
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
toast.error("Failed to cancel invitation");
|
||||
} finally {
|
||||
setActioningId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async (invitation: Invitation) => {
|
||||
setActioningId(invitation.id);
|
||||
try {
|
||||
await resendInvitation.mutateAsync(invitation.id);
|
||||
toast.success("Invitation resent");
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
toast.error("Failed to resend invitation");
|
||||
} finally {
|
||||
setActioningId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (invitations.length === 0) {
|
||||
return (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
No pending invitations
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{invitations.map((invitation) => {
|
||||
const role = invitation.publicMetadata?.role || "unknown";
|
||||
const createdDate = new Date(invitation.createdAt);
|
||||
const isActioning = actioningId === invitation.id;
|
||||
|
||||
return (
|
||||
<Card key={invitation.id} className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{invitation.emailAddress}</h3>
|
||||
<span className="text-xs px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
Pending
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Sent {formatDistanceToNow(createdDate, { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{invitation.url && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopyUrl(invitation.url!)}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy Link
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResend(invitation)}
|
||||
disabled={isActioning}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-1 ${isActioning ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Resend
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleRevoke(invitation)}
|
||||
disabled={isActioning}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { UserGrid, type User } from "@/components/users/UserGrid";
|
||||
import { InvitationsGrid } from "@/components/users/InvitationsGrid";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
useSendInvitation,
|
||||
useInvitations,
|
||||
} from "@/hooks/use-api";
|
||||
|
||||
interface UserManagementProps {
|
||||
@@ -23,6 +25,9 @@ interface UserManagementProps {
|
||||
export function UserManagement({ gymId }: UserManagementProps) {
|
||||
const { user } = useUser();
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [viewFilter, setViewFilter] = useState<"all" | "active" | "pending">(
|
||||
"all",
|
||||
);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -40,17 +45,28 @@ export function UserManagement({ gymId }: UserManagementProps) {
|
||||
|
||||
const {
|
||||
data: users = [],
|
||||
isLoading,
|
||||
refetch,
|
||||
isLoading: usersLoading,
|
||||
refetch: refetchUsers,
|
||||
} = useUsers({
|
||||
role: filter !== "all" ? filter : undefined,
|
||||
gymId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: invitations = [],
|
||||
isLoading: invitationsLoading,
|
||||
refetch: refetchInvitations,
|
||||
} = useInvitations(gymId);
|
||||
|
||||
const { data: gyms = [] } = useGyms();
|
||||
const updateUser = useUpdateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
const sendInvitation = useSendInvitation();
|
||||
|
||||
const isLoading =
|
||||
viewFilter === "pending" ? invitationsLoading : usersLoading;
|
||||
const refetch = viewFilter === "pending" ? refetchInvitations : refetchUsers;
|
||||
|
||||
const handleUserSelect = (user: User | null) => {
|
||||
setSelectedUser(user);
|
||||
};
|
||||
@@ -193,6 +209,19 @@ export function UserManagement({ gymId }: UserManagementProps) {
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold">User Management</h2>
|
||||
<div className="flex gap-2">
|
||||
{/* View Filter: All/Active/Pending */}
|
||||
<select
|
||||
value={viewFilter}
|
||||
onChange={(e) =>
|
||||
setViewFilter(e.target.value as "all" | "active" | "pending")
|
||||
}
|
||||
className="px-3 py-2 border rounded-md bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="all">All Users</option>
|
||||
<option value="active">Active Users</option>
|
||||
<option value="pending">Pending Invitations</option>
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant={filter === "all" ? "default" : "outline"}
|
||||
onClick={() => setFilter("all")}
|
||||
@@ -220,7 +249,7 @@ export function UserManagement({ gymId }: UserManagementProps) {
|
||||
variant={filter === "client" ? "default" : "outline"}
|
||||
onClick={() => setFilter("client")}
|
||||
>
|
||||
Clientsa
|
||||
Clients
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "trainer" ? "default" : "outline"}
|
||||
@@ -245,33 +274,48 @@ export function UserManagement({ gymId }: UserManagementProps) {
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {users.length} users
|
||||
{selectedUser && (
|
||||
<span className="ml-4 text-blue-600">
|
||||
Selected: {selectedUser.firstName} {selectedUser.lastName}
|
||||
</span>
|
||||
{viewFilter === "pending" ? (
|
||||
<>Showing {invitations.length} pending invitations</>
|
||||
) : (
|
||||
<>
|
||||
Showing {users.length} users
|
||||
{selectedUser && (
|
||||
<span className="ml-4 text-blue-600">
|
||||
Selected: {selectedUser.firstName} {selectedUser.lastName}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="default" onClick={handleRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="default" onClick={handleExport}>
|
||||
Export CSV
|
||||
</Button>
|
||||
{viewFilter !== "pending" && (
|
||||
<Button variant="default" onClick={handleExport}>
|
||||
Export CSV
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<UserGrid
|
||||
users={users}
|
||||
onUserSelect={(user) => handleUserSelect(user)}
|
||||
onEditUser={handleEditUser}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
loading={isLoading}
|
||||
/>
|
||||
{viewFilter === "pending" ? (
|
||||
<InvitationsGrid
|
||||
invitations={invitations}
|
||||
onRefetch={refetchInvitations}
|
||||
/>
|
||||
) : (
|
||||
<UserGrid
|
||||
users={users}
|
||||
onUserSelect={(user) => handleUserSelect(user)}
|
||||
onEditUser={handleEditUser}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
loading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -362,8 +406,12 @@ export function UserManagement({ gymId }: UserManagementProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Only superAdmins and admins can reassign gyms */}
|
||||
{user?.publicMetadata?.role !== "trainer" && (
|
||||
{/* Only superAdmins can reassign gyms */}
|
||||
{(() => {
|
||||
const currentRole = user?.publicMetadata?.role;
|
||||
console.log("Current user role for gym selector:", currentRole);
|
||||
return currentRole === "superAdmin";
|
||||
})() && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Gym</label>
|
||||
<select
|
||||
@@ -539,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>
|
||||
@@ -576,7 +624,10 @@ export function UserManagement({ gymId }: UserManagementProps) {
|
||||
<CreateUserModal
|
||||
open={createModalOpen}
|
||||
onOpenChange={setCreateModalOpen}
|
||||
onSuccess={() => refetch()}
|
||||
onSuccess={() => {
|
||||
refetchUsers();
|
||||
refetchInvitations();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,12 +55,29 @@ 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;
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
emailAddress: string;
|
||||
publicMetadata: {
|
||||
role?: string;
|
||||
gymId?: string;
|
||||
createdBy?: string;
|
||||
} | null;
|
||||
status: "pending" | "accepted" | "revoked" | "expired";
|
||||
url?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
revoked?: boolean;
|
||||
}
|
||||
|
||||
async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
@@ -339,13 +356,32 @@ export function useCheckOut() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvitations() {
|
||||
export function useInvitations(gymId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["invitations"],
|
||||
queryFn: () =>
|
||||
fetchApi<{ data: { invitations: unknown[] } }>("/api/invitations").then(
|
||||
queryKey: ["invitations", gymId],
|
||||
queryFn: () => {
|
||||
const url = gymId
|
||||
? `/api/invitations?gymId=${gymId}`
|
||||
: "/api/invitations";
|
||||
return fetchApi<{ data: { invitations: Invitation[] } }>(url).then(
|
||||
(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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -364,6 +400,37 @@ export function useSendInvitation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeInvitation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (invitationId: string) =>
|
||||
fetchApi<{ data: { success: boolean } }>(
|
||||
`/api/invitations/${invitationId}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["invitations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResendInvitation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (invitationId: string) =>
|
||||
fetchApi<{ data: { invitation: any } }>(
|
||||
`/api/invitations/${invitationId}/resend`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["invitations"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface AnalyticsData {
|
||||
userGrowth: { label: string; value: number }[];
|
||||
membershipDistribution: { label: string; value: number; color: string }[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -16,9 +16,12 @@ export const passwordSchema = z
|
||||
export const phoneSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => !val || /^\+?[1-9]\d{1,14}$/.test(val), {
|
||||
message: "Invalid phone number format",
|
||||
});
|
||||
.refine(
|
||||
(val) => !val || val.trim() === "" || /^\+?[1-9]\d{1,14}$/.test(val),
|
||||
{
|
||||
message: "Invalid phone number format",
|
||||
},
|
||||
);
|
||||
|
||||
export const dateTimeSchema = z.string().datetime("Invalid datetime format");
|
||||
|
||||
|
||||
@@ -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
|
||||
-27
@@ -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()
|
||||
|
||||
-9
@@ -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()
|
||||
-9
@@ -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()
|
||||
|
||||
-9
@@ -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()
|
||||
-9
@@ -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()
|
||||
|
||||
-9
@@ -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()
|
||||
-27
@@ -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()
|
||||
|
||||
-9
@@ -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()
|
||||
-9
@@ -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()
|
||||
|
||||
-9
@@ -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()
|
||||
-9
@@ -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()
|
||||
|
||||
-9
@@ -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()
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
-1439
File diff suppressed because it is too large.
Load diff
-851
@@ -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
|
||||
}
|
||||
}
|
||||
-60
@@ -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
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"backtraceGraph" :
|
||||
{
|
||||
"commands" : [],
|
||||
"files" : [],
|
||||
"nodes" : []
|
||||
},
|
||||
"installers" : [],
|
||||
"paths" :
|
||||
{
|
||||
"build" : ".",
|
||||
"source" : "."
|
||||
}
|
||||
}
|
||||
-92
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-226
@@ -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
|
||||
|
||||
-72
@@ -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 "")
|
||||
-83
@@ -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 "")
|
||||
BIN
Binary file not shown.
Loaded 100 of 289 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user