Merge branch 'dev' into feature/session-persistence-new

This commit is contained in:
2026-03-17 18:40:22 +05:30
257 changed files with 32250 additions and 1272 deletions

View File

@@ -6,6 +6,7 @@ COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY scripts ./scripts
ENV PORT=8080
EXPOSE 8080

View File

@@ -8,6 +8,7 @@
"name": "@krow/command-api",
"version": "0.1.0",
"dependencies": {
"@google-cloud/storage": "^7.19.0",
"express": "^4.21.2",
"firebase-admin": "^13.0.2",
"pg": "^8.16.3",
@@ -151,7 +152,6 @@
"resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz",
"integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"arrify": "^2.0.0",
"extend": "^3.0.2"
@@ -165,7 +165,6 @@
"resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz",
"integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=14.0.0"
}
@@ -175,7 +174,6 @@
"resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz",
"integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=14"
}
@@ -185,7 +183,6 @@
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz",
"integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@google-cloud/paginator": "^5.0.0",
"@google-cloud/projectify": "^4.0.0",
@@ -212,7 +209,6 @@
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"optional": true,
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -398,7 +394,6 @@
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 10"
}
@@ -407,8 +402,7 @@
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
"integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
@@ -447,7 +441,6 @@
"resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz",
"integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/caseless": "*",
"@types/node": "*",
@@ -459,15 +452,13 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"optional": true,
"dependencies": {
"event-target-shim": "^5.0.0"
},
@@ -534,7 +525,6 @@
"resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
"integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -551,7 +541,6 @@
"resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
"integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==",
"license": "MIT",
"optional": true,
"dependencies": {
"retry": "0.13.1"
}
@@ -560,7 +549,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/atomic-sleep": {
@@ -708,7 +696,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -783,7 +770,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -838,7 +824,6 @@
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"license": "MIT",
"optional": true,
"dependencies": {
"end-of-stream": "^1.4.1",
"inherits": "^2.0.3",
@@ -882,7 +867,6 @@
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"once": "^1.4.0"
}
@@ -921,7 +905,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -963,7 +946,6 @@
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6"
}
@@ -1053,7 +1035,6 @@
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"strnum": "^2.1.2"
},
@@ -1122,7 +1103,6 @@
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
"integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
"license": "MIT",
"optional": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -1381,7 +1361,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -1419,8 +1398,7 @@
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/http-errors": {
"version": "2.0.1",
@@ -1453,7 +1431,6 @@
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tootallnate/once": "2",
"agent-base": "6",
@@ -1468,7 +1445,6 @@
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"debug": "4"
},
@@ -1481,7 +1457,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"optional": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -1498,8 +1473,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
@@ -1822,7 +1796,6 @@
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"optional": true,
"bin": {
"mime": "cli.js"
},
@@ -1942,7 +1915,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -1953,7 +1925,6 @@
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
@@ -2273,7 +2244,6 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -2307,7 +2277,6 @@
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 4"
}
@@ -2317,7 +2286,6 @@
"resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz",
"integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/request": "^2.48.8",
"extend": "^3.0.2",
@@ -2541,7 +2509,6 @@
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
"integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==",
"license": "MIT",
"optional": true,
"dependencies": {
"stubs": "^3.0.0"
}
@@ -2550,15 +2517,13 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
@@ -2601,15 +2566,13 @@
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/stubs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz",
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/superagent": {
"version": "10.3.0",
@@ -2717,7 +2680,6 @@
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
"integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.0",
@@ -2734,7 +2696,6 @@
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"debug": "4"
},
@@ -2747,7 +2708,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"optional": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -2765,7 +2725,6 @@
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"optional": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
@@ -2778,8 +2737,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/teeny-request/node_modules/uuid": {
"version": "9.0.1",
@@ -2790,7 +2748,6 @@
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"optional": true,
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -2857,8 +2814,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
@@ -2952,7 +2908,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"devOptional": true,
"license": "ISC"
},
"node_modules/xtend": {
@@ -3014,7 +2969,6 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},

View File

@@ -9,9 +9,14 @@
"scripts": {
"start": "node src/server.js",
"test": "node --test",
"migrate:idempotency": "node scripts/migrate-idempotency.mjs"
"dispatch:notifications": "node scripts/dispatch-notifications.mjs",
"migrate:idempotency": "node scripts/migrate-idempotency.mjs",
"migrate:v2-schema": "node scripts/migrate-v2-schema.mjs",
"seed:v2-demo": "node scripts/seed-v2-demo-data.mjs",
"smoke:v2-live": "node scripts/live-smoke-v2.mjs"
},
"dependencies": {
"@google-cloud/storage": "^7.19.0",
"express": "^4.21.2",
"firebase-admin": "^13.0.2",
"pg": "^8.16.3",

View File

@@ -0,0 +1,14 @@
import { dispatchPendingNotifications } from '../src/services/notification-dispatcher.js';
import { closePool } from '../src/services/db.js';
try {
const summary = await dispatchPendingNotifications();
// eslint-disable-next-line no-console
console.log(JSON.stringify({ ok: true, summary }, null, 2));
} catch (error) {
// eslint-disable-next-line no-console
console.error(JSON.stringify({ ok: false, error: error?.message || String(error) }, null, 2));
process.exitCode = 1;
} finally {
await closePool();
}

View File

@@ -0,0 +1,348 @@
import assert from 'node:assert/strict';
import { V2DemoFixture as fixture } from './v2-demo-fixture.mjs';
const firebaseApiKey = process.env.FIREBASE_API_KEY || 'AIzaSyBqRtZPMGU-Sz5x5UnRrunKu5NSWYyPRn8';
const demoEmail = process.env.V2_SMOKE_EMAIL || fixture.users.businessOwner.email;
const demoPassword = process.env.V2_SMOKE_PASSWORD || 'Demo2026!';
const commandBaseUrl = process.env.COMMAND_API_BASE_URL || 'https://krow-command-api-v2-e3g6witsvq-uc.a.run.app';
const queryBaseUrl = process.env.QUERY_API_BASE_URL || 'https://krow-query-api-v2-e3g6witsvq-uc.a.run.app';
async function signInWithPassword() {
const response = await fetch(
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${firebaseApiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: demoEmail,
password: demoPassword,
returnSecureToken: true,
}),
}
);
const payload = await response.json();
if (!response.ok) {
throw new Error(`Firebase sign-in failed: ${JSON.stringify(payload)}`);
}
return {
idToken: payload.idToken,
localId: payload.localId,
};
}
async function apiCall(baseUrl, path, {
method = 'GET',
token,
idempotencyKey,
body,
expectedStatus = 200,
} = {}) {
const headers = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (idempotencyKey) {
headers['Idempotency-Key'] = idempotencyKey;
}
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (response.status !== expectedStatus) {
throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${text}`);
}
return payload;
}
function uniqueKey(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function logStep(step, payload) {
// eslint-disable-next-line no-console
console.log(`[live-smoke-v2] ${step}: ${JSON.stringify(payload)}`);
}
async function main() {
const auth = await signInWithPassword();
assert.equal(auth.localId, fixture.users.businessOwner.id);
logStep('auth.ok', { uid: auth.localId, email: demoEmail });
const listOrders = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/orders`,
{ token: auth.idToken }
);
assert.ok(Array.isArray(listOrders.items));
assert.ok(listOrders.items.some((item) => item.id === fixture.orders.open.id));
logStep('orders.list.ok', { count: listOrders.items.length });
const openOrderDetail = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/orders/${fixture.orders.open.id}`,
{ token: auth.idToken }
);
assert.equal(openOrderDetail.id, fixture.orders.open.id);
assert.equal(openOrderDetail.shifts[0].id, fixture.shifts.open.id);
logStep('orders.detail.ok', { orderId: openOrderDetail.id, shiftCount: openOrderDetail.shifts.length });
const favoriteResult = await apiCall(
commandBaseUrl,
`/commands/businesses/${fixture.business.id}/favorite-staff`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('favorite'),
body: {
tenantId: fixture.tenant.id,
staffId: fixture.staff.ana.id,
},
}
);
assert.equal(favoriteResult.staffId, fixture.staff.ana.id);
logStep('favorites.add.ok', favoriteResult);
const favoriteList = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/businesses/${fixture.business.id}/favorite-staff`,
{ token: auth.idToken }
);
assert.ok(favoriteList.items.some((item) => item.staffId === fixture.staff.ana.id));
logStep('favorites.list.ok', { count: favoriteList.items.length });
const reviewResult = await apiCall(
commandBaseUrl,
`/commands/assignments/${fixture.assignments.completedAna.id}/reviews`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('review'),
body: {
tenantId: fixture.tenant.id,
businessId: fixture.business.id,
staffId: fixture.staff.ana.id,
rating: 5,
reviewText: 'Live smoke review',
tags: ['smoke', 'reliable'],
},
}
);
assert.equal(reviewResult.staffId, fixture.staff.ana.id);
logStep('reviews.create.ok', reviewResult);
const reviewSummary = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/staff/${fixture.staff.ana.id}/review-summary`,
{ token: auth.idToken }
);
assert.equal(reviewSummary.staffId, fixture.staff.ana.id);
assert.ok(reviewSummary.ratingCount >= 1);
logStep('reviews.summary.ok', { ratingCount: reviewSummary.ratingCount, averageRating: reviewSummary.averageRating });
const assigned = await apiCall(
commandBaseUrl,
`/commands/shifts/${fixture.shifts.open.id}/assign-staff`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('assign'),
body: {
tenantId: fixture.tenant.id,
shiftRoleId: fixture.shiftRoles.openBarista.id,
workforceId: fixture.workforce.ana.id,
applicationId: fixture.applications.openAna.id,
},
}
);
assert.equal(assigned.shiftId, fixture.shifts.open.id);
logStep('assign.ok', assigned);
const accepted = await apiCall(
commandBaseUrl,
`/commands/shifts/${fixture.shifts.open.id}/accept`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('accept'),
body: {
shiftRoleId: fixture.shiftRoles.openBarista.id,
workforceId: fixture.workforce.ana.id,
},
}
);
assert.ok(['ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED'].includes(accepted.status));
const liveAssignmentId = accepted.assignmentId || assigned.assignmentId;
logStep('accept.ok', accepted);
const clockIn = await apiCall(
commandBaseUrl,
'/commands/attendance/clock-in',
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('clockin'),
body: {
assignmentId: liveAssignmentId,
sourceType: 'NFC',
sourceReference: 'smoke',
nfcTagUid: fixture.clockPoint.nfcTagUid,
deviceId: 'smoke-device',
latitude: fixture.clockPoint.latitude,
longitude: fixture.clockPoint.longitude,
accuracyMeters: 5,
},
}
);
assert.equal(clockIn.assignmentId, liveAssignmentId);
logStep('attendance.clockin.ok', clockIn);
const clockOut = await apiCall(
commandBaseUrl,
'/commands/attendance/clock-out',
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('clockout'),
body: {
assignmentId: liveAssignmentId,
sourceType: 'NFC',
sourceReference: 'smoke',
nfcTagUid: fixture.clockPoint.nfcTagUid,
deviceId: 'smoke-device',
latitude: fixture.clockPoint.latitude,
longitude: fixture.clockPoint.longitude,
accuracyMeters: 5,
},
}
);
assert.equal(clockOut.assignmentId, liveAssignmentId);
logStep('attendance.clockout.ok', clockOut);
const attendance = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/assignments/${liveAssignmentId}/attendance`,
{ token: auth.idToken }
);
assert.ok(Array.isArray(attendance.events));
assert.ok(attendance.events.length >= 2);
logStep('attendance.query.ok', { eventCount: attendance.events.length, sessionStatus: attendance.sessionStatus });
const orderNumber = `ORD-V2-SMOKE-${Date.now()}`;
const createdOrder = await apiCall(
commandBaseUrl,
'/commands/orders/create',
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('order-create'),
body: {
tenantId: fixture.tenant.id,
businessId: fixture.business.id,
vendorId: fixture.vendor.id,
orderNumber,
title: 'Smoke created order',
serviceType: 'EVENT',
shifts: [
{
shiftCode: `SHIFT-${Date.now()}`,
title: 'Smoke shift',
startsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
endsAt: new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(),
requiredWorkers: 1,
clockPointId: fixture.clockPoint.id,
roles: [
{
roleCode: fixture.roles.barista.code,
roleName: fixture.roles.barista.name,
workersNeeded: 1,
payRateCents: 2200,
billRateCents: 3500,
},
],
},
],
},
}
);
assert.equal(createdOrder.orderNumber, orderNumber);
logStep('orders.create.ok', createdOrder);
const updatedOrder = await apiCall(
commandBaseUrl,
`/commands/orders/${createdOrder.orderId}/update`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('order-update'),
body: {
tenantId: fixture.tenant.id,
title: 'Smoke updated order',
notes: 'updated during live smoke',
},
}
);
assert.equal(updatedOrder.orderId, createdOrder.orderId);
logStep('orders.update.ok', updatedOrder);
const changedShift = await apiCall(
commandBaseUrl,
`/commands/shifts/${createdOrder.shiftIds[0]}/change-status`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('shift-status'),
body: {
tenantId: fixture.tenant.id,
status: 'PENDING_CONFIRMATION',
reason: 'live smoke transition',
},
}
);
assert.equal(changedShift.status, 'PENDING_CONFIRMATION');
logStep('shift.status.ok', changedShift);
const cancelledOrder = await apiCall(
commandBaseUrl,
`/commands/orders/${createdOrder.orderId}/cancel`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('order-cancel'),
body: {
tenantId: fixture.tenant.id,
reason: 'live smoke cleanup',
},
}
);
assert.equal(cancelledOrder.status, 'CANCELLED');
logStep('orders.cancel.ok', cancelledOrder);
const cancelledOrderDetail = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/orders/${createdOrder.orderId}`,
{ token: auth.idToken }
);
assert.equal(cancelledOrderDetail.status, 'CANCELLED');
logStep('orders.cancel.verify.ok', { orderId: cancelledOrderDetail.id, status: cancelledOrderDetail.status });
// eslint-disable-next-line no-console
console.log('LIVE_SMOKE_V2_OK');
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});

View File

@@ -3,11 +3,11 @@ import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL;
const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL || process.env.DATABASE_URL;
if (!databaseUrl) {
// eslint-disable-next-line no-console
console.error('IDEMPOTENCY_DATABASE_URL is required');
console.error('IDEMPOTENCY_DATABASE_URL or DATABASE_URL is required');
process.exit(1);
}

View File

@@ -0,0 +1,69 @@
import { readdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
// eslint-disable-next-line no-console
console.error('DATABASE_URL is required');
process.exit(1);
}
const scriptDir = resolve(fileURLToPath(new URL('.', import.meta.url)));
const migrationsDir = resolve(scriptDir, '../sql/v2');
const migrationFiles = readdirSync(migrationsDir)
.filter((file) => file.endsWith('.sql'))
.sort();
const pool = new Pool({
connectionString: databaseUrl,
max: Number.parseInt(process.env.DB_POOL_MAX || '5', 10),
});
async function ensureMigrationTable(client) {
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
}
try {
const client = await pool.connect();
try {
await client.query('BEGIN');
await ensureMigrationTable(client);
for (const file of migrationFiles) {
const alreadyApplied = await client.query(
'SELECT 1 FROM schema_migrations WHERE version = $1',
[file]
);
if (alreadyApplied.rowCount > 0) {
continue;
}
const sql = readFileSync(resolve(migrationsDir, file), 'utf8');
await client.query(sql);
await client.query(
'INSERT INTO schema_migrations (version) VALUES ($1)',
[file]
);
// eslint-disable-next-line no-console
console.log(`Applied migration ${file}`);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} finally {
await pool.end();
}

View File

@@ -0,0 +1,965 @@
import { Pool } from 'pg';
import { resolveDatabasePoolConfig } from '../src/services/db.js';
import { V2DemoFixture as fixture } from './v2-demo-fixture.mjs';
const poolConfig = resolveDatabasePoolConfig();
if (!poolConfig) {
// eslint-disable-next-line no-console
console.error('Database connection settings are required');
process.exit(1);
}
const pool = new Pool(poolConfig);
function hoursFromNow(hours) {
return new Date(Date.now() + (hours * 60 * 60 * 1000)).toISOString();
}
async function upsertUser(client, user) {
await client.query(
`
INSERT INTO users (id, email, display_name, status, metadata)
VALUES ($1, $2, $3, 'ACTIVE', '{}'::jsonb)
ON CONFLICT (id) DO UPDATE
SET email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
status = 'ACTIVE',
updated_at = NOW()
`,
[user.id, user.email || null, user.displayName || null]
);
}
async function main() {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM tenants WHERE id = $1', [fixture.tenant.id]);
const openStartsAt = hoursFromNow(4);
const openEndsAt = hoursFromNow(12);
const completedStartsAt = hoursFromNow(-28);
const completedEndsAt = hoursFromNow(-20);
const checkedInAt = hoursFromNow(-27.5);
const checkedOutAt = hoursFromNow(-20.25);
const assignedStartsAt = hoursFromNow(0.1);
const assignedEndsAt = hoursFromNow(8.1);
const availableStartsAt = hoursFromNow(30);
const availableEndsAt = hoursFromNow(38);
const cancelledStartsAt = hoursFromNow(20);
const cancelledEndsAt = hoursFromNow(28);
const noShowStartsAt = hoursFromNow(-18);
const noShowEndsAt = hoursFromNow(-10);
const invoiceDueAt = hoursFromNow(72);
await upsertUser(client, fixture.users.businessOwner);
await upsertUser(client, fixture.users.operationsManager);
await upsertUser(client, fixture.users.vendorManager);
await upsertUser(client, fixture.users.staffAna);
await client.query(
`
INSERT INTO tenants (id, slug, name, status, metadata)
VALUES ($1, $2, $3, 'ACTIVE', $4::jsonb)
`,
[fixture.tenant.id, fixture.tenant.slug, fixture.tenant.name, JSON.stringify({ seededBy: 'seed-v2-demo-data' })]
);
await client.query(
`
INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata)
VALUES
($1, $2, 'ACTIVE', 'admin', '{"persona":"business_owner"}'::jsonb),
($1, $3, 'ACTIVE', 'manager', '{"persona":"ops_manager"}'::jsonb),
($1, $4, 'ACTIVE', 'manager', '{"persona":"vendor_manager"}'::jsonb),
($1, $5, 'ACTIVE', 'member', '{"persona":"staff"}'::jsonb)
`,
[
fixture.tenant.id,
fixture.users.businessOwner.id,
fixture.users.operationsManager.id,
fixture.users.vendorManager.id,
fixture.users.staffAna.id,
]
);
await client.query(
`
INSERT INTO businesses (
id, tenant_id, slug, business_name, status, contact_name, contact_email, contact_phone, metadata
)
VALUES ($1, $2, $3, $4, 'ACTIVE', $5, $6, $7, $8::jsonb)
`,
[
fixture.business.id,
fixture.tenant.id,
fixture.business.slug,
fixture.business.name,
'Legendary Client Manager',
fixture.users.businessOwner.email,
'+15550001001',
JSON.stringify({ segment: 'buyer', seeded: true }),
]
);
await client.query(
`
INSERT INTO business_memberships (
tenant_id, business_id, user_id, membership_status, business_role, metadata
)
VALUES
($1, $2, $3, 'ACTIVE', 'owner', '{"persona":"client_owner"}'::jsonb),
($1, $2, $4, 'ACTIVE', 'manager', '{"persona":"client_ops"}'::jsonb)
`,
[fixture.tenant.id, fixture.business.id, fixture.users.businessOwner.id, fixture.users.operationsManager.id]
);
await client.query(
`
INSERT INTO vendors (
id, tenant_id, slug, company_name, status, contact_name, contact_email, contact_phone, metadata
)
VALUES ($1, $2, $3, $4, 'ACTIVE', $5, $6, $7, $8::jsonb)
`,
[
fixture.vendor.id,
fixture.tenant.id,
fixture.vendor.slug,
fixture.vendor.name,
'Vendor Manager',
fixture.users.vendorManager.email,
'+15550001002',
JSON.stringify({ kind: 'internal_pool', seeded: true }),
]
);
await client.query(
`
INSERT INTO vendor_memberships (
tenant_id, vendor_id, user_id, membership_status, vendor_role, metadata
)
VALUES ($1, $2, $3, 'ACTIVE', 'owner', '{"persona":"vendor_owner"}'::jsonb)
`,
[fixture.tenant.id, fixture.vendor.id, fixture.users.vendorManager.id]
);
await client.query(
`
INSERT INTO cost_centers (id, tenant_id, business_id, code, name, status, metadata)
VALUES ($1, $2, $3, 'CAFE_OPS', $4, 'ACTIVE', '{"seeded":true}'::jsonb)
`,
[fixture.costCenters.cafeOps.id, fixture.tenant.id, fixture.business.id, fixture.costCenters.cafeOps.name]
);
await client.query(
`
INSERT INTO roles_catalog (id, tenant_id, code, name, status, metadata)
VALUES
($1, $3, $4, $5, 'ACTIVE', '{}'::jsonb),
($2, $3, $6, $7, 'ACTIVE', '{}'::jsonb)
`,
[
fixture.roles.barista.id,
fixture.roles.captain.id,
fixture.tenant.id,
fixture.roles.barista.code,
fixture.roles.barista.name,
fixture.roles.captain.code,
fixture.roles.captain.name,
]
);
await client.query(
`
INSERT INTO staffs (
id, tenant_id, user_id, full_name, email, phone, status, primary_role, onboarding_status,
average_rating, rating_count, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, 'ACTIVE', $7, 'COMPLETED', 4.50, 1, $8::jsonb)
`,
[
fixture.staff.ana.id,
fixture.tenant.id,
fixture.users.staffAna.id,
fixture.staff.ana.fullName,
fixture.staff.ana.email,
fixture.staff.ana.phone,
fixture.staff.ana.primaryRole,
JSON.stringify({
favoriteCandidate: true,
seeded: true,
firstName: 'Ana',
lastName: 'Barista',
bio: 'Experienced barista and event staffing professional.',
preferredLocations: [
{
city: 'Mountain View',
latitude: fixture.clockPoint.latitude,
longitude: fixture.clockPoint.longitude,
},
],
maxDistanceMiles: 20,
industries: ['CATERING', 'CAFE'],
skills: ['BARISTA', 'CUSTOMER_SERVICE'],
emergencyContact: {
name: 'Maria Barista',
phone: '+15550007777',
},
}),
]
);
await client.query(
`
INSERT INTO staff_roles (staff_id, role_id, is_primary)
VALUES ($1, $2, TRUE)
`,
[fixture.staff.ana.id, fixture.roles.barista.id]
);
await client.query(
`
INSERT INTO workforce (id, tenant_id, vendor_id, staff_id, workforce_number, employment_type, status, metadata)
VALUES ($1, $2, $3, $4, $5, 'TEMP', 'ACTIVE', $6::jsonb)
`,
[
fixture.workforce.ana.id,
fixture.tenant.id,
fixture.vendor.id,
fixture.staff.ana.id,
fixture.workforce.ana.workforceNumber,
JSON.stringify({ source: 'seed-v2-demo' }),
]
);
await client.query(
`
INSERT INTO staff_availability (
id, tenant_id, staff_id, day_of_week, availability_status, time_slots, metadata
)
VALUES
($1, $3, $4, 1, 'PARTIAL', '[{"start":"08:00","end":"18:00"}]'::jsonb, '{"seeded":true}'::jsonb),
($2, $3, $4, 5, 'PARTIAL', '[{"start":"09:00","end":"17:00"}]'::jsonb, '{"seeded":true}'::jsonb)
`,
[fixture.availability.monday.id, fixture.availability.friday.id, fixture.tenant.id, fixture.staff.ana.id]
);
await client.query(
`
INSERT INTO staff_benefits (
id, tenant_id, staff_id, benefit_type, title, status, tracked_hours, target_hours, metadata
)
VALUES ($1, $2, $3, 'COMMUTER', $4, 'ACTIVE', 32, 40, '{"description":"Commuter stipend unlocked after 40 hours"}'::jsonb)
`,
[fixture.benefits.commuter.id, fixture.tenant.id, fixture.staff.ana.id, fixture.benefits.commuter.title]
);
await client.query(
`
INSERT INTO emergency_contacts (
id, tenant_id, staff_id, full_name, phone, relationship_type, is_primary, metadata
)
VALUES ($1, $2, $3, 'Maria Barista', '+15550007777', 'SIBLING', TRUE, '{"seeded":true}'::jsonb)
`,
[fixture.emergencyContacts.primary.id, fixture.tenant.id, fixture.staff.ana.id]
);
await client.query(
`
INSERT INTO clock_points (
id, tenant_id, business_id, cost_center_id, label, address, latitude, longitude,
geofence_radius_meters, nfc_tag_uid, default_clock_in_mode, allow_clock_in_override, status, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'ACTIVE', $13::jsonb)
`,
[
fixture.clockPoint.id,
fixture.tenant.id,
fixture.business.id,
fixture.costCenters.cafeOps.id,
fixture.clockPoint.label,
fixture.clockPoint.address,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
fixture.clockPoint.geofenceRadiusMeters,
fixture.clockPoint.nfcTagUid,
fixture.clockPoint.defaultClockInMode,
fixture.clockPoint.allowClockInOverride,
JSON.stringify({ city: 'Mountain View', state: 'CA', zipCode: '94043', seeded: true }),
]
);
await client.query(
`
INSERT INTO hub_managers (id, tenant_id, hub_id, business_membership_id)
SELECT $1, $2, $3, bm.id
FROM business_memberships bm
WHERE bm.business_id = $4
AND bm.user_id = $5
`,
[
fixture.hubManagers.opsLead.id,
fixture.tenant.id,
fixture.clockPoint.id,
fixture.business.id,
fixture.users.operationsManager.id,
]
);
await client.query(
`
INSERT INTO orders (
id, tenant_id, business_id, vendor_id, order_number, title, description, status, service_type,
starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata
)
VALUES
($1, $3, $4, $5, $6, $7, 'Open order for live v2 commands', 'OPEN', 'EVENT', $8, $9, 'Google Cafe', $10, $11, $12, 'Use this order for live smoke and frontend reads', $13, '{"slice":"open","orderType":"ONE_TIME"}'::jsonb),
($2, $3, $4, $5, $14, $15, 'Completed order for favorites, reviews, invoices, and attendance history', 'COMPLETED', 'CATERING', $16, $17, 'Google Catering', $10, $11, $12, 'Completed historical example', $13, '{"slice":"completed","orderType":"ONE_TIME"}'::jsonb)
`,
[
fixture.orders.open.id,
fixture.orders.completed.id,
fixture.tenant.id,
fixture.business.id,
fixture.vendor.id,
fixture.orders.open.number,
fixture.orders.open.title,
openStartsAt,
openEndsAt,
fixture.clockPoint.address,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
fixture.users.businessOwner.id,
fixture.orders.completed.number,
fixture.orders.completed.title,
completedStartsAt,
completedEndsAt,
]
);
await client.query(
`
INSERT INTO orders (
id, tenant_id, business_id, vendor_id, order_number, title, description, status, service_type,
starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata
)
VALUES (
$1, $2, $3, $4, $5, $6, 'Active order used to populate assigned, available, cancelled, and no-show shift states',
'ACTIVE', 'RESTAURANT', $7, $8, 'Google Cafe', $9, $10, $11, 'Mixed state scenario order', $12,
'{"slice":"active","orderType":"ONE_TIME"}'::jsonb
)
`,
[
fixture.orders.active.id,
fixture.tenant.id,
fixture.business.id,
fixture.vendor.id,
fixture.orders.active.number,
fixture.orders.active.title,
assignedStartsAt,
availableEndsAt,
fixture.clockPoint.address,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
fixture.users.operationsManager.id,
]
);
await client.query(
`
INSERT INTO shifts (
id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone,
location_name, location_address, latitude, longitude, geofence_radius_meters, clock_in_mode, allow_clock_in_override,
required_workers, assigned_workers, notes, metadata
)
VALUES
($1, $3, $5, $7, $9, $11, $13, $15, 'OPEN', $17, $18, 'America/Los_Angeles', 'Google Cafe', $19, $21, $22, $23, NULL, NULL, 1, 0, 'Open staffing need', '{"slice":"open"}'::jsonb),
($2, $4, $6, $8, $10, $12, $14, $16, 'COMPLETED', $20, $24, 'America/Los_Angeles', 'Google Catering', $19, $21, $22, $23, NULL, NULL, 1, 1, 'Completed staffed shift', '{"slice":"completed"}'::jsonb)
`,
[
fixture.shifts.open.id,
fixture.shifts.completed.id,
fixture.tenant.id,
fixture.tenant.id,
fixture.orders.open.id,
fixture.orders.completed.id,
fixture.business.id,
fixture.business.id,
fixture.vendor.id,
fixture.vendor.id,
fixture.clockPoint.id,
fixture.clockPoint.id,
fixture.shifts.open.code,
fixture.shifts.completed.code,
fixture.shifts.open.title,
fixture.shifts.completed.title,
openStartsAt,
openEndsAt,
fixture.clockPoint.address,
completedStartsAt,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
fixture.clockPoint.geofenceRadiusMeters,
completedEndsAt,
]
);
await client.query(
`
INSERT INTO shifts (
id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone,
location_name, location_address, latitude, longitude, geofence_radius_meters, clock_in_mode, allow_clock_in_override,
required_workers, assigned_workers, notes, metadata
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, 'OPEN', $9, $10, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Available shift for staff marketplace', '{"slice":"available"}'::jsonb),
($15, $2, $3, $4, $5, $6, $16, $17, 'ASSIGNED', $18, $19, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, $30, $31, 1, 1, 'Assigned shift waiting for staff confirmation', '{"slice":"assigned"}'::jsonb),
($20, $2, $3, $4, $5, $6, $21, $22, 'CANCELLED', $23, $24, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::jsonb),
($25, $2, $3, $4, $5, $6, $26, $27, 'COMPLETED', $28, $29, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 'GEO_REQUIRED', TRUE, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb)
`,
[
fixture.shifts.available.id,
fixture.tenant.id,
fixture.orders.active.id,
fixture.business.id,
fixture.vendor.id,
fixture.clockPoint.id,
fixture.shifts.available.code,
fixture.shifts.available.title,
availableStartsAt,
availableEndsAt,
fixture.clockPoint.address,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
fixture.clockPoint.geofenceRadiusMeters,
fixture.shifts.assigned.id,
fixture.shifts.assigned.code,
fixture.shifts.assigned.title,
assignedStartsAt,
assignedEndsAt,
fixture.shifts.cancelled.id,
fixture.shifts.cancelled.code,
fixture.shifts.cancelled.title,
cancelledStartsAt,
cancelledEndsAt,
fixture.shifts.noShow.id,
fixture.shifts.noShow.code,
fixture.shifts.noShow.title,
noShowStartsAt,
noShowEndsAt,
fixture.shifts.assigned.clockInMode,
fixture.shifts.assigned.allowClockInOverride,
]
);
await client.query(
`
INSERT INTO shift_roles (
id, shift_id, role_id, role_code, role_name, workers_needed, assigned_count, pay_rate_cents, bill_rate_cents, metadata
)
VALUES
($1, $2, $3, $4, $5, 1, 0, 2200, 3500, '{"slice":"open"}'::jsonb),
($6, $7, $3, $4, $5, 1, 1, 2200, 3500, '{"slice":"completed"}'::jsonb)
`,
[
fixture.shiftRoles.openBarista.id,
fixture.shifts.open.id,
fixture.roles.barista.id,
fixture.roles.barista.code,
fixture.roles.barista.name,
fixture.shiftRoles.completedBarista.id,
fixture.shifts.completed.id,
]
);
await client.query(
`
INSERT INTO shift_roles (
id, shift_id, role_id, role_code, role_name, workers_needed, assigned_count, pay_rate_cents, bill_rate_cents, metadata
)
VALUES
($1, $2, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"available"}'::jsonb),
($3, $4, $7, $8, $9, 1, 1, 2300, 3600, '{"slice":"assigned"}'::jsonb),
($5, $6, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"cancelled"}'::jsonb),
($10, $11, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"no_show"}'::jsonb)
`,
[
fixture.shiftRoles.availableBarista.id,
fixture.shifts.available.id,
fixture.shiftRoles.assignedBarista.id,
fixture.shifts.assigned.id,
fixture.shiftRoles.cancelledBarista.id,
fixture.shifts.cancelled.id,
fixture.roles.barista.id,
fixture.roles.barista.code,
fixture.roles.barista.name,
fixture.shiftRoles.noShowBarista.id,
fixture.shifts.noShow.id,
]
);
await client.query(
`
INSERT INTO applications (
id, tenant_id, shift_id, shift_role_id, staff_id, status, origin, applied_at, metadata
)
VALUES ($1, $2, $3, $4, $5, 'PENDING', 'STAFF', NOW(), '{"slice":"open"}'::jsonb)
`,
[
fixture.applications.openAna.id,
fixture.tenant.id,
fixture.shifts.open.id,
fixture.shiftRoles.openBarista.id,
fixture.staff.ana.id,
]
);
await client.query(
`
INSERT INTO assignments (
id, tenant_id, business_id, vendor_id, shift_id, shift_role_id, workforce_id, staff_id, status,
assigned_at, accepted_at, checked_in_at, checked_out_at, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'COMPLETED', $9, $10, $11, $12, '{"slice":"completed"}'::jsonb)
`,
[
fixture.assignments.completedAna.id,
fixture.tenant.id,
fixture.business.id,
fixture.vendor.id,
fixture.shifts.completed.id,
fixture.shiftRoles.completedBarista.id,
fixture.workforce.ana.id,
fixture.staff.ana.id,
completedStartsAt,
completedStartsAt,
checkedInAt,
checkedOutAt,
]
);
await client.query(
`
INSERT INTO assignments (
id, tenant_id, business_id, vendor_id, shift_id, shift_role_id, workforce_id, staff_id, status,
assigned_at, accepted_at, checked_in_at, checked_out_at, metadata
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, 'ASSIGNED', NOW(), NULL, NULL, NULL, '{"slice":"assigned"}'::jsonb),
($9, $2, $3, $4, $10, $11, $7, $8, 'CANCELLED', NOW(), NULL, NULL, NULL, '{"slice":"cancelled","cancellationReason":"Client cancelled"}'::jsonb),
($12, $2, $3, $4, $13, $14, $7, $8, 'NO_SHOW', $15, NULL, NULL, NULL, '{"slice":"no_show"}'::jsonb)
`,
[
fixture.assignments.assignedAna.id,
fixture.tenant.id,
fixture.business.id,
fixture.vendor.id,
fixture.shifts.assigned.id,
fixture.shiftRoles.assignedBarista.id,
fixture.workforce.ana.id,
fixture.staff.ana.id,
fixture.assignments.cancelledAna.id,
fixture.shifts.cancelled.id,
fixture.shiftRoles.cancelledBarista.id,
fixture.assignments.noShowAna.id,
fixture.shifts.noShow.id,
fixture.shiftRoles.noShowBarista.id,
noShowStartsAt,
]
);
await client.query(
`
INSERT INTO attendance_events (
tenant_id, assignment_id, shift_id, staff_id, clock_point_id, event_type, source_type, source_reference,
nfc_tag_uid, device_id, latitude, longitude, accuracy_meters, distance_to_clock_point_meters, within_geofence,
validation_status, validation_reason, captured_at, raw_payload
)
VALUES
($1, $2, $3, $4, $5, 'CLOCK_IN', 'NFC', 'seed', $6, 'seed-device', $7, $8, 5, 0, TRUE, 'ACCEPTED', NULL, $9, '{"seeded":true}'::jsonb),
($1, $2, $3, $4, $5, 'CLOCK_OUT', 'NFC', 'seed', $6, 'seed-device', $7, $8, 5, 0, TRUE, 'ACCEPTED', NULL, $10, '{"seeded":true}'::jsonb)
`,
[
fixture.tenant.id,
fixture.assignments.completedAna.id,
fixture.shifts.completed.id,
fixture.staff.ana.id,
fixture.clockPoint.id,
fixture.clockPoint.nfcTagUid,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
checkedInAt,
checkedOutAt,
]
);
const attendanceEvents = await client.query(
`
SELECT id, event_type
FROM attendance_events
WHERE assignment_id = $1
ORDER BY captured_at ASC
`,
[fixture.assignments.completedAna.id]
);
await client.query(
`
INSERT INTO attendance_sessions (
id, tenant_id, assignment_id, staff_id, clock_in_event_id, clock_out_event_id, status,
check_in_at, check_out_at, worked_minutes, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, 'CLOSED', $7, $8, 435, '{"seeded":true}'::jsonb)
`,
[
'95f6017c-256c-4eb5-8033-eb942f018001',
fixture.tenant.id,
fixture.assignments.completedAna.id,
fixture.staff.ana.id,
attendanceEvents.rows.find((row) => row.event_type === 'CLOCK_IN')?.id,
attendanceEvents.rows.find((row) => row.event_type === 'CLOCK_OUT')?.id,
checkedInAt,
checkedOutAt,
]
);
await client.query(
`
INSERT INTO timesheets (
id, tenant_id, assignment_id, staff_id, status, regular_minutes, overtime_minutes, break_minutes, gross_pay_cents, metadata
)
VALUES ($1, $2, $3, $4, 'APPROVED', 420, 15, 30, 15950, '{"seeded":true}'::jsonb)
`,
[fixture.timesheets.completedAna.id, fixture.tenant.id, fixture.assignments.completedAna.id, fixture.staff.ana.id]
);
await client.query(
`
INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata)
VALUES
($1, $2, 'GOVERNMENT_ID', $3, $10, '{"seeded":true,"description":"State ID or passport","required":true}'::jsonb),
($4, $2, 'CERTIFICATION', $5, $10, '{"seeded":true}'::jsonb),
($6, $2, 'ATTIRE', $7, $10, '{"seeded":true,"description":"Upload a photo of your black shirt","required":true}'::jsonb),
($8, $2, 'TAX_FORM', $9, $10, '{"seeded":true}'::jsonb),
($11, $2, 'TAX_FORM', $12, $10, '{"seeded":true}'::jsonb)
`,
[
fixture.documents.governmentId.id,
fixture.tenant.id,
fixture.documents.governmentId.name,
fixture.documents.foodSafety.id,
fixture.documents.foodSafety.name,
fixture.documents.attireBlackShirt.id,
fixture.documents.attireBlackShirt.name,
fixture.documents.taxFormI9.id,
fixture.documents.taxFormI9.name,
fixture.roles.barista.code,
fixture.documents.taxFormW4.id,
fixture.documents.taxFormW4.name,
]
);
await client.query(
`
INSERT INTO staff_documents (
id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata
)
VALUES
($1, $2, $3, $4, $5, 'PENDING', $6, '{"seeded":true,"verificationStatus":"PENDING_REVIEW"}'::jsonb),
($7, $2, $3, $8, $9, 'VERIFIED', $10, '{"seeded":true,"verificationStatus":"APPROVED"}'::jsonb),
($11, $2, $3, $12, $13, 'VERIFIED', NULL, '{"seeded":true,"verificationStatus":"APPROVED"}'::jsonb),
($14, $2, $3, $15, $16, 'VERIFIED', NULL, '{"seeded":true,"formStatus":"SUBMITTED","fields":{"ssnLast4":"1234","filingStatus":"single"}}'::jsonb),
($17, $2, $3, $18, $19, 'PENDING', NULL, '{"seeded":true,"formStatus":"DRAFT","fields":{"section1Complete":true}}'::jsonb)
`,
[
fixture.staffDocuments.governmentId.id,
fixture.tenant.id,
fixture.staff.ana.id,
fixture.documents.governmentId.id,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/government-id-front.jpg`,
hoursFromNow(24 * 365),
fixture.staffDocuments.foodSafety.id,
fixture.documents.foodSafety.id,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
hoursFromNow(24 * 180),
fixture.staffDocuments.attireBlackShirt.id,
fixture.documents.attireBlackShirt.id,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/black-shirt.jpg`,
fixture.staffDocuments.taxFormW4.id,
fixture.documents.taxFormW4.id,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/w4-form.pdf`,
fixture.staffDocuments.taxFormI9.id,
fixture.documents.taxFormI9.id,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/i9-form.pdf`,
]
);
await client.query(
`
INSERT INTO certificates (
id, tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, file_uri, metadata
)
VALUES ($1, $2, $3, 'FOOD_SAFETY', 'FH-ANA-2026', $4, $5, 'VERIFIED', $6, $7::jsonb)
`,
[
fixture.certificates.foodSafety.id,
fixture.tenant.id,
fixture.staff.ana.id,
hoursFromNow(-24 * 30),
hoursFromNow(24 * 180),
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-safety-certificate.pdf`,
JSON.stringify({
seeded: true,
name: 'Food Safety Certificate',
issuer: 'ServSafe',
verificationStatus: 'APPROVED',
}),
]
);
await client.query(
`
INSERT INTO verification_jobs (
tenant_id, staff_id, document_id, type, file_uri, status, idempotency_key,
provider_name, provider_reference, confidence, reasons, extracted, review, metadata
)
VALUES (
$1, $2, $3, 'certification', $4, 'APPROVED', 'seed-certification-job',
'seed', 'seed-certification-provider', 0.980, '["Verified by seed"]'::jsonb,
'{"certificateType":"FOOD_SAFETY"}'::jsonb, '{"decision":"APPROVED"}'::jsonb, '{"seeded":true}'::jsonb
)
`,
[
fixture.tenant.id,
fixture.staff.ana.id,
fixture.documents.foodSafety.id,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
]
);
await client.query(
`
INSERT INTO accounts (
id, tenant_id, owner_type, owner_business_id, owner_vendor_id, owner_staff_id,
provider_name, provider_reference, last4, is_primary, metadata
)
VALUES
($1, $3, 'BUSINESS', $4, NULL, NULL, 'stripe', 'ba_business_demo', '6789', TRUE, '{"seeded":true,"accountType":"CHECKING","routingNumberMasked":"*****0001"}'::jsonb),
($2, $3, 'STAFF', NULL, NULL, $5, 'stripe', 'ba_staff_demo', '4321', TRUE, '{"seeded":true,"accountType":"CHECKING","routingNumberMasked":"*****0002"}'::jsonb)
`,
[
fixture.accounts.businessPrimary.id,
fixture.accounts.staffPrimary.id,
fixture.tenant.id,
fixture.business.id,
fixture.staff.ana.id,
]
);
await client.query(
`
INSERT INTO invoices (
id, tenant_id, order_id, business_id, vendor_id, invoice_number, status, currency_code,
subtotal_cents, tax_cents, total_cents, due_at, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, 'PENDING_REVIEW', 'USD', 15250, 700, 15950, $7, '{"seeded":true,"savingsCents":1250}'::jsonb)
`,
[
fixture.invoices.completed.id,
fixture.tenant.id,
fixture.orders.completed.id,
fixture.business.id,
fixture.vendor.id,
fixture.invoices.completed.number,
invoiceDueAt,
]
);
await client.query(
`
INSERT INTO recent_payments (
id, tenant_id, invoice_id, assignment_id, staff_id, status, amount_cents, process_date, metadata
)
VALUES ($1, $2, $3, $4, $5, 'PENDING', 15950, NULL, '{"seeded":true}'::jsonb)
`,
[
fixture.recentPayments.completed.id,
fixture.tenant.id,
fixture.invoices.completed.id,
fixture.assignments.completedAna.id,
fixture.staff.ana.id,
]
);
await client.query(
`
INSERT INTO staff_favorites (id, tenant_id, business_id, staff_id, created_by_user_id, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
`,
[
fixture.favorites.ana.id,
fixture.tenant.id,
fixture.business.id,
fixture.staff.ana.id,
fixture.users.businessOwner.id,
]
);
await client.query(
`
INSERT INTO staff_reviews (
id, tenant_id, business_id, staff_id, assignment_id, reviewer_user_id, rating, review_text, tags, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, 5, 'Reliable, on time, and client friendly.', '["reliable","favorite"]'::jsonb, NOW(), NOW())
`,
[
fixture.reviews.anaCompleted.id,
fixture.tenant.id,
fixture.business.id,
fixture.staff.ana.id,
fixture.assignments.completedAna.id,
fixture.users.businessOwner.id,
]
);
await client.query(
`
INSERT INTO domain_events (tenant_id, aggregate_type, aggregate_id, sequence, event_type, actor_user_id, payload)
VALUES
($1, 'order', $2, 1, 'ORDER_CREATED', $3, '{"seeded":true}'::jsonb),
($1, 'assignment', $4, 1, 'STAFF_ASSIGNED', $3, '{"seeded":true}'::jsonb)
`,
[
fixture.tenant.id,
fixture.orders.completed.id,
fixture.users.businessOwner.id,
fixture.assignments.completedAna.id,
]
);
await client.query(
`
INSERT INTO location_stream_batches (
id, tenant_id, business_id, vendor_id, shift_id, assignment_id, staff_id, actor_user_id,
source_type, device_id, object_uri, point_count, out_of_geofence_count, missing_coordinate_count,
max_distance_to_clock_point_meters, started_at, ended_at, metadata
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, 'GEO', 'seed-device',
$9, 4, 2, 0, 910, $10, $11, '{"seeded":true,"source":"seed-v2-demo"}'::jsonb
)
`,
[
fixture.locationStreamBatches.noShowSample.id,
fixture.tenant.id,
fixture.business.id,
fixture.vendor.id,
fixture.shifts.noShow.id,
fixture.assignments.noShowAna.id,
fixture.staff.ana.id,
fixture.users.staffAna.id,
`gs://krow-workforce-dev-v2-private/location-streams/${fixture.tenant.id}/${fixture.staff.ana.id}/${fixture.assignments.noShowAna.id}/${fixture.locationStreamBatches.noShowSample.id}.json`,
hoursFromNow(-18.25),
hoursFromNow(-17.75),
]
);
await client.query(
`
INSERT INTO geofence_incidents (
id, tenant_id, business_id, vendor_id, shift_id, assignment_id, staff_id, actor_user_id, location_stream_batch_id,
incident_type, severity, status, effective_clock_in_mode, source_type, device_id,
latitude, longitude, accuracy_meters, distance_to_clock_point_meters, within_geofence,
override_reason, message, occurred_at, metadata
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
'OUTSIDE_GEOFENCE', 'CRITICAL', 'OPEN', 'GEO_REQUIRED', 'GEO', 'seed-device',
$10, $11, 12, 910, FALSE, NULL, 'Worker drifted outside hub geofence during active monitoring',
$12, '{"seeded":true,"outOfGeofenceCount":2}'::jsonb
)
`,
[
fixture.geofenceIncidents.noShowOutsideGeofence.id,
fixture.tenant.id,
fixture.business.id,
fixture.vendor.id,
fixture.shifts.noShow.id,
fixture.assignments.noShowAna.id,
fixture.staff.ana.id,
fixture.users.staffAna.id,
fixture.locationStreamBatches.noShowSample.id,
fixture.clockPoint.latitude + 0.0065,
fixture.clockPoint.longitude + 0.0065,
hoursFromNow(-17.9),
]
);
await client.query(
`
INSERT INTO notification_outbox (
id, tenant_id, business_id, shift_id, assignment_id, related_incident_id, audience_type,
recipient_user_id, recipient_business_membership_id, channel, notification_type, priority, dedupe_key,
subject, body, payload, status, scheduled_at, created_at, updated_at
)
SELECT
$1, $2, $3, $4, $5, $6, 'USER',
bm.user_id, bm.id, 'PUSH', 'GEOFENCE_BREACH_ALERT', 'CRITICAL', $7,
'Worker left the workplace geofence',
'Seeded alert for coverage incident review',
jsonb_build_object('seeded', TRUE, 'batchId', $8::text),
'PENDING', NOW(), NOW(), NOW()
FROM business_memberships bm
WHERE bm.tenant_id = $2
AND bm.business_id = $3
AND bm.user_id = $9
`,
[
fixture.notificationOutbox.noShowManagerAlert.id,
fixture.tenant.id,
fixture.business.id,
fixture.shifts.noShow.id,
fixture.assignments.noShowAna.id,
fixture.geofenceIncidents.noShowOutsideGeofence.id,
`seed-geofence-breach:${fixture.geofenceIncidents.noShowOutsideGeofence.id}:${fixture.users.operationsManager.id}`,
fixture.locationStreamBatches.noShowSample.id,
fixture.users.operationsManager.id,
]
);
await client.query('COMMIT');
// eslint-disable-next-line no-console
console.log(JSON.stringify({
tenantId: fixture.tenant.id,
businessId: fixture.business.id,
vendorId: fixture.vendor.id,
staffId: fixture.staff.ana.id,
staffUserId: fixture.users.staffAna.id,
workforceId: fixture.workforce.ana.id,
openOrderId: fixture.orders.open.id,
openShiftId: fixture.shifts.open.id,
openShiftRoleId: fixture.shiftRoles.openBarista.id,
openApplicationId: fixture.applications.openAna.id,
completedOrderId: fixture.orders.completed.id,
completedAssignmentId: fixture.assignments.completedAna.id,
clockPointId: fixture.clockPoint.id,
nfcTagUid: fixture.clockPoint.nfcTagUid,
businessOwnerUid: fixture.users.businessOwner.id,
}, null, 2));
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
await pool.end();
}
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,290 @@
export const V2DemoFixture = {
tenant: {
id: '6d5fa42c-1f38-49be-8895-8aeb0e731001',
slug: 'legendary-event-staffing',
name: 'Legendary Event Staffing and Entertainment',
},
users: {
businessOwner: {
id: process.env.V2_DEMO_OWNER_UID || 'alFf9mYw3uYbm7ZjeLo1KoTgFxq2',
email: process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com',
displayName: 'Legendary Demo Owner',
},
operationsManager: {
id: 'demo-ops-manager',
email: 'ops+v2@krowd.com',
displayName: 'Wil Ops Lead',
},
vendorManager: {
id: 'demo-vendor-manager',
email: 'vendor+v2@krowd.com',
displayName: 'Vendor Manager',
},
staffAna: {
id: process.env.V2_DEMO_STAFF_UID || 'vwptrLl5S2Z598WP93cgrQEzqBg1',
email: process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com',
displayName: 'Ana Barista',
},
},
business: {
id: '14f4fcfb-f21f-4ba9-9328-90f794a56001',
slug: 'google-mv-cafes',
name: 'Google Mountain View Cafes',
},
vendor: {
id: '80f8c8d3-9da8-4892-908f-4d4982af7001',
slug: 'legendary-pool-a',
name: 'Legendary Staffing Pool A',
},
costCenters: {
cafeOps: {
id: '31db54dd-9b32-4504-9056-9c71a9f73001',
name: 'Cafe Operations',
},
},
roles: {
barista: {
id: '67c5010e-85f0-4f6b-99b7-167c9afdf001',
code: 'BARISTA',
name: 'Barista',
},
captain: {
id: '67c5010e-85f0-4f6b-99b7-167c9afdf002',
code: 'CAPTAIN',
name: 'Captain',
},
},
staff: {
ana: {
id: '4b7dff1a-1856-4d59-b450-5a6736461001',
fullName: 'Ana Barista',
email: 'ana.barista+v2@krowd.com',
phone: '+15557654321',
primaryRole: 'BARISTA',
},
},
workforce: {
ana: {
id: '4cc1d34a-87c3-4426-8ee0-a24c8bcfa001',
workforceNumber: 'WF-V2-ANA-001',
},
},
clockPoint: {
id: 'efb80ccf-3361-49c8-bc74-ff8cd4d2e001',
label: 'Google MV Cafe Clock Point',
address: '1600 Amphitheatre Pkwy, Mountain View, CA',
latitude: 37.4221,
longitude: -122.0841,
geofenceRadiusMeters: 120,
nfcTagUid: 'NFC-DEMO-ANA-001',
defaultClockInMode: 'GEO_REQUIRED',
allowClockInOverride: true,
},
hubManagers: {
opsLead: {
id: '3f2dfd17-e6b4-4fe4-9fea-3c91c7ca8001',
},
},
availability: {
monday: {
id: '887bc357-c3e0-4b2c-a174-bf27d6902001',
},
friday: {
id: '887bc357-c3e0-4b2c-a174-bf27d6902002',
},
},
benefits: {
commuter: {
id: 'dbd28438-66b0-452f-a5fc-dd0f3ea61001',
title: 'Commuter Support',
},
},
orders: {
open: {
id: 'b6132d7a-45c3-4879-b349-46b2fd518001',
number: 'ORD-V2-OPEN-1001',
title: 'Morning cafe staffing',
},
completed: {
id: 'b6132d7a-45c3-4879-b349-46b2fd518002',
number: 'ORD-V2-COMP-1002',
title: 'Completed catering shift',
},
active: {
id: 'b6132d7a-45c3-4879-b349-46b2fd518003',
number: 'ORD-V2-ACT-1003',
title: 'Live staffing operations',
},
},
shifts: {
open: {
id: '6e7dadad-99e4-45bb-b0da-7bb617954001',
code: 'SHIFT-V2-OPEN-1',
title: 'Open breakfast shift',
},
completed: {
id: '6e7dadad-99e4-45bb-b0da-7bb617954002',
code: 'SHIFT-V2-COMP-1',
title: 'Completed catering shift',
},
available: {
id: '6e7dadad-99e4-45bb-b0da-7bb617954003',
code: 'SHIFT-V2-OPEN-2',
title: 'Available lunch shift',
},
assigned: {
id: '6e7dadad-99e4-45bb-b0da-7bb617954004',
code: 'SHIFT-V2-ASSIGNED-1',
title: 'Assigned espresso shift',
clockInMode: 'GEO_REQUIRED',
allowClockInOverride: true,
},
cancelled: {
id: '6e7dadad-99e4-45bb-b0da-7bb617954005',
code: 'SHIFT-V2-CANCELLED-1',
title: 'Cancelled hospitality shift',
},
noShow: {
id: '6e7dadad-99e4-45bb-b0da-7bb617954006',
code: 'SHIFT-V2-NOSHOW-1',
title: 'No-show breakfast shift',
},
},
shiftRoles: {
openBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b001',
},
completedBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b002',
},
availableBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b003',
},
assignedBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b004',
},
cancelledBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b005',
},
noShowBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b006',
},
},
applications: {
openAna: {
id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34001',
},
},
assignments: {
completedAna: {
id: 'f1d3f738-a132-4863-b222-4f9cb25aa001',
},
assignedAna: {
id: 'f1d3f738-a132-4863-b222-4f9cb25aa002',
},
cancelledAna: {
id: 'f1d3f738-a132-4863-b222-4f9cb25aa003',
},
noShowAna: {
id: 'f1d3f738-a132-4863-b222-4f9cb25aa004',
},
},
timesheets: {
completedAna: {
id: '41ea4057-0c55-4907-b525-07315b2b6001',
},
},
invoices: {
completed: {
id: '1455e15b-77f9-4c66-b2a8-dce35f7ac001',
number: 'INV-V2-2001',
},
},
recentPayments: {
completed: {
id: 'be6f736b-e945-4676-a73d-2912c7575001',
},
},
favorites: {
ana: {
id: 'ba5cb8fa-0be9-4ef4-a9fb-e60a8a48e001',
},
},
reviews: {
anaCompleted: {
id: '9b6bc737-fd69-4855-b425-6f0c2c4fd001',
},
},
documents: {
governmentId: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995000',
name: 'Government ID',
},
foodSafety: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001',
name: 'Food Handler Card',
},
attireBlackShirt: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995002',
name: 'Black Shirt',
},
taxFormI9: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995003',
name: 'I-9',
},
taxFormW4: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995004',
name: 'W-4',
},
},
staffDocuments: {
governmentId: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95000',
},
foodSafety: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95001',
},
attireBlackShirt: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95002',
},
taxFormI9: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95003',
},
taxFormW4: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95004',
},
},
certificates: {
foodSafety: {
id: 'df6452dc-4ec7-4d54-876d-26bf8ce5b001',
},
},
emergencyContacts: {
primary: {
id: '8bb1e0c0-59bb-4ce7-8f0f-27674e0b2001',
},
},
accounts: {
businessPrimary: {
id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe001',
},
staffPrimary: {
id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe002',
},
},
locationStreamBatches: {
noShowSample: {
id: '7184a512-b5b2-46b7-a8e0-f4a04bb8f001',
},
},
geofenceIncidents: {
noShowOutsideGeofence: {
id: '8174a512-b5b2-46b7-a8e0-f4a04bb8f001',
},
},
notificationOutbox: {
noShowManagerAlert: {
id: '9174a512-b5b2-46b7-a8e0-f4a04bb8f001',
},
},
};

View File

@@ -0,0 +1,639 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT,
display_name TEXT,
phone TEXT,
status TEXT NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INVITED', 'DISABLED')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
ON users (LOWER(email))
WHERE email IS NOT NULL;
CREATE TABLE IF NOT EXISTS tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
invited_email TEXT,
membership_status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')),
base_role TEXT NOT NULL DEFAULT 'member'
CHECK (base_role IN ('admin', 'manager', 'member', 'viewer')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_tenant_membership_identity
CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_memberships_tenant_user
ON tenant_memberships (tenant_id, user_id)
WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_memberships_tenant_invited_email
ON tenant_memberships (tenant_id, LOWER(invited_email))
WHERE invited_email IS NOT NULL;
CREATE TABLE IF NOT EXISTS businesses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
business_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')),
contact_name TEXT,
contact_email TEXT,
contact_phone TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_businesses_tenant_slug
ON businesses (tenant_id, slug);
CREATE TABLE IF NOT EXISTS business_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
invited_email TEXT,
membership_status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')),
business_role TEXT NOT NULL DEFAULT 'member'
CHECK (business_role IN ('owner', 'manager', 'member', 'viewer')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_business_membership_identity
CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_business_memberships_business_user
ON business_memberships (business_id, user_id)
WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_business_memberships_business_invited_email
ON business_memberships (business_id, LOWER(invited_email))
WHERE invited_email IS NOT NULL;
CREATE TABLE IF NOT EXISTS vendors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
company_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')),
contact_name TEXT,
contact_email TEXT,
contact_phone TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_vendors_tenant_slug
ON vendors (tenant_id, slug);
CREATE TABLE IF NOT EXISTS vendor_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
invited_email TEXT,
membership_status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')),
vendor_role TEXT NOT NULL DEFAULT 'member'
CHECK (vendor_role IN ('owner', 'manager', 'member', 'viewer')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_vendor_membership_identity
CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_vendor_memberships_vendor_user
ON vendor_memberships (vendor_id, user_id)
WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_vendor_memberships_vendor_invited_email
ON vendor_memberships (vendor_id, LOWER(invited_email))
WHERE invited_email IS NOT NULL;
CREATE TABLE IF NOT EXISTS staffs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
full_name TEXT NOT NULL,
email TEXT,
phone TEXT,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INVITED', 'INACTIVE', 'BLOCKED')),
primary_role TEXT,
onboarding_status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (onboarding_status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED')),
average_rating NUMERIC(3, 2) NOT NULL DEFAULT 0 CHECK (average_rating >= 0 AND average_rating <= 5),
rating_count INTEGER NOT NULL DEFAULT 0 CHECK (rating_count >= 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staffs_tenant_user
ON staffs (tenant_id, user_id)
WHERE user_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS workforce (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
workforce_number TEXT NOT NULL,
employment_type TEXT NOT NULL
CHECK (employment_type IN ('W2', 'W1099', 'TEMP', 'CONTRACT')),
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE', 'SUSPENDED')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workforce_vendor_staff
ON workforce (vendor_id, staff_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workforce_number_tenant
ON workforce (tenant_id, workforce_number);
CREATE TABLE IF NOT EXISTS roles_catalog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
code TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_catalog_tenant_code
ON roles_catalog (tenant_id, code);
CREATE TABLE IF NOT EXISTS staff_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles_catalog(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_roles_staff_role
ON staff_roles (staff_id, role_id);
CREATE TABLE IF NOT EXISTS clock_points (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID REFERENCES businesses(id) ON DELETE SET NULL,
label TEXT NOT NULL,
address TEXT,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
geofence_radius_meters INTEGER NOT NULL DEFAULT 100 CHECK (geofence_radius_meters > 0),
nfc_tag_uid TEXT,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_clock_points_tenant_nfc_tag
ON clock_points (tenant_id, nfc_tag_uid)
WHERE nfc_tag_uid IS NOT NULL;
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
order_number TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED', 'CANCELLED')),
service_type TEXT NOT NULL DEFAULT 'EVENT'
CHECK (service_type IN ('EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER')),
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
location_name TEXT,
location_address TEXT,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
notes TEXT,
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_orders_time_window CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at < ends_at)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_tenant_order_number
ON orders (tenant_id, order_number);
CREATE INDEX IF NOT EXISTS idx_orders_tenant_business_status
ON orders (tenant_id, business_id, status, created_at DESC);
CREATE TABLE IF NOT EXISTS shifts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
clock_point_id UUID REFERENCES clock_points(id) ON DELETE SET NULL,
shift_code TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'OPEN'
CHECK (status IN ('DRAFT', 'OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE', 'COMPLETED', 'CANCELLED')),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC',
location_name TEXT,
location_address TEXT,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
geofence_radius_meters INTEGER CHECK (geofence_radius_meters IS NULL OR geofence_radius_meters > 0),
required_workers INTEGER NOT NULL DEFAULT 1 CHECK (required_workers > 0),
assigned_workers INTEGER NOT NULL DEFAULT 0 CHECK (assigned_workers >= 0),
notes TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_shifts_time_window CHECK (starts_at < ends_at),
CONSTRAINT chk_shifts_assigned_workers CHECK (assigned_workers <= required_workers)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_shifts_order_shift_code
ON shifts (order_id, shift_code);
CREATE INDEX IF NOT EXISTS idx_shifts_tenant_time
ON shifts (tenant_id, starts_at, ends_at);
CREATE TABLE IF NOT EXISTS shift_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles_catalog(id) ON DELETE SET NULL,
role_code TEXT NOT NULL,
role_name TEXT NOT NULL,
workers_needed INTEGER NOT NULL CHECK (workers_needed > 0),
assigned_count INTEGER NOT NULL DEFAULT 0 CHECK (assigned_count >= 0),
pay_rate_cents INTEGER NOT NULL DEFAULT 0 CHECK (pay_rate_cents >= 0),
bill_rate_cents INTEGER NOT NULL DEFAULT 0 CHECK (bill_rate_cents >= 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_shift_roles_assigned_count CHECK (assigned_count <= workers_needed)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_shift_roles_shift_role_code
ON shift_roles (shift_id, role_code);
CREATE TABLE IF NOT EXISTS applications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
shift_role_id UUID NOT NULL REFERENCES shift_roles(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'LATE', 'NO_SHOW', 'COMPLETED', 'REJECTED', 'CANCELLED')),
origin TEXT NOT NULL DEFAULT 'STAFF'
CHECK (origin IN ('STAFF', 'BUSINESS', 'VENDOR', 'SYSTEM')),
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_applications_shift_role_staff
ON applications (shift_role_id, staff_id);
CREATE INDEX IF NOT EXISTS idx_applications_staff_status
ON applications (staff_id, status, applied_at DESC);
CREATE TABLE IF NOT EXISTS assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
shift_role_id UUID NOT NULL REFERENCES shift_roles(id) ON DELETE CASCADE,
workforce_id UUID NOT NULL REFERENCES workforce(id) ON DELETE RESTRICT,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
application_id UUID REFERENCES applications(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'ASSIGNED'
CHECK (status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED', 'CANCELLED', 'NO_SHOW')),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
accepted_at TIMESTAMPTZ,
checked_in_at TIMESTAMPTZ,
checked_out_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_assignments_shift_role_workforce
ON assignments (shift_role_id, workforce_id);
CREATE INDEX IF NOT EXISTS idx_assignments_staff_status
ON assignments (staff_id, status, assigned_at DESC);
CREATE TABLE IF NOT EXISTS attendance_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
clock_point_id UUID REFERENCES clock_points(id) ON DELETE SET NULL,
event_type TEXT NOT NULL
CHECK (event_type IN ('CLOCK_IN', 'CLOCK_OUT', 'MANUAL_ADJUSTMENT')),
source_type TEXT NOT NULL
CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')),
source_reference TEXT,
nfc_tag_uid TEXT,
device_id TEXT,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
accuracy_meters INTEGER CHECK (accuracy_meters IS NULL OR accuracy_meters >= 0),
distance_to_clock_point_meters INTEGER CHECK (distance_to_clock_point_meters IS NULL OR distance_to_clock_point_meters >= 0),
within_geofence BOOLEAN,
validation_status TEXT NOT NULL DEFAULT 'ACCEPTED'
CHECK (validation_status IN ('ACCEPTED', 'FLAGGED', 'REJECTED')),
validation_reason TEXT,
captured_at TIMESTAMPTZ NOT NULL,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_attendance_events_assignment_time
ON attendance_events (assignment_id, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_attendance_events_staff_time
ON attendance_events (staff_id, captured_at DESC);
CREATE TABLE IF NOT EXISTS attendance_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
assignment_id UUID NOT NULL UNIQUE REFERENCES assignments(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
clock_in_event_id UUID REFERENCES attendance_events(id) ON DELETE SET NULL,
clock_out_event_id UUID REFERENCES attendance_events(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'OPEN'
CHECK (status IN ('OPEN', 'CLOSED', 'DISPUTED')),
check_in_at TIMESTAMPTZ,
check_out_at TIMESTAMPTZ,
worked_minutes INTEGER NOT NULL DEFAULT 0 CHECK (worked_minutes >= 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS timesheets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
assignment_id UUID NOT NULL UNIQUE REFERENCES assignments(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'SUBMITTED', 'APPROVED', 'REJECTED', 'PAID')),
regular_minutes INTEGER NOT NULL DEFAULT 0 CHECK (regular_minutes >= 0),
overtime_minutes INTEGER NOT NULL DEFAULT 0 CHECK (overtime_minutes >= 0),
break_minutes INTEGER NOT NULL DEFAULT 0 CHECK (break_minutes >= 0),
gross_pay_cents BIGINT NOT NULL DEFAULT 0 CHECK (gross_pay_cents >= 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
document_type TEXT NOT NULL,
name TEXT NOT NULL,
required_for_role_code TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_tenant_type_name
ON documents (tenant_id, document_type, name);
CREATE TABLE IF NOT EXISTS staff_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
file_uri TEXT,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'VERIFIED', 'REJECTED', 'EXPIRED')),
expires_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_documents_staff_document
ON staff_documents (staff_id, document_id);
CREATE TABLE IF NOT EXISTS certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
certificate_type TEXT NOT NULL,
certificate_number TEXT,
issued_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'VERIFIED', 'REJECTED', 'EXPIRED')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS verification_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
type TEXT NOT NULL,
file_uri TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'PROCESSING', 'AUTO_PASS', 'AUTO_FAIL', 'NEEDS_REVIEW', 'APPROVED', 'REJECTED', 'ERROR')),
idempotency_key TEXT,
provider_name TEXT,
provider_reference TEXT,
confidence NUMERIC(4, 3),
reasons JSONB NOT NULL DEFAULT '[]'::jsonb,
extracted JSONB NOT NULL DEFAULT '{}'::jsonb,
review JSONB NOT NULL DEFAULT '{}'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_verification_jobs_tenant_idempotency
ON verification_jobs (tenant_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS verification_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
verification_job_id UUID NOT NULL REFERENCES verification_jobs(id) ON DELETE CASCADE,
reviewer_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
decision TEXT NOT NULL CHECK (decision IN ('APPROVED', 'REJECTED')),
note TEXT,
reason_code TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS verification_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
verification_job_id UUID NOT NULL REFERENCES verification_jobs(id) ON DELETE CASCADE,
from_status TEXT,
to_status TEXT NOT NULL,
actor_type TEXT NOT NULL,
actor_id TEXT,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
owner_type TEXT NOT NULL CHECK (owner_type IN ('BUSINESS', 'VENDOR', 'STAFF')),
owner_business_id UUID REFERENCES businesses(id) ON DELETE CASCADE,
owner_vendor_id UUID REFERENCES vendors(id) ON DELETE CASCADE,
owner_staff_id UUID REFERENCES staffs(id) ON DELETE CASCADE,
provider_name TEXT NOT NULL,
provider_reference TEXT NOT NULL,
last4 TEXT,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_accounts_single_owner
CHECK (
(owner_business_id IS NOT NULL)::INTEGER
+ (owner_vendor_id IS NOT NULL)::INTEGER
+ (owner_staff_id IS NOT NULL)::INTEGER = 1
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_business
ON accounts (owner_business_id)
WHERE owner_business_id IS NOT NULL AND is_primary = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_vendor
ON accounts (owner_vendor_id)
WHERE owner_vendor_id IS NOT NULL AND is_primary = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_staff
ON accounts (owner_staff_id)
WHERE owner_staff_id IS NOT NULL AND is_primary = TRUE;
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
invoice_number TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('DRAFT', 'PENDING', 'PENDING_REVIEW', 'APPROVED', 'PAID', 'OVERDUE', 'DISPUTED', 'VOID')),
currency_code TEXT NOT NULL DEFAULT 'USD',
subtotal_cents BIGINT NOT NULL DEFAULT 0 CHECK (subtotal_cents >= 0),
tax_cents BIGINT NOT NULL DEFAULT 0 CHECK (tax_cents >= 0),
total_cents BIGINT NOT NULL DEFAULT 0 CHECK (total_cents >= 0),
due_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_tenant_invoice_number
ON invoices (tenant_id, invoice_number);
CREATE TABLE IF NOT EXISTS recent_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL,
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'PROCESSING', 'PAID', 'FAILED')),
amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0),
process_date TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS staff_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
reviewer_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
review_text TEXT,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_reviews_business_assignment_staff
ON staff_reviews (business_id, assignment_id, staff_id);
CREATE INDEX IF NOT EXISTS idx_staff_reviews_staff_created_at
ON staff_reviews (staff_id, created_at DESC);
CREATE TABLE IF NOT EXISTS staff_favorites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_favorites_business_staff
ON staff_favorites (business_id, staff_id);
CREATE TABLE IF NOT EXISTS domain_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
aggregate_type TEXT NOT NULL,
aggregate_id UUID NOT NULL,
sequence INTEGER NOT NULL CHECK (sequence > 0),
event_type TEXT NOT NULL,
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_domain_events_aggregate_sequence
ON domain_events (tenant_id, aggregate_type, aggregate_id, sequence);

View File

@@ -0,0 +1,64 @@
CREATE TABLE IF NOT EXISTS cost_centers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
code TEXT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cost_centers_business_name
ON cost_centers (business_id, name);
ALTER TABLE clock_points
ADD COLUMN IF NOT EXISTS cost_center_id UUID REFERENCES cost_centers(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS hub_managers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
hub_id UUID NOT NULL REFERENCES clock_points(id) ON DELETE CASCADE,
business_membership_id UUID NOT NULL REFERENCES business_memberships(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_hub_managers_hub_membership
ON hub_managers (hub_id, business_membership_id);
CREATE TABLE IF NOT EXISTS staff_availability (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
availability_status TEXT NOT NULL DEFAULT 'UNAVAILABLE'
CHECK (availability_status IN ('AVAILABLE', 'UNAVAILABLE', 'PARTIAL')),
time_slots JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_availability_staff_day
ON staff_availability (staff_id, day_of_week);
CREATE TABLE IF NOT EXISTS staff_benefits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
benefit_type TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE', 'PENDING')),
tracked_hours INTEGER NOT NULL DEFAULT 0 CHECK (tracked_hours >= 0),
target_hours INTEGER NOT NULL DEFAULT 0 CHECK (target_hours >= 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_benefits_staff_type
ON staff_benefits (staff_id, benefit_type);

View File

@@ -0,0 +1,44 @@
CREATE TABLE IF NOT EXISTS emergency_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
full_name TEXT NOT NULL,
phone TEXT NOT NULL,
relationship_type TEXT NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_emergency_contacts_staff
ON emergency_contacts (staff_id, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_emergency_contacts_primary_staff
ON emergency_contacts (staff_id)
WHERE is_primary = TRUE;
ALTER TABLE assignments
DROP CONSTRAINT IF EXISTS assignments_status_check;
ALTER TABLE assignments
ADD CONSTRAINT assignments_status_check
CHECK (status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED', 'CANCELLED', 'NO_SHOW'));
ALTER TABLE verification_jobs
ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS subject_type TEXT,
ADD COLUMN IF NOT EXISTS subject_id TEXT;
ALTER TABLE staff_documents
ADD COLUMN IF NOT EXISTS verification_job_id UUID REFERENCES verification_jobs(id) ON DELETE SET NULL;
ALTER TABLE certificates
ADD COLUMN IF NOT EXISTS file_uri TEXT,
ADD COLUMN IF NOT EXISTS verification_job_id UUID REFERENCES verification_jobs(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_verification_jobs_owner
ON verification_jobs (owner_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_verification_jobs_subject
ON verification_jobs (subject_type, subject_id, created_at DESC);

View File

@@ -0,0 +1,155 @@
ALTER TABLE clock_points
ADD COLUMN IF NOT EXISTS default_clock_in_mode TEXT,
ADD COLUMN IF NOT EXISTS allow_clock_in_override BOOLEAN;
UPDATE clock_points
SET default_clock_in_mode = COALESCE(default_clock_in_mode, 'EITHER'),
allow_clock_in_override = COALESCE(allow_clock_in_override, TRUE)
WHERE default_clock_in_mode IS NULL
OR allow_clock_in_override IS NULL;
ALTER TABLE clock_points
ALTER COLUMN default_clock_in_mode SET DEFAULT 'EITHER',
ALTER COLUMN default_clock_in_mode SET NOT NULL,
ALTER COLUMN allow_clock_in_override SET DEFAULT TRUE,
ALTER COLUMN allow_clock_in_override SET NOT NULL;
ALTER TABLE clock_points
DROP CONSTRAINT IF EXISTS clock_points_default_clock_in_mode_check;
ALTER TABLE clock_points
ADD CONSTRAINT clock_points_default_clock_in_mode_check
CHECK (default_clock_in_mode IN ('NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER'));
ALTER TABLE shifts
ADD COLUMN IF NOT EXISTS clock_in_mode TEXT,
ADD COLUMN IF NOT EXISTS allow_clock_in_override BOOLEAN;
ALTER TABLE shifts
DROP CONSTRAINT IF EXISTS shifts_clock_in_mode_check;
ALTER TABLE shifts
ADD CONSTRAINT shifts_clock_in_mode_check
CHECK (clock_in_mode IS NULL OR clock_in_mode IN ('NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER'));
CREATE TABLE IF NOT EXISTS location_stream_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID REFERENCES businesses(id) ON DELETE SET NULL,
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
source_type TEXT NOT NULL DEFAULT 'GEO'
CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')),
device_id TEXT,
object_uri TEXT,
point_count INTEGER NOT NULL DEFAULT 0 CHECK (point_count >= 0),
out_of_geofence_count INTEGER NOT NULL DEFAULT 0 CHECK (out_of_geofence_count >= 0),
missing_coordinate_count INTEGER NOT NULL DEFAULT 0 CHECK (missing_coordinate_count >= 0),
max_distance_to_clock_point_meters INTEGER CHECK (max_distance_to_clock_point_meters IS NULL OR max_distance_to_clock_point_meters >= 0),
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_location_stream_batches_assignment_received
ON location_stream_batches (assignment_id, received_at DESC);
CREATE INDEX IF NOT EXISTS idx_location_stream_batches_staff_received
ON location_stream_batches (staff_id, received_at DESC);
CREATE TABLE IF NOT EXISTS geofence_incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID REFERENCES businesses(id) ON DELETE SET NULL,
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL,
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
location_stream_batch_id UUID REFERENCES location_stream_batches(id) ON DELETE SET NULL,
incident_type TEXT NOT NULL
CHECK (incident_type IN ('CLOCK_IN_OVERRIDE', 'OUTSIDE_GEOFENCE', 'LOCATION_UNAVAILABLE', 'NFC_MISMATCH', 'CLOCK_IN_REJECTED')),
severity TEXT NOT NULL DEFAULT 'WARNING'
CHECK (severity IN ('INFO', 'WARNING', 'CRITICAL')),
status TEXT NOT NULL DEFAULT 'OPEN'
CHECK (status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED')),
effective_clock_in_mode TEXT
CHECK (effective_clock_in_mode IS NULL OR effective_clock_in_mode IN ('NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER')),
source_type TEXT
CHECK (source_type IS NULL OR source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')),
nfc_tag_uid TEXT,
device_id TEXT,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
accuracy_meters INTEGER CHECK (accuracy_meters IS NULL OR accuracy_meters >= 0),
distance_to_clock_point_meters INTEGER CHECK (distance_to_clock_point_meters IS NULL OR distance_to_clock_point_meters >= 0),
within_geofence BOOLEAN,
override_reason TEXT,
message TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_geofence_incidents_assignment_occurred
ON geofence_incidents (assignment_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_geofence_incidents_shift_occurred
ON geofence_incidents (shift_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_geofence_incidents_staff_occurred
ON geofence_incidents (staff_id, occurred_at DESC);
CREATE TABLE IF NOT EXISTS notification_outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID REFERENCES businesses(id) ON DELETE SET NULL,
shift_id UUID REFERENCES shifts(id) ON DELETE SET NULL,
assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL,
related_incident_id UUID REFERENCES geofence_incidents(id) ON DELETE SET NULL,
audience_type TEXT NOT NULL DEFAULT 'USER'
CHECK (audience_type IN ('USER', 'STAFF', 'BUSINESS_MEMBERSHIP', 'SYSTEM')),
recipient_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
recipient_staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
recipient_business_membership_id UUID REFERENCES business_memberships(id) ON DELETE SET NULL,
channel TEXT NOT NULL DEFAULT 'PUSH'
CHECK (channel IN ('PUSH', 'EMAIL', 'SMS', 'IN_APP', 'WEBHOOK')),
notification_type TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'NORMAL'
CHECK (priority IN ('LOW', 'NORMAL', 'HIGH', 'CRITICAL')),
dedupe_key TEXT,
subject TEXT,
body TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'PROCESSING', 'SENT', 'FAILED', 'CANCELLED')),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sent_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_notification_outbox_recipient
CHECK (
recipient_user_id IS NOT NULL
OR recipient_staff_id IS NOT NULL
OR recipient_business_membership_id IS NOT NULL
OR audience_type = 'SYSTEM'
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_outbox_dedupe
ON notification_outbox (dedupe_key);
CREATE INDEX IF NOT EXISTS idx_notification_outbox_status_schedule
ON notification_outbox (status, scheduled_at ASC);
CREATE INDEX IF NOT EXISTS idx_notification_outbox_recipient_user
ON notification_outbox (recipient_user_id, created_at DESC)
WHERE recipient_user_id IS NOT NULL;

View File

@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_notification_outbox_dedupe;
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_outbox_dedupe
ON notification_outbox (dedupe_key);

View File

@@ -0,0 +1,107 @@
CREATE TABLE IF NOT EXISTS device_push_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
business_membership_id UUID REFERENCES business_memberships(id) ON DELETE SET NULL,
vendor_membership_id UUID REFERENCES vendor_memberships(id) ON DELETE SET NULL,
provider TEXT NOT NULL DEFAULT 'FCM'
CHECK (provider IN ('FCM', 'APNS', 'WEB_PUSH')),
platform TEXT NOT NULL
CHECK (platform IN ('IOS', 'ANDROID', 'WEB')),
push_token TEXT NOT NULL,
token_hash TEXT NOT NULL,
device_id TEXT,
app_version TEXT,
app_build TEXT,
locale TEXT,
timezone TEXT,
notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE,
invalidated_at TIMESTAMPTZ,
invalidation_reason TEXT,
last_registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_delivery_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_device_push_tokens_membership_scope
CHECK (
business_membership_id IS NOT NULL
OR vendor_membership_id IS NOT NULL
OR staff_id IS NOT NULL
OR user_id IS NOT NULL
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_push_tokens_provider_hash
ON device_push_tokens (provider, token_hash);
CREATE INDEX IF NOT EXISTS idx_device_push_tokens_user_active
ON device_push_tokens (user_id, last_seen_at DESC)
WHERE invalidated_at IS NULL AND notifications_enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_device_push_tokens_staff_active
ON device_push_tokens (staff_id, last_seen_at DESC)
WHERE staff_id IS NOT NULL AND invalidated_at IS NULL AND notifications_enabled = TRUE;
CREATE TABLE IF NOT EXISTS notification_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
notification_outbox_id UUID NOT NULL REFERENCES notification_outbox(id) ON DELETE CASCADE,
device_push_token_id UUID REFERENCES device_push_tokens(id) ON DELETE SET NULL,
provider TEXT NOT NULL DEFAULT 'FCM'
CHECK (provider IN ('FCM', 'APNS', 'WEB_PUSH')),
delivery_status TEXT NOT NULL
CHECK (delivery_status IN ('SIMULATED', 'SENT', 'FAILED', 'INVALID_TOKEN', 'SKIPPED')),
provider_message_id TEXT,
attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number >= 1),
error_code TEXT,
error_message TEXT,
response_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_outbox_created
ON notification_deliveries (notification_outbox_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_token_created
ON notification_deliveries (device_push_token_id, created_at DESC)
WHERE device_push_token_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS attendance_security_proofs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
event_type TEXT NOT NULL
CHECK (event_type IN ('CLOCK_IN', 'CLOCK_OUT')),
source_type TEXT NOT NULL
CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')),
device_id TEXT,
nfc_tag_uid TEXT,
proof_nonce TEXT,
proof_timestamp TIMESTAMPTZ,
request_fingerprint TEXT,
attestation_provider TEXT
CHECK (attestation_provider IS NULL OR attestation_provider IN ('PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK')),
attestation_token_hash TEXT,
attestation_status TEXT NOT NULL DEFAULT 'NOT_PROVIDED'
CHECK (attestation_status IN ('NOT_PROVIDED', 'RECORDED_UNVERIFIED', 'VERIFIED', 'REJECTED', 'BYPASSED')),
attestation_reason TEXT,
object_uri TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_security_proofs_nonce
ON attendance_security_proofs (tenant_id, proof_nonce)
WHERE proof_nonce IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_attendance_security_proofs_assignment_created
ON attendance_security_proofs (assignment_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_attendance_security_proofs_staff_created
ON attendance_security_proofs (staff_id, created_at DESC);

View File

@@ -5,10 +5,11 @@ import { requestContext } from './middleware/request-context.js';
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js';
import { createCommandsRouter } from './routes/commands.js';
import { createMobileCommandsRouter } from './routes/mobile.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp() {
export function createApp(options = {}) {
const app = express();
app.use(requestContext);
@@ -21,7 +22,8 @@ export function createApp() {
app.use(express.json({ limit: '2mb' }));
app.use(healthRouter);
app.use('/commands', createCommandsRouter());
app.use('/commands', createCommandsRouter(options.commandHandlers));
app.use('/commands', createMobileCommandsRouter(options.mobileCommandHandlers));
app.use(notFoundHandler);
app.use(errorHandler);

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const attendanceCommandSchema = z.object({
assignmentId: z.string().uuid(),
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']),
sourceReference: z.string().max(255).optional(),
nfcTagUid: z.string().max(255).optional(),
deviceId: z.string().max(255).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
accuracyMeters: z.number().int().nonnegative().optional(),
capturedAt: z.string().datetime().optional(),
rawPayload: z.record(z.any()).optional(),
});

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
export const favoriteStaffSchema = z.object({
tenantId: z.string().uuid(),
businessId: z.string().uuid(),
staffId: z.string().uuid(),
});

View File

@@ -0,0 +1,360 @@
import { z } from 'zod';
const timeSlotSchema = z.object({
start: z.string().min(1).max(20),
end: z.string().min(1).max(20),
});
const preferredLocationSchema = z.object({
label: z.string().min(1).max(160),
city: z.string().max(120).optional(),
state: z.string().max(80).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
radiusMiles: z.number().nonnegative().optional(),
});
const hhmmSchema = z.string().regex(/^\d{2}:\d{2}$/, 'Time must use HH:MM format');
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must use YYYY-MM-DD format');
const clockInModeSchema = z.enum(['NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER']);
const pushProviderSchema = z.enum(['FCM', 'APNS', 'WEB_PUSH']);
const pushPlatformSchema = z.enum(['IOS', 'ANDROID', 'WEB']);
const shiftPositionSchema = z.object({
roleId: z.string().uuid().optional(),
roleCode: z.string().min(1).max(120).optional(),
roleName: z.string().min(1).max(160).optional(),
workerCount: z.number().int().positive().optional(),
workersNeeded: z.number().int().positive().optional(),
startTime: hhmmSchema,
endTime: hhmmSchema,
hourlyRateCents: z.number().int().nonnegative().optional(),
payRateCents: z.number().int().nonnegative().optional(),
billRateCents: z.number().int().nonnegative().optional(),
lunchBreakMinutes: z.number().int().nonnegative().optional(),
paidBreak: z.boolean().optional(),
instantBook: z.boolean().optional(),
metadata: z.record(z.any()).optional(),
}).superRefine((value, ctx) => {
if (!value.roleId && !value.roleCode && !value.roleName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'roleId, roleCode, or roleName is required',
path: ['roleId'],
});
}
});
const baseOrderCreateSchema = z.object({
hubId: z.string().uuid(),
vendorId: z.string().uuid().optional(),
eventName: z.string().min(2).max(160),
timezone: z.string().min(1).max(80).optional(),
description: z.string().max(5000).optional(),
notes: z.string().max(5000).optional(),
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
positions: z.array(shiftPositionSchema).min(1),
metadata: z.record(z.any()).optional(),
});
export const hubCreateSchema = z.object({
name: z.string().min(1).max(160),
fullAddress: z.string().max(300).optional(),
placeId: z.string().max(255).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
street: z.string().max(160).optional(),
city: z.string().max(120).optional(),
state: z.string().max(80).optional(),
country: z.string().max(80).optional(),
zipCode: z.string().max(40).optional(),
costCenterId: z.string().uuid().optional(),
geofenceRadiusMeters: z.number().int().positive().optional(),
nfcTagId: z.string().max(255).optional(),
clockInMode: clockInModeSchema.optional(),
allowClockInOverride: z.boolean().optional(),
});
export const hubUpdateSchema = hubCreateSchema.extend({
hubId: z.string().uuid(),
});
export const hubDeleteSchema = z.object({
hubId: z.string().uuid(),
reason: z.string().max(1000).optional(),
});
export const hubAssignNfcSchema = z.object({
hubId: z.string().uuid(),
nfcTagId: z.string().min(1).max(255),
});
export const hubAssignManagerSchema = z.object({
hubId: z.string().uuid(),
businessMembershipId: z.string().uuid().optional(),
managerUserId: z.string().min(1).optional(),
}).refine((value) => value.businessMembershipId || value.managerUserId, {
message: 'businessMembershipId or managerUserId is required',
});
export const invoiceApproveSchema = z.object({
invoiceId: z.string().uuid(),
});
export const invoiceDisputeSchema = z.object({
invoiceId: z.string().uuid(),
reason: z.string().min(3).max(2000),
});
export const coverageReviewSchema = z.object({
staffId: z.string().uuid(),
assignmentId: z.string().uuid().optional(),
rating: z.number().int().min(1).max(5),
markAsFavorite: z.boolean().optional(),
issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
feedback: z.string().max(5000).optional(),
});
export const cancelLateWorkerSchema = z.object({
assignmentId: z.string().uuid(),
reason: z.string().max(1000).optional(),
});
export const clientOneTimeOrderSchema = baseOrderCreateSchema.extend({
orderDate: isoDateSchema,
});
export const clientRecurringOrderSchema = baseOrderCreateSchema.extend({
startDate: isoDateSchema,
endDate: isoDateSchema,
recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1),
});
export const clientPermanentOrderSchema = baseOrderCreateSchema.extend({
startDate: isoDateSchema,
endDate: isoDateSchema.optional(),
daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(),
horizonDays: z.number().int().min(7).max(180).optional(),
});
export const clientOrderEditSchema = z.object({
orderId: z.string().uuid(),
orderType: z.enum(['ONE_TIME', 'RECURRING', 'PERMANENT']).optional(),
hubId: z.string().uuid().optional(),
vendorId: z.string().uuid().optional(),
eventName: z.string().min(2).max(160).optional(),
orderDate: isoDateSchema.optional(),
startDate: isoDateSchema.optional(),
endDate: isoDateSchema.optional(),
recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1).optional(),
daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(),
timezone: z.string().min(1).max(80).optional(),
description: z.string().max(5000).optional(),
notes: z.string().max(5000).optional(),
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
positions: z.array(shiftPositionSchema).min(1).optional(),
metadata: z.record(z.any()).optional(),
}).superRefine((value, ctx) => {
const keys = Object.keys(value).filter((key) => key !== 'orderId');
if (keys.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'At least one field must be provided to create an edited order copy',
path: [],
});
}
});
export const clientOrderCancelSchema = z.object({
orderId: z.string().uuid(),
reason: z.string().max(1000).optional(),
metadata: z.record(z.any()).optional(),
});
export const availabilityDayUpdateSchema = z.object({
dayOfWeek: z.number().int().min(0).max(6),
availabilityStatus: z.enum(['AVAILABLE', 'UNAVAILABLE', 'PARTIAL']),
slots: z.array(timeSlotSchema).max(8).optional(),
metadata: z.record(z.any()).optional(),
});
export const availabilityQuickSetSchema = z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
quickSetType: z.enum(['all', 'weekdays', 'weekends', 'clear']),
slots: z.array(timeSlotSchema).max(8).optional(),
});
export const shiftApplySchema = z.object({
shiftId: z.string().uuid(),
roleId: z.string().uuid().optional(),
instantBook: z.boolean().optional(),
});
export const shiftDecisionSchema = z.object({
shiftId: z.string().uuid(),
reason: z.string().max(1000).optional(),
});
export const staffClockInSchema = z.object({
assignmentId: z.string().uuid().optional(),
shiftId: z.string().uuid().optional(),
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(),
sourceReference: z.string().max(255).optional(),
nfcTagId: z.string().max(255).optional(),
deviceId: z.string().max(255).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
accuracyMeters: z.number().int().nonnegative().optional(),
capturedAt: z.string().datetime().optional(),
proofNonce: z.string().min(8).max(255).optional(),
proofTimestamp: z.string().datetime().optional(),
attestationProvider: z.enum(['PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK']).optional(),
attestationToken: z.string().min(16).max(20000).optional(),
isMockLocation: z.boolean().optional(),
notes: z.string().max(2000).optional(),
overrideReason: z.string().max(2000).optional(),
rawPayload: z.record(z.any()).optional(),
}).refine((value) => value.assignmentId || value.shiftId, {
message: 'assignmentId or shiftId is required',
});
export const staffClockOutSchema = z.object({
assignmentId: z.string().uuid().optional(),
shiftId: z.string().uuid().optional(),
applicationId: z.string().uuid().optional(),
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(),
sourceReference: z.string().max(255).optional(),
nfcTagId: z.string().max(255).optional(),
deviceId: z.string().max(255).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
accuracyMeters: z.number().int().nonnegative().optional(),
capturedAt: z.string().datetime().optional(),
proofNonce: z.string().min(8).max(255).optional(),
proofTimestamp: z.string().datetime().optional(),
attestationProvider: z.enum(['PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK']).optional(),
attestationToken: z.string().min(16).max(20000).optional(),
isMockLocation: z.boolean().optional(),
notes: z.string().max(2000).optional(),
overrideReason: z.string().max(2000).optional(),
breakMinutes: z.number().int().nonnegative().optional(),
rawPayload: z.record(z.any()).optional(),
}).refine((value) => value.assignmentId || value.shiftId || value.applicationId, {
message: 'assignmentId, shiftId, or applicationId is required',
});
const locationPointSchema = z.object({
capturedAt: z.string().datetime(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
accuracyMeters: z.number().int().nonnegative().optional(),
speedMps: z.number().nonnegative().optional(),
isMocked: z.boolean().optional(),
metadata: z.record(z.any()).optional(),
});
export const staffLocationBatchSchema = z.object({
assignmentId: z.string().uuid().optional(),
shiftId: z.string().uuid().optional(),
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).default('GEO'),
deviceId: z.string().max(255).optional(),
points: z.array(locationPointSchema).min(1).max(96),
metadata: z.record(z.any()).optional(),
}).refine((value) => value.assignmentId || value.shiftId, {
message: 'assignmentId or shiftId is required',
});
export const pushTokenRegisterSchema = z.object({
provider: pushProviderSchema.default('FCM'),
platform: pushPlatformSchema,
pushToken: z.string().min(16).max(4096),
deviceId: z.string().max(255).optional(),
appVersion: z.string().max(80).optional(),
appBuild: z.string().max(80).optional(),
locale: z.string().max(32).optional(),
timezone: z.string().max(64).optional(),
notificationsEnabled: z.boolean().optional(),
metadata: z.record(z.any()).optional(),
});
export const pushTokenDeleteSchema = z.object({
tokenId: z.string().uuid().optional(),
pushToken: z.string().min(16).max(4096).optional(),
reason: z.string().max(255).optional(),
}).refine((value) => value.tokenId || value.pushToken, {
message: 'tokenId or pushToken is required',
});
export const staffProfileSetupSchema = z.object({
fullName: z.string().min(2).max(160),
bio: z.string().max(5000).optional(),
email: z.string().email().optional(),
phoneNumber: z.string().min(6).max(40),
preferredLocations: z.array(preferredLocationSchema).max(20).optional(),
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
industries: z.array(z.string().min(1).max(80)).max(30).optional(),
skills: z.array(z.string().min(1).max(80)).max(50).optional(),
primaryRole: z.string().max(120).optional(),
tenantId: z.string().uuid().optional(),
vendorId: z.string().uuid().optional(),
});
export const personalInfoUpdateSchema = z.object({
firstName: z.string().min(1).max(80).optional(),
lastName: z.string().min(1).max(80).optional(),
bio: z.string().max(5000).optional(),
preferredLocations: z.array(preferredLocationSchema).max(20).optional(),
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
email: z.string().email().optional(),
phone: z.string().min(6).max(40).optional(),
displayName: z.string().min(2).max(160).optional(),
});
export const profileExperienceSchema = z.object({
industries: z.array(z.string().min(1).max(80)).max(30).optional(),
skills: z.array(z.string().min(1).max(80)).max(50).optional(),
primaryRole: z.string().max(120).optional(),
});
export const preferredLocationsUpdateSchema = z.object({
preferredLocations: z.array(preferredLocationSchema).max(20),
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
});
export const emergencyContactCreateSchema = z.object({
fullName: z.string().min(2).max(160),
phone: z.string().min(6).max(40),
relationshipType: z.string().min(1).max(120),
isPrimary: z.boolean().optional(),
metadata: z.record(z.any()).optional(),
});
export const emergencyContactUpdateSchema = emergencyContactCreateSchema.partial().extend({
contactId: z.string().uuid(),
});
const taxFormFieldsSchema = z.record(z.any());
export const taxFormDraftSchema = z.object({
formType: z.enum(['I9', 'W4']),
fields: taxFormFieldsSchema,
});
export const taxFormSubmitSchema = z.object({
formType: z.enum(['I9', 'W4']),
fields: taxFormFieldsSchema,
});
export const bankAccountCreateSchema = z.object({
bankName: z.string().min(2).max(160),
accountNumber: z.string().min(4).max(34),
routingNumber: z.string().min(4).max(20),
accountType: z.string()
.transform((value) => value.trim().toUpperCase())
.pipe(z.enum(['CHECKING', 'SAVINGS'])),
});
export const privacyUpdateSchema = z.object({
profileVisible: z.boolean(),
});

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const orderCancelSchema = z.object({
orderId: z.string().uuid(),
tenantId: z.string().uuid(),
reason: z.string().max(1000).optional(),
metadata: z.record(z.any()).optional(),
});

View File

@@ -0,0 +1,58 @@
import { z } from 'zod';
const roleSchema = z.object({
roleId: z.string().uuid().optional(),
roleCode: z.string().min(1).max(100),
roleName: z.string().min(1).max(120),
workersNeeded: z.number().int().positive(),
payRateCents: z.number().int().nonnegative().optional(),
billRateCents: z.number().int().nonnegative().optional(),
metadata: z.record(z.any()).optional(),
});
const shiftSchema = z.object({
shiftCode: z.string().min(1).max(80),
title: z.string().min(1).max(160),
status: z.enum([
'DRAFT',
'OPEN',
'PENDING_CONFIRMATION',
'ASSIGNED',
'ACTIVE',
'COMPLETED',
'CANCELLED',
]).optional(),
startsAt: z.string().datetime(),
endsAt: z.string().datetime(),
timezone: z.string().min(1).max(80).optional(),
clockPointId: z.string().uuid().optional(),
locationName: z.string().max(160).optional(),
locationAddress: z.string().max(300).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
geofenceRadiusMeters: z.number().int().positive().optional(),
requiredWorkers: z.number().int().positive(),
notes: z.string().max(5000).optional(),
metadata: z.record(z.any()).optional(),
roles: z.array(roleSchema).min(1),
});
export const orderCreateSchema = z.object({
tenantId: z.string().uuid(),
businessId: z.string().uuid(),
vendorId: z.string().uuid().optional(),
orderNumber: z.string().min(1).max(80),
title: z.string().min(1).max(160),
description: z.string().max(5000).optional(),
status: z.enum(['DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED', 'CANCELLED']).optional(),
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
startsAt: z.string().datetime().optional(),
endsAt: z.string().datetime().optional(),
locationName: z.string().max(160).optional(),
locationAddress: z.string().max(300).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
notes: z.string().max(5000).optional(),
metadata: z.record(z.any()).optional(),
shifts: z.array(shiftSchema).min(1),
});

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
const nullableString = (max) => z.union([z.string().max(max), z.null()]);
const nullableDateTime = z.union([z.string().datetime(), z.null()]);
const nullableUuid = z.union([z.string().uuid(), z.null()]);
const orderUpdateShape = {
orderId: z.string().uuid(),
tenantId: z.string().uuid(),
vendorId: nullableUuid.optional(),
title: nullableString(160).optional(),
description: nullableString(5000).optional(),
status: z.enum(['DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED']).optional(),
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
startsAt: nullableDateTime.optional(),
endsAt: nullableDateTime.optional(),
locationName: nullableString(160).optional(),
locationAddress: nullableString(300).optional(),
latitude: z.union([z.number().min(-90).max(90), z.null()]).optional(),
longitude: z.union([z.number().min(-180).max(180), z.null()]).optional(),
notes: nullableString(5000).optional(),
metadata: z.record(z.any()).optional(),
};
export const orderUpdateSchema = z.object(orderUpdateShape).superRefine((value, ctx) => {
const mutableKeys = Object.keys(orderUpdateShape).filter((key) => !['orderId', 'tenantId'].includes(key));
const hasMutableField = mutableKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key));
if (!hasMutableField) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'At least one mutable order field must be provided',
path: [],
});
}
});

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const shiftAcceptSchema = z.object({
shiftId: z.string().uuid().optional(),
shiftRoleId: z.string().uuid(),
workforceId: z.string().uuid(),
metadata: z.record(z.any()).optional(),
});

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const shiftAssignStaffSchema = z.object({
shiftId: z.string().uuid(),
tenantId: z.string().uuid(),
shiftRoleId: z.string().uuid(),
workforceId: z.string().uuid(),
applicationId: z.string().uuid().optional(),
metadata: z.record(z.any()).optional(),
});

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const shiftStatusChangeSchema = z.object({
shiftId: z.string().uuid(),
tenantId: z.string().uuid(),
status: z.enum([
'DRAFT',
'OPEN',
'PENDING_CONFIRMATION',
'ASSIGNED',
'ACTIVE',
'COMPLETED',
'CANCELLED',
]),
reason: z.string().max(1000).optional(),
metadata: z.record(z.any()).optional(),
});

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const staffReviewSchema = z.object({
tenantId: z.string().uuid(),
businessId: z.string().uuid(),
staffId: z.string().uuid(),
assignmentId: z.string().uuid(),
rating: z.number().int().min(1).max(5),
reviewText: z.string().max(5000).optional(),
tags: z.array(z.string().min(1).max(80)).max(20).optional(),
});

View File

@@ -3,10 +3,45 @@ import { AppError } from '../lib/errors.js';
import { requireAuth, requirePolicy } from '../middleware/auth.js';
import { requireIdempotencyKey } from '../middleware/idempotency.js';
import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js';
import { commandBaseSchema } from '../contracts/commands/command-base.js';
import {
addFavoriteStaff,
clockIn,
clockOut,
createOrder,
createStaffReview,
updateOrder,
cancelOrder,
changeShiftStatus,
assignStaffToShift,
removeFavoriteStaff,
acceptShift,
} from '../services/command-service.js';
import { attendanceCommandSchema } from '../contracts/commands/attendance.js';
import { favoriteStaffSchema } from '../contracts/commands/favorite-staff.js';
import { orderCancelSchema } from '../contracts/commands/order-cancel.js';
import { orderCreateSchema } from '../contracts/commands/order-create.js';
import { orderUpdateSchema } from '../contracts/commands/order-update.js';
import { shiftAssignStaffSchema } from '../contracts/commands/shift-assign-staff.js';
import { shiftAcceptSchema } from '../contracts/commands/shift-accept.js';
import { shiftStatusChangeSchema } from '../contracts/commands/shift-status-change.js';
import { staffReviewSchema } from '../contracts/commands/staff-review.js';
function parseBody(body) {
const parsed = commandBaseSchema.safeParse(body || {});
const defaultHandlers = {
addFavoriteStaff,
assignStaffToShift,
cancelOrder,
changeShiftStatus,
clockIn,
clockOut,
createOrder,
createStaffReview,
removeFavoriteStaff,
acceptShift,
updateOrder,
};
function parseBody(schema, body) {
const parsed = schema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid command payload', 400, {
issues: parsed.error.issues,
@@ -15,50 +50,37 @@ function parseBody(body) {
return parsed.data;
}
function createCommandResponse(route, requestId, idempotencyKey) {
return {
accepted: true,
async function runIdempotentCommand(req, res, work) {
const route = `${req.baseUrl}${req.route.path}`;
const compositeKey = buildIdempotencyKey({
userId: req.actor.uid,
route,
commandId: `${route}:${Date.now()}`,
idempotencyKey,
requestId,
idempotencyKey: req.idempotencyKey,
});
const existing = await readIdempotentResult(compositeKey);
if (existing) {
return res.status(existing.statusCode).json(existing.payload);
}
const payload = await work();
const responsePayload = {
...payload,
idempotencyKey: req.idempotencyKey,
requestId: req.requestId,
};
const persisted = await writeIdempotentResult({
compositeKey,
userId: req.actor.uid,
route,
idempotencyKey: req.idempotencyKey,
payload: responsePayload,
statusCode: 200,
});
return res.status(persisted.statusCode).json(persisted.payload);
}
function buildCommandHandler(policyAction, policyResource) {
return async (req, res, next) => {
try {
parseBody(req.body);
const route = `${req.baseUrl}${req.route.path}`;
const compositeKey = buildIdempotencyKey({
userId: req.actor.uid,
route,
idempotencyKey: req.idempotencyKey,
});
const existing = await readIdempotentResult(compositeKey);
if (existing) {
return res.status(existing.statusCode).json(existing.payload);
}
const payload = createCommandResponse(route, req.requestId, req.idempotencyKey);
const persisted = await writeIdempotentResult({
compositeKey,
userId: req.actor.uid,
route,
idempotencyKey: req.idempotencyKey,
payload,
statusCode: 200,
});
return res.status(persisted.statusCode).json(persisted.payload);
} catch (error) {
return next(error);
}
};
}
export function createCommandsRouter() {
export function createCommandsRouter(handlers = defaultHandlers) {
const router = Router();
router.post(
@@ -66,7 +88,14 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('orders.create', 'order'),
buildCommandHandler('orders.create', 'order')
async (req, res, next) => {
try {
const payload = parseBody(orderCreateSchema, req.body);
return await runIdempotentCommand(req, res, () => handlers.createOrder(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
@@ -74,7 +103,17 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('orders.update', 'order'),
buildCommandHandler('orders.update', 'order')
async (req, res, next) => {
try {
const payload = parseBody(orderUpdateSchema, {
...req.body,
orderId: req.params.orderId,
});
return await runIdempotentCommand(req, res, () => handlers.updateOrder(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
@@ -82,7 +121,17 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('orders.cancel', 'order'),
buildCommandHandler('orders.cancel', 'order')
async (req, res, next) => {
try {
const payload = parseBody(orderCancelSchema, {
...req.body,
orderId: req.params.orderId,
});
return await runIdempotentCommand(req, res, () => handlers.cancelOrder(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
@@ -90,7 +139,17 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('shifts.change-status', 'shift'),
buildCommandHandler('shifts.change-status', 'shift')
async (req, res, next) => {
try {
const payload = parseBody(shiftStatusChangeSchema, {
...req.body,
shiftId: req.params.shiftId,
});
return await runIdempotentCommand(req, res, () => handlers.changeShiftStatus(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
@@ -98,7 +157,17 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('shifts.assign-staff', 'shift'),
buildCommandHandler('shifts.assign-staff', 'shift')
async (req, res, next) => {
try {
const payload = parseBody(shiftAssignStaffSchema, {
...req.body,
shiftId: req.params.shiftId,
});
return await runIdempotentCommand(req, res, () => handlers.assignStaffToShift(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
@@ -106,7 +175,102 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('shifts.accept', 'shift'),
buildCommandHandler('shifts.accept', 'shift')
async (req, res, next) => {
try {
const payload = parseBody(shiftAcceptSchema, {
...req.body,
shiftId: req.params.shiftId,
});
return await runIdempotentCommand(req, res, () => handlers.acceptShift(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
'/attendance/clock-in',
requireAuth,
requireIdempotencyKey,
requirePolicy('attendance.clock-in', 'attendance'),
async (req, res, next) => {
try {
const payload = parseBody(attendanceCommandSchema, req.body);
return await runIdempotentCommand(req, res, () => handlers.clockIn(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
'/attendance/clock-out',
requireAuth,
requireIdempotencyKey,
requirePolicy('attendance.clock-out', 'attendance'),
async (req, res, next) => {
try {
const payload = parseBody(attendanceCommandSchema, req.body);
return await runIdempotentCommand(req, res, () => handlers.clockOut(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
'/businesses/:businessId/favorite-staff',
requireAuth,
requireIdempotencyKey,
requirePolicy('business.favorite-staff', 'staff'),
async (req, res, next) => {
try {
const payload = parseBody(favoriteStaffSchema, {
...req.body,
businessId: req.params.businessId,
});
return await runIdempotentCommand(req, res, () => handlers.addFavoriteStaff(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.delete(
'/businesses/:businessId/favorite-staff/:staffId',
requireAuth,
requireIdempotencyKey,
requirePolicy('business.unfavorite-staff', 'staff'),
async (req, res, next) => {
try {
const payload = parseBody(favoriteStaffSchema, {
...req.body,
businessId: req.params.businessId,
staffId: req.params.staffId,
});
return await runIdempotentCommand(req, res, () => handlers.removeFavoriteStaff(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
'/assignments/:assignmentId/reviews',
requireAuth,
requireIdempotencyKey,
requirePolicy('assignments.review-staff', 'assignment'),
async (req, res, next) => {
try {
const payload = parseBody(staffReviewSchema, {
...req.body,
assignmentId: req.params.assignmentId,
});
return await runIdempotentCommand(req, res, () => handlers.createStaffReview(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
return router;

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
export const healthRouter = Router();
@@ -13,3 +14,32 @@ function healthHandler(req, res) {
healthRouter.get('/health', healthHandler);
healthRouter.get('/healthz', healthHandler);
healthRouter.get('/readyz', async (req, res) => {
if (!isDatabaseConfigured()) {
return res.status(503).json({
ok: false,
service: 'krow-command-api',
status: 'DATABASE_NOT_CONFIGURED',
requestId: req.requestId,
});
}
try {
const ok = await checkDatabaseHealth();
return res.status(ok ? 200 : 503).json({
ok,
service: 'krow-command-api',
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
requestId: req.requestId,
});
} catch (error) {
return res.status(503).json({
ok: false,
service: 'krow-command-api',
status: 'DATABASE_UNAVAILABLE',
details: { message: error.message },
requestId: req.requestId,
});
}
});

View File

@@ -0,0 +1,472 @@
import { Router } from 'express';
import { AppError } from '../lib/errors.js';
import { requireAuth, requirePolicy } from '../middleware/auth.js';
import { requireIdempotencyKey } from '../middleware/idempotency.js';
import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js';
import {
addStaffBankAccount,
approveInvoice,
applyForShift,
assignHubManager,
assignHubNfc,
cancelLateWorker,
cancelClientOrder,
createEmergencyContact,
createClientOneTimeOrder,
createClientPermanentOrder,
createClientRecurringOrder,
createEditedOrderCopy,
createHub,
declinePendingShift,
disputeInvoice,
quickSetStaffAvailability,
rateWorkerFromCoverage,
registerClientPushToken,
registerStaffPushToken,
requestShiftSwap,
saveTaxFormDraft,
setupStaffProfile,
staffClockIn,
staffClockOut,
submitLocationStreamBatch,
submitTaxForm,
unregisterClientPushToken,
unregisterStaffPushToken,
updateEmergencyContact,
updateHub,
updatePersonalInfo,
updatePreferredLocations,
updatePrivacyVisibility,
updateProfileExperience,
updateStaffAvailabilityDay,
deleteHub,
acceptPendingShift,
} from '../services/mobile-command-service.js';
import {
availabilityDayUpdateSchema,
availabilityQuickSetSchema,
bankAccountCreateSchema,
cancelLateWorkerSchema,
clientOneTimeOrderSchema,
clientOrderCancelSchema,
clientOrderEditSchema,
clientPermanentOrderSchema,
clientRecurringOrderSchema,
coverageReviewSchema,
emergencyContactCreateSchema,
emergencyContactUpdateSchema,
hubAssignManagerSchema,
hubAssignNfcSchema,
hubCreateSchema,
hubDeleteSchema,
hubUpdateSchema,
invoiceApproveSchema,
invoiceDisputeSchema,
personalInfoUpdateSchema,
preferredLocationsUpdateSchema,
privacyUpdateSchema,
profileExperienceSchema,
pushTokenDeleteSchema,
pushTokenRegisterSchema,
shiftApplySchema,
shiftDecisionSchema,
staffClockInSchema,
staffClockOutSchema,
staffLocationBatchSchema,
staffProfileSetupSchema,
taxFormDraftSchema,
taxFormSubmitSchema,
} from '../contracts/commands/mobile.js';
const defaultHandlers = {
acceptPendingShift,
addStaffBankAccount,
approveInvoice,
applyForShift,
assignHubManager,
assignHubNfc,
cancelLateWorker,
cancelClientOrder,
createEmergencyContact,
createClientOneTimeOrder,
createClientPermanentOrder,
createClientRecurringOrder,
createEditedOrderCopy,
createHub,
declinePendingShift,
disputeInvoice,
quickSetStaffAvailability,
rateWorkerFromCoverage,
registerClientPushToken,
registerStaffPushToken,
requestShiftSwap,
saveTaxFormDraft,
setupStaffProfile,
staffClockIn,
staffClockOut,
submitLocationStreamBatch,
submitTaxForm,
unregisterClientPushToken,
unregisterStaffPushToken,
updateEmergencyContact,
updateHub,
updatePersonalInfo,
updatePreferredLocations,
updatePrivacyVisibility,
updateProfileExperience,
updateStaffAvailabilityDay,
deleteHub,
};
function parseBody(schema, body) {
const parsed = schema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid request payload', 400, {
issues: parsed.error.issues,
});
}
return parsed.data;
}
async function runIdempotentCommand(req, res, work) {
const route = `${req.baseUrl}${req.route.path}`;
const compositeKey = buildIdempotencyKey({
userId: req.actor.uid,
route,
idempotencyKey: req.idempotencyKey,
});
const existing = await readIdempotentResult(compositeKey);
if (existing) {
return res.status(existing.statusCode).json(existing.payload);
}
const payload = await work();
const responsePayload = {
...payload,
idempotencyKey: req.idempotencyKey,
requestId: req.requestId,
};
const persisted = await writeIdempotentResult({
compositeKey,
userId: req.actor.uid,
route,
idempotencyKey: req.idempotencyKey,
payload: responsePayload,
statusCode: 200,
});
return res.status(persisted.statusCode).json(persisted.payload);
}
function mobileCommand(route, { schema, policyAction, resource, handler, paramShape }) {
return [
route,
requireAuth,
requireIdempotencyKey,
requirePolicy(policyAction, resource),
async (req, res, next) => {
try {
const body = typeof paramShape === 'function'
? paramShape(req)
: req.body;
const payload = parseBody(schema, body);
return await runIdempotentCommand(req, res, () => handler(req.actor, payload));
} catch (error) {
return next(error);
}
},
];
}
export function createMobileCommandsRouter(handlers = defaultHandlers) {
const router = Router();
router.post(...mobileCommand('/client/orders/one-time', {
schema: clientOneTimeOrderSchema,
policyAction: 'orders.create',
resource: 'order',
handler: handlers.createClientOneTimeOrder,
}));
router.post(...mobileCommand('/client/orders/recurring', {
schema: clientRecurringOrderSchema,
policyAction: 'orders.create',
resource: 'order',
handler: handlers.createClientRecurringOrder,
}));
router.post(...mobileCommand('/client/orders/permanent', {
schema: clientPermanentOrderSchema,
policyAction: 'orders.create',
resource: 'order',
handler: handlers.createClientPermanentOrder,
}));
router.post(...mobileCommand('/client/orders/:orderId/edit', {
schema: clientOrderEditSchema,
policyAction: 'orders.update',
resource: 'order',
handler: handlers.createEditedOrderCopy,
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
}));
router.post(...mobileCommand('/client/orders/:orderId/cancel', {
schema: clientOrderCancelSchema,
policyAction: 'orders.cancel',
resource: 'order',
handler: handlers.cancelClientOrder,
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
}));
router.post(...mobileCommand('/client/hubs', {
schema: hubCreateSchema,
policyAction: 'client.hubs.create',
resource: 'hub',
handler: handlers.createHub,
}));
router.put(...mobileCommand('/client/hubs/:hubId', {
schema: hubUpdateSchema,
policyAction: 'client.hubs.update',
resource: 'hub',
handler: handlers.updateHub,
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
}));
router.delete(...mobileCommand('/client/hubs/:hubId', {
schema: hubDeleteSchema,
policyAction: 'client.hubs.delete',
resource: 'hub',
handler: handlers.deleteHub,
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
}));
router.post(...mobileCommand('/client/hubs/:hubId/assign-nfc', {
schema: hubAssignNfcSchema,
policyAction: 'client.hubs.update',
resource: 'hub',
handler: handlers.assignHubNfc,
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
}));
router.post(...mobileCommand('/client/hubs/:hubId/managers', {
schema: hubAssignManagerSchema,
policyAction: 'client.hubs.update',
resource: 'hub',
handler: handlers.assignHubManager,
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
}));
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', {
schema: invoiceApproveSchema,
policyAction: 'client.billing.write',
resource: 'invoice',
handler: handlers.approveInvoice,
paramShape: (req) => ({ invoiceId: req.params.invoiceId }),
}));
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/dispute', {
schema: invoiceDisputeSchema,
policyAction: 'client.billing.write',
resource: 'invoice',
handler: handlers.disputeInvoice,
paramShape: (req) => ({ ...req.body, invoiceId: req.params.invoiceId }),
}));
router.post(...mobileCommand('/client/coverage/reviews', {
schema: coverageReviewSchema,
policyAction: 'client.coverage.write',
resource: 'staff_review',
handler: handlers.rateWorkerFromCoverage,
}));
router.post(...mobileCommand('/client/coverage/late-workers/:assignmentId/cancel', {
schema: cancelLateWorkerSchema,
policyAction: 'client.coverage.write',
resource: 'assignment',
handler: handlers.cancelLateWorker,
paramShape: (req) => ({ ...req.body, assignmentId: req.params.assignmentId }),
}));
router.post(...mobileCommand('/staff/profile/setup', {
schema: staffProfileSetupSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.setupStaffProfile,
}));
router.post(...mobileCommand('/client/devices/push-tokens', {
schema: pushTokenRegisterSchema,
policyAction: 'notifications.device.write',
resource: 'device_push_token',
handler: handlers.registerClientPushToken,
}));
router.delete(...mobileCommand('/client/devices/push-tokens', {
schema: pushTokenDeleteSchema,
policyAction: 'notifications.device.write',
resource: 'device_push_token',
handler: handlers.unregisterClientPushToken,
paramShape: (req) => ({
...req.body,
tokenId: req.body?.tokenId || req.query.tokenId,
pushToken: req.body?.pushToken || req.query.pushToken,
reason: req.body?.reason || req.query.reason,
}),
}));
router.post(...mobileCommand('/staff/clock-in', {
schema: staffClockInSchema,
policyAction: 'attendance.clock-in',
resource: 'attendance',
handler: handlers.staffClockIn,
}));
router.post(...mobileCommand('/staff/clock-out', {
schema: staffClockOutSchema,
policyAction: 'attendance.clock-out',
resource: 'attendance',
handler: handlers.staffClockOut,
}));
router.post(...mobileCommand('/staff/location-streams', {
schema: staffLocationBatchSchema,
policyAction: 'attendance.location-stream.write',
resource: 'attendance',
handler: handlers.submitLocationStreamBatch,
}));
router.post(...mobileCommand('/staff/devices/push-tokens', {
schema: pushTokenRegisterSchema,
policyAction: 'notifications.device.write',
resource: 'device_push_token',
handler: handlers.registerStaffPushToken,
}));
router.delete(...mobileCommand('/staff/devices/push-tokens', {
schema: pushTokenDeleteSchema,
policyAction: 'notifications.device.write',
resource: 'device_push_token',
handler: handlers.unregisterStaffPushToken,
paramShape: (req) => ({
...req.body,
tokenId: req.body?.tokenId || req.query.tokenId,
pushToken: req.body?.pushToken || req.query.pushToken,
reason: req.body?.reason || req.query.reason,
}),
}));
router.put(...mobileCommand('/staff/availability', {
schema: availabilityDayUpdateSchema,
policyAction: 'staff.availability.write',
resource: 'staff',
handler: handlers.updateStaffAvailabilityDay,
}));
router.post(...mobileCommand('/staff/availability/quick-set', {
schema: availabilityQuickSetSchema,
policyAction: 'staff.availability.write',
resource: 'staff',
handler: handlers.quickSetStaffAvailability,
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/apply', {
schema: shiftApplySchema,
policyAction: 'staff.shifts.apply',
resource: 'shift',
handler: handlers.applyForShift,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/accept', {
schema: shiftDecisionSchema,
policyAction: 'staff.shifts.accept',
resource: 'shift',
handler: handlers.acceptPendingShift,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/decline', {
schema: shiftDecisionSchema,
policyAction: 'staff.shifts.decline',
resource: 'shift',
handler: handlers.declinePendingShift,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/request-swap', {
schema: shiftDecisionSchema,
policyAction: 'staff.shifts.swap',
resource: 'shift',
handler: handlers.requestShiftSwap,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.put(...mobileCommand('/staff/profile/personal-info', {
schema: personalInfoUpdateSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.updatePersonalInfo,
}));
router.put(...mobileCommand('/staff/profile/experience', {
schema: profileExperienceSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.updateProfileExperience,
}));
router.put(...mobileCommand('/staff/profile/locations', {
schema: preferredLocationsUpdateSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.updatePreferredLocations,
}));
router.post(...mobileCommand('/staff/profile/emergency-contacts', {
schema: emergencyContactCreateSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.createEmergencyContact,
}));
router.put(...mobileCommand('/staff/profile/emergency-contacts/:contactId', {
schema: emergencyContactUpdateSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.updateEmergencyContact,
paramShape: (req) => ({ ...req.body, contactId: req.params.contactId }),
}));
router.put(...mobileCommand('/staff/profile/tax-forms/:formType', {
schema: taxFormDraftSchema,
policyAction: 'staff.profile.write',
resource: 'staff_document',
handler: handlers.saveTaxFormDraft,
paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }),
}));
router.post(...mobileCommand('/staff/profile/tax-forms/:formType/submit', {
schema: taxFormSubmitSchema,
policyAction: 'staff.profile.write',
resource: 'staff_document',
handler: handlers.submitTaxForm,
paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }),
}));
router.post(...mobileCommand('/staff/profile/bank-accounts', {
schema: bankAccountCreateSchema,
policyAction: 'staff.profile.write',
resource: 'account',
handler: handlers.addStaffBankAccount,
}));
router.put(...mobileCommand('/staff/profile/privacy', {
schema: privacyUpdateSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.updatePrivacyVisibility,
}));
return router;
}

View File

@@ -0,0 +1,111 @@
import { AppError } from '../lib/errors.js';
import { query } from './db.js';
export async function loadActorContext(uid) {
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
query(
`
SELECT id AS "userId", email, display_name AS "displayName", phone, status
FROM users
WHERE id = $1
`,
[uid]
),
query(
`
SELECT tm.id AS "membershipId",
tm.tenant_id AS "tenantId",
tm.base_role AS role,
t.name AS "tenantName",
t.slug AS "tenantSlug"
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = $1
AND tm.membership_status = 'ACTIVE'
ORDER BY tm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT bm.id AS "membershipId",
bm.business_id AS "businessId",
bm.business_role AS role,
b.business_name AS "businessName",
b.slug AS "businessSlug",
bm.tenant_id AS "tenantId"
FROM business_memberships bm
JOIN businesses b ON b.id = bm.business_id
WHERE bm.user_id = $1
AND bm.membership_status = 'ACTIVE'
ORDER BY bm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT vm.id AS "membershipId",
vm.vendor_id AS "vendorId",
vm.vendor_role AS role,
v.company_name AS "vendorName",
v.slug AS "vendorSlug",
vm.tenant_id AS "tenantId"
FROM vendor_memberships vm
JOIN vendors v ON v.id = vm.vendor_id
WHERE vm.user_id = $1
AND vm.membership_status = 'ACTIVE'
ORDER BY vm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT s.id AS "staffId",
s.tenant_id AS "tenantId",
s.full_name AS "fullName",
s.email,
s.phone,
s.primary_role AS "primaryRole",
s.onboarding_status AS "onboardingStatus",
s.status,
s.metadata,
w.id AS "workforceId",
w.vendor_id AS "vendorId",
w.workforce_number AS "workforceNumber"
FROM staffs s
LEFT JOIN workforce w ON w.staff_id = s.id
WHERE s.user_id = $1
ORDER BY s.created_at ASC
LIMIT 1
`,
[uid]
),
]);
return {
user: userResult.rows[0] || null,
tenant: tenantResult.rows[0] || null,
business: businessResult.rows[0] || null,
vendor: vendorResult.rows[0] || null,
staff: staffResult.rows[0] || null,
};
}
export async function requireClientContext(uid) {
const context = await loadActorContext(uid);
if (!context.user || !context.tenant || !context.business) {
throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid });
}
return context;
}
export async function requireStaffContext(uid) {
const context = await loadActorContext(uid);
if (!context.user || !context.tenant || !context.staff) {
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
}
return context;
}

View File

@@ -0,0 +1,84 @@
export async function recordGeofenceIncident(client, {
assignment,
actorUserId,
locationStreamBatchId = null,
incidentType,
severity = 'WARNING',
status = 'OPEN',
effectiveClockInMode = null,
sourceType = null,
nfcTagUid = null,
deviceId = null,
latitude = null,
longitude = null,
accuracyMeters = null,
distanceToClockPointMeters = null,
withinGeofence = null,
overrideReason = null,
message = null,
occurredAt = null,
metadata = {},
}) {
const result = await client.query(
`
INSERT INTO geofence_incidents (
tenant_id,
business_id,
vendor_id,
shift_id,
assignment_id,
staff_id,
actor_user_id,
location_stream_batch_id,
incident_type,
severity,
status,
effective_clock_in_mode,
source_type,
nfc_tag_uid,
device_id,
latitude,
longitude,
accuracy_meters,
distance_to_clock_point_meters,
within_geofence,
override_reason,
message,
occurred_at,
metadata
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, COALESCE($23::timestamptz, NOW()), $24::jsonb
)
RETURNING id
`,
[
assignment.tenant_id,
assignment.business_id,
assignment.vendor_id,
assignment.shift_id,
assignment.id,
assignment.staff_id,
actorUserId,
locationStreamBatchId,
incidentType,
severity,
status,
effectiveClockInMode,
sourceType,
nfcTagUid,
deviceId,
latitude,
longitude,
accuracyMeters,
distanceToClockPointMeters,
withinGeofence,
overrideReason,
message,
occurredAt,
JSON.stringify(metadata || {}),
]
);
return result.rows[0].id;
}

View File

@@ -0,0 +1,38 @@
import { Storage } from '@google-cloud/storage';
const storage = new Storage();
function resolvePrivateBucket() {
return process.env.PRIVATE_BUCKET || null;
}
export async function uploadAttendanceSecurityLog({
tenantId,
staffId,
assignmentId,
proofId,
payload,
}) {
const bucket = resolvePrivateBucket();
if (!bucket) {
return null;
}
const objectPath = [
'attendance-security',
tenantId,
staffId,
assignmentId,
`${proofId}.json`,
].join('/');
await storage.bucket(bucket).file(objectPath).save(JSON.stringify(payload), {
resumable: false,
contentType: 'application/json',
metadata: {
cacheControl: 'private, max-age=0',
},
});
return `gs://${bucket}/${objectPath}`;
}

View File

@@ -0,0 +1,285 @@
import crypto from 'node:crypto';
import { AppError } from '../lib/errors.js';
import { uploadAttendanceSecurityLog } from './attendance-security-log-storage.js';
function parseBooleanEnv(name, fallback = false) {
const value = process.env[name];
if (value == null) return fallback;
return value === 'true';
}
function parseIntEnv(name, fallback) {
const parsed = Number.parseInt(`${process.env[name] || fallback}`, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function hashValue(value) {
if (!value) return null;
return crypto.createHash('sha256').update(`${value}`).digest('hex');
}
function normalizeTimestamp(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
}
function buildRequestFingerprint({ assignmentId, actorUserId, eventType, sourceType, deviceId, nfcTagUid, capturedAt }) {
const fingerprintSource = [assignmentId, actorUserId, eventType, sourceType, deviceId || '', nfcTagUid || '', capturedAt || ''].join('|');
return hashValue(fingerprintSource);
}
async function persistProofRecord(client, {
proofId,
assignment,
actor,
payload,
eventType,
proofNonce,
proofTimestamp,
requestFingerprint,
attestationProvider,
attestationTokenHash,
attestationStatus,
attestationReason,
objectUri,
metadata,
}) {
await client.query(
`
INSERT INTO attendance_security_proofs (
id,
tenant_id,
assignment_id,
shift_id,
staff_id,
actor_user_id,
event_type,
source_type,
device_id,
nfc_tag_uid,
proof_nonce,
proof_timestamp,
request_fingerprint,
attestation_provider,
attestation_token_hash,
attestation_status,
attestation_reason,
object_uri,
metadata
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::timestamptz, $13, $14, $15, $16, $17, $18, $19::jsonb
)
`,
[
proofId,
assignment.tenant_id,
assignment.id,
assignment.shift_id,
assignment.staff_id,
actor.uid,
eventType,
payload.sourceType,
payload.deviceId || null,
payload.nfcTagUid || null,
proofNonce,
proofTimestamp,
requestFingerprint,
attestationProvider,
attestationTokenHash,
attestationStatus,
attestationReason,
objectUri,
JSON.stringify(metadata || {}),
]
);
}
function buildBaseMetadata({ payload, capturedAt, securityCode = null, securityReason = null }) {
return {
capturedAt,
proofTimestamp: payload.proofTimestamp || null,
rawPayload: payload.rawPayload || {},
securityCode,
securityReason,
notes: payload.notes || null,
};
}
export async function recordAttendanceSecurityProof(client, {
assignment,
actor,
payload,
eventType,
capturedAt,
}) {
const proofId = crypto.randomUUID();
const proofNonce = payload.proofNonce || null;
const proofTimestamp = normalizeTimestamp(payload.proofTimestamp || payload.capturedAt || capturedAt);
const requestFingerprint = buildRequestFingerprint({
assignmentId: assignment.id,
actorUserId: actor.uid,
eventType,
sourceType: payload.sourceType,
deviceId: payload.deviceId,
nfcTagUid: payload.nfcTagUid,
capturedAt,
});
const attestationProvider = payload.attestationProvider || null;
const attestationTokenHash = hashValue(payload.attestationToken || null);
const requiresNonce = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_PROOF_NONCE', false);
const requiresDeviceId = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_DEVICE_ID', false);
const requiresAttestation = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_ATTESTATION', false);
const maxAgeSeconds = parseIntEnv('NFC_PROOF_MAX_AGE_SECONDS', 120);
const baseMetadata = buildBaseMetadata({ payload, capturedAt });
let securityCode = null;
let securityReason = null;
let attestationStatus = payload.sourceType === 'NFC' ? 'NOT_PROVIDED' : 'BYPASSED';
let attestationReason = null;
if (requiresDeviceId && !payload.deviceId) {
securityCode = 'DEVICE_ID_REQUIRED';
securityReason = 'NFC proof must include a deviceId';
} else if (requiresNonce && !proofNonce) {
securityCode = 'NFC_PROOF_NONCE_REQUIRED';
securityReason = 'NFC proof must include a proofNonce';
} else if (proofTimestamp) {
const skewSeconds = Math.abs(new Date(capturedAt).getTime() - new Date(proofTimestamp).getTime()) / 1000;
if (skewSeconds > maxAgeSeconds) {
securityCode = 'NFC_PROOF_TIMESTAMP_EXPIRED';
securityReason = `NFC proof timestamp exceeded the ${maxAgeSeconds}-second window`;
}
}
if (!securityCode && proofNonce) {
const replayCheck = await client.query(
`
SELECT id
FROM attendance_security_proofs
WHERE tenant_id = $1
AND proof_nonce = $2
LIMIT 1
`,
[assignment.tenant_id, proofNonce]
);
if (replayCheck.rowCount > 0) {
securityCode = 'NFC_REPLAY_DETECTED';
securityReason = 'This NFC proof nonce was already used';
}
}
if (payload.sourceType === 'NFC') {
if (attestationProvider || payload.attestationToken) {
if (!attestationProvider || !payload.attestationToken) {
securityCode = securityCode || 'ATTESTATION_PAYLOAD_INVALID';
securityReason = securityReason || 'attestationProvider and attestationToken must be provided together';
attestationStatus = 'REJECTED';
attestationReason = 'Incomplete attestation payload';
} else {
attestationStatus = 'RECORDED_UNVERIFIED';
attestationReason = 'Attestation payload recorded; server-side verifier not yet enabled';
}
}
if (requiresAttestation && attestationStatus !== 'RECORDED_UNVERIFIED' && attestationStatus !== 'VERIFIED') {
securityCode = securityCode || 'ATTESTATION_REQUIRED';
securityReason = securityReason || 'NFC proof requires device attestation';
attestationStatus = 'REJECTED';
attestationReason = 'Device attestation is required for NFC proof';
}
if (requiresAttestation && attestationStatus === 'RECORDED_UNVERIFIED') {
securityCode = securityCode || 'ATTESTATION_NOT_VERIFIED';
securityReason = securityReason || 'NFC proof attestation cannot be trusted until verifier is enabled';
attestationStatus = 'REJECTED';
attestationReason = 'Recorded attestation is not yet verified';
}
}
const objectUri = await uploadAttendanceSecurityLog({
tenantId: assignment.tenant_id,
staffId: assignment.staff_id,
assignmentId: assignment.id,
proofId,
payload: {
assignmentId: assignment.id,
shiftId: assignment.shift_id,
staffId: assignment.staff_id,
actorUserId: actor.uid,
eventType,
sourceType: payload.sourceType,
proofNonce,
proofTimestamp,
deviceId: payload.deviceId || null,
nfcTagUid: payload.nfcTagUid || null,
requestFingerprint,
attestationProvider,
attestationTokenHash,
attestationStatus,
attestationReason,
capturedAt,
metadata: {
...baseMetadata,
securityCode,
securityReason,
},
},
});
try {
await persistProofRecord(client, {
proofId,
assignment,
actor,
payload,
eventType,
proofNonce,
proofTimestamp,
requestFingerprint,
attestationProvider,
attestationTokenHash,
attestationStatus,
attestationReason,
objectUri,
metadata: {
...baseMetadata,
securityCode,
securityReason,
},
});
} catch (error) {
if (error?.code === '23505' && proofNonce) {
throw new AppError('ATTENDANCE_SECURITY_FAILED', 'This NFC proof nonce was already used', 409, {
assignmentId: assignment.id,
proofNonce,
securityCode: 'NFC_REPLAY_DETECTED',
objectUri,
});
}
throw error;
}
if (securityCode) {
throw new AppError('ATTENDANCE_SECURITY_FAILED', securityReason, 409, {
assignmentId: assignment.id,
proofId,
proofNonce,
securityCode,
objectUri,
});
}
return {
proofId,
proofNonce,
proofTimestamp,
attestationStatus,
attestationReason,
objectUri,
};
}

View File

@@ -0,0 +1,203 @@
export const CLOCK_IN_MODES = {
NFC_REQUIRED: 'NFC_REQUIRED',
GEO_REQUIRED: 'GEO_REQUIRED',
EITHER: 'EITHER',
};
function toRadians(value) {
return (value * Math.PI) / 180;
}
export function distanceMeters(from, to) {
if (
from?.latitude == null
|| from?.longitude == null
|| to?.latitude == null
|| to?.longitude == null
) {
return null;
}
const earthRadiusMeters = 6371000;
const dLat = toRadians(Number(to.latitude) - Number(from.latitude));
const dLon = toRadians(Number(to.longitude) - Number(from.longitude));
const lat1 = toRadians(Number(from.latitude));
const lat2 = toRadians(Number(to.latitude));
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(earthRadiusMeters * c);
}
export function resolveEffectiveClockInPolicy(record = {}) {
return {
mode: record.clock_in_mode
|| record.shift_clock_in_mode
|| record.default_clock_in_mode
|| CLOCK_IN_MODES.EITHER,
allowOverride: record.allow_clock_in_override
?? record.shift_allow_clock_in_override
?? record.default_allow_clock_in_override
?? true,
};
}
function validateNfc(expectedNfcTag, payload) {
if (payload.sourceType !== 'NFC') {
return {
ok: false,
code: 'NFC_REQUIRED',
reason: 'Clock-in requires NFC',
overrideable: false,
};
}
if (!payload.nfcTagUid) {
return {
ok: false,
code: 'NFC_REQUIRED',
reason: 'NFC tag is required',
overrideable: false,
};
}
if (!expectedNfcTag) {
return {
ok: false,
code: 'NFC_NOT_CONFIGURED',
reason: 'Hub is not configured for NFC clock-in',
overrideable: false,
};
}
if (payload.nfcTagUid !== expectedNfcTag) {
return {
ok: false,
code: 'NFC_MISMATCH',
reason: 'NFC tag mismatch',
overrideable: false,
};
}
return {
ok: true,
distance: null,
withinGeofence: null,
};
}
function validateGeo(expectedPoint, radius, payload) {
if (payload.latitude == null || payload.longitude == null) {
return {
ok: false,
code: 'LOCATION_REQUIRED',
reason: 'Location coordinates are required',
overrideable: true,
distance: null,
withinGeofence: null,
};
}
if (
expectedPoint?.latitude == null
|| expectedPoint?.longitude == null
|| radius == null
) {
return {
ok: false,
code: 'GEOFENCE_NOT_CONFIGURED',
reason: 'Clock-in geofence is not configured',
overrideable: true,
distance: null,
withinGeofence: null,
};
}
const distance = distanceMeters({
latitude: payload.latitude,
longitude: payload.longitude,
}, expectedPoint);
if (distance == null) {
return {
ok: false,
code: 'LOCATION_REQUIRED',
reason: 'Location coordinates are required',
overrideable: true,
distance: null,
withinGeofence: null,
};
}
if (distance > radius) {
return {
ok: false,
code: 'OUTSIDE_GEOFENCE',
reason: `Outside geofence by ${distance - radius} meters`,
overrideable: true,
distance,
withinGeofence: false,
};
}
return {
ok: true,
distance,
withinGeofence: true,
};
}
export function evaluateClockInAttempt(record, payload) {
const policy = resolveEffectiveClockInPolicy(record);
const expectedPoint = {
latitude: record.expected_latitude,
longitude: record.expected_longitude,
};
const radius = record.geofence_radius_meters;
const expectedNfcTag = record.expected_nfc_tag_uid;
let proofResult;
if (policy.mode === CLOCK_IN_MODES.NFC_REQUIRED) {
proofResult = validateNfc(expectedNfcTag, payload);
} else if (policy.mode === CLOCK_IN_MODES.GEO_REQUIRED) {
proofResult = validateGeo(expectedPoint, radius, payload);
} else {
proofResult = payload.sourceType === 'NFC'
? validateNfc(expectedNfcTag, payload)
: validateGeo(expectedPoint, radius, payload);
}
if (proofResult.ok) {
return {
effectiveClockInMode: policy.mode,
allowOverride: policy.allowOverride,
validationStatus: 'ACCEPTED',
validationCode: null,
validationReason: null,
distance: proofResult.distance ?? null,
withinGeofence: proofResult.withinGeofence ?? null,
overrideUsed: false,
overrideable: false,
};
}
const rawOverrideReason = payload.overrideReason || payload.notes || null;
const overrideReason = typeof rawOverrideReason === 'string' ? rawOverrideReason.trim() : '';
const canOverride = policy.allowOverride
&& proofResult.overrideable === true
&& overrideReason.length > 0;
return {
effectiveClockInMode: policy.mode,
allowOverride: policy.allowOverride,
validationStatus: canOverride ? 'FLAGGED' : 'REJECTED',
validationCode: proofResult.code,
validationReason: proofResult.reason,
distance: proofResult.distance ?? null,
withinGeofence: proofResult.withinGeofence ?? null,
overrideUsed: canOverride,
overrideReason: overrideReason || null,
overrideable: proofResult.overrideable === true,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
import pg from 'pg';
const { Pool, types } = pg;
function parseNumericDatabaseValue(value) {
if (value == null) return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
let pool;
function parseIntOrDefault(value, fallback) {
const parsed = Number.parseInt(`${value || fallback}`, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function resolveDatabasePoolConfig({
preferIdempotency = false,
maxEnvVar = 'DB_POOL_MAX',
} = {}) {
const primaryUrl = preferIdempotency
? process.env.IDEMPOTENCY_DATABASE_URL || process.env.DATABASE_URL
: process.env.DATABASE_URL || process.env.IDEMPOTENCY_DATABASE_URL;
if (primaryUrl) {
return {
connectionString: primaryUrl,
max: parseIntOrDefault(process.env[maxEnvVar], 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
const user = process.env.DB_USER;
const password = process.env.DB_PASSWORD;
const database = process.env.DB_NAME;
const host = process.env.DB_HOST || (
process.env.INSTANCE_CONNECTION_NAME
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
: ''
);
if (!user || password == null || !database || !host) {
return null;
}
return {
host,
port: parseIntOrDefault(process.env.DB_PORT, 5432),
user,
password,
database,
max: parseIntOrDefault(process.env[maxEnvVar], 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
export function isDatabaseConfigured() {
return Boolean(resolveDatabasePoolConfig());
}
function getPool() {
if (!pool) {
const resolved = resolveDatabasePoolConfig();
if (!resolved) {
throw new Error('Database connection settings are required');
}
pool = new Pool(resolved);
}
return pool;
}
export async function query(text, params = []) {
return getPool().query(text, params);
}
export async function withTransaction(work) {
const client = await getPool().connect();
try {
await client.query('BEGIN');
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
export async function checkDatabaseHealth() {
const result = await query('SELECT 1 AS ok');
return result.rows[0]?.ok === 1;
}
export async function closePool() {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -0,0 +1,19 @@
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
import { getMessaging } from 'firebase-admin/messaging';
export function ensureFirebaseAdminApp() {
if (getApps().length === 0) {
initializeApp({ credential: applicationDefault() });
}
}
export function getFirebaseAdminAuth() {
ensureFirebaseAdminApp();
return getAuth();
}
export function getFirebaseAdminMessaging() {
ensureFirebaseAdminApp();
return getMessaging();
}

View File

@@ -1,13 +1,5 @@
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
function ensureAdminApp() {
if (getApps().length === 0) {
initializeApp({ credential: applicationDefault() });
}
}
import { getFirebaseAdminAuth } from './firebase-admin.js';
export async function verifyFirebaseToken(token) {
ensureAdminApp();
return getAuth().verifyIdToken(token);
return getFirebaseAdminAuth().verifyIdToken(token);
}

View File

@@ -1,4 +1,5 @@
import { Pool } from 'pg';
import { resolveDatabasePoolConfig } from './db.js';
const DEFAULT_TTL_SECONDS = Number.parseInt(process.env.IDEMPOTENCY_TTL_SECONDS || '86400', 10);
const CLEANUP_EVERY_OPS = Number.parseInt(process.env.IDEMPOTENCY_CLEANUP_EVERY_OPS || '100', 10);
@@ -12,9 +13,9 @@ function shouldUseSqlStore() {
return false;
}
if (mode === 'sql') {
return true;
return Boolean(resolveDatabasePoolConfig({ preferIdempotency: true, maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX' }));
}
return Boolean(process.env.IDEMPOTENCY_DATABASE_URL);
return Boolean(resolveDatabasePoolConfig({ preferIdempotency: true, maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX' }));
}
function gcExpiredMemoryRecords(now = Date.now()) {
@@ -55,15 +56,16 @@ function createMemoryAdapter() {
}
async function createSqlAdapter() {
const connectionString = process.env.IDEMPOTENCY_DATABASE_URL;
if (!connectionString) {
throw new Error('IDEMPOTENCY_DATABASE_URL is required for sql idempotency store');
const poolConfig = resolveDatabasePoolConfig({
preferIdempotency: true,
maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX',
});
if (!poolConfig) {
throw new Error('Database connection settings are required for sql idempotency store');
}
const pool = new Pool({
connectionString,
max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10),
});
const pool = new Pool(poolConfig);
await pool.query(`
CREATE TABLE IF NOT EXISTS command_idempotency (

View File

@@ -0,0 +1,38 @@
import { Storage } from '@google-cloud/storage';
const storage = new Storage();
function resolvePrivateBucket() {
return process.env.PRIVATE_BUCKET || null;
}
export async function uploadLocationBatch({
tenantId,
staffId,
assignmentId,
batchId,
payload,
}) {
const bucket = resolvePrivateBucket();
if (!bucket) {
return null;
}
const objectPath = [
'location-streams',
tenantId,
staffId,
assignmentId,
`${batchId}.json`,
].join('/');
await storage.bucket(bucket).file(objectPath).save(JSON.stringify(payload), {
resumable: false,
contentType: 'application/json',
metadata: {
cacheControl: 'private, max-age=0',
},
});
return `gs://${bucket}/${objectPath}`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
import crypto from 'node:crypto';
export const PUSH_PROVIDERS = {
FCM: 'FCM',
APNS: 'APNS',
WEB_PUSH: 'WEB_PUSH',
};
export const PUSH_PLATFORMS = {
IOS: 'IOS',
ANDROID: 'ANDROID',
WEB: 'WEB',
};
export function hashPushToken(pushToken) {
return crypto.createHash('sha256').update(`${pushToken || ''}`).digest('hex');
}
export async function registerPushToken(client, {
tenantId,
userId,
staffId = null,
businessMembershipId = null,
vendorMembershipId = null,
provider = PUSH_PROVIDERS.FCM,
platform,
pushToken,
deviceId = null,
appVersion = null,
appBuild = null,
locale = null,
timezone = null,
notificationsEnabled = true,
metadata = {},
}) {
const tokenHash = hashPushToken(pushToken);
const result = await client.query(
`
INSERT INTO device_push_tokens (
tenant_id,
user_id,
staff_id,
business_membership_id,
vendor_membership_id,
provider,
platform,
push_token,
token_hash,
device_id,
app_version,
app_build,
locale,
timezone,
notifications_enabled,
invalidated_at,
invalidation_reason,
last_registered_at,
last_seen_at,
metadata
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NULL, NULL, NOW(), NOW(), $16::jsonb
)
ON CONFLICT (provider, token_hash) DO UPDATE
SET tenant_id = EXCLUDED.tenant_id,
user_id = EXCLUDED.user_id,
staff_id = EXCLUDED.staff_id,
business_membership_id = EXCLUDED.business_membership_id,
vendor_membership_id = EXCLUDED.vendor_membership_id,
platform = EXCLUDED.platform,
push_token = EXCLUDED.push_token,
device_id = EXCLUDED.device_id,
app_version = EXCLUDED.app_version,
app_build = EXCLUDED.app_build,
locale = EXCLUDED.locale,
timezone = EXCLUDED.timezone,
notifications_enabled = EXCLUDED.notifications_enabled,
invalidated_at = NULL,
invalidation_reason = NULL,
last_registered_at = NOW(),
last_seen_at = NOW(),
metadata = COALESCE(device_push_tokens.metadata, '{}'::jsonb) || EXCLUDED.metadata,
updated_at = NOW()
RETURNING id,
tenant_id AS "tenantId",
user_id AS "userId",
staff_id AS "staffId",
business_membership_id AS "businessMembershipId",
vendor_membership_id AS "vendorMembershipId",
provider,
platform,
device_id AS "deviceId",
notifications_enabled AS "notificationsEnabled"
`,
[
tenantId,
userId,
staffId,
businessMembershipId,
vendorMembershipId,
provider,
platform,
pushToken,
tokenHash,
deviceId,
appVersion,
appBuild,
locale,
timezone,
notificationsEnabled,
JSON.stringify(metadata || {}),
]
);
return result.rows[0];
}
export async function unregisterPushToken(client, {
tenantId,
userId,
tokenId = null,
pushToken = null,
reason = 'USER_REQUESTED',
}) {
const tokenHash = pushToken ? hashPushToken(pushToken) : null;
const result = await client.query(
`
UPDATE device_push_tokens
SET notifications_enabled = FALSE,
invalidated_at = NOW(),
invalidation_reason = $4,
updated_at = NOW()
WHERE tenant_id = $1
AND user_id = $2
AND (
($3::uuid IS NOT NULL AND id = $3::uuid)
OR
($5::text IS NOT NULL AND token_hash = $5::text)
)
RETURNING id,
provider,
platform,
device_id AS "deviceId"
`,
[tenantId, userId, tokenId, reason, tokenHash]
);
return result.rows;
}
export async function resolveNotificationTargetTokens(client, notification) {
const result = await client.query(
`
WITH recipient_users AS (
SELECT $2::text AS user_id
WHERE $2::text IS NOT NULL
UNION
SELECT bm.user_id
FROM business_memberships bm
WHERE $3::uuid IS NOT NULL
AND bm.id = $3::uuid
UNION
SELECT s.user_id
FROM staffs s
WHERE $4::uuid IS NOT NULL
AND s.id = $4::uuid
)
SELECT
dpt.id,
dpt.user_id AS "userId",
dpt.staff_id AS "staffId",
dpt.provider,
dpt.platform,
dpt.push_token AS "pushToken",
dpt.device_id AS "deviceId",
dpt.metadata
FROM device_push_tokens dpt
JOIN recipient_users ru ON ru.user_id = dpt.user_id
WHERE dpt.tenant_id = $1
AND dpt.notifications_enabled = TRUE
AND dpt.invalidated_at IS NULL
ORDER BY dpt.last_seen_at DESC, dpt.created_at DESC
`,
[
notification.tenant_id,
notification.recipient_user_id,
notification.recipient_business_membership_id,
notification.recipient_staff_id,
]
);
return result.rows;
}
export async function markPushTokenInvalid(client, tokenId, reason) {
await client.query(
`
UPDATE device_push_tokens
SET notifications_enabled = FALSE,
invalidated_at = NOW(),
invalidation_reason = $2,
updated_at = NOW()
WHERE id = $1
`,
[tokenId, reason]
);
}
export async function touchPushTokenDelivery(client, tokenId) {
await client.query(
`
UPDATE device_push_tokens
SET last_delivery_at = NOW(),
last_seen_at = NOW(),
updated_at = NOW()
WHERE id = $1
`,
[tokenId]
);
}

View File

@@ -0,0 +1,348 @@
import { query, withTransaction } from './db.js';
import { enqueueNotification } from './notification-outbox.js';
import {
markPushTokenInvalid,
resolveNotificationTargetTokens,
touchPushTokenDelivery,
} from './notification-device-tokens.js';
import { createPushSender } from './notification-fcm.js';
function parseIntEnv(name, fallback) {
const parsed = Number.parseInt(`${process.env[name] || fallback}`, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseBooleanEnv(name, fallback = false) {
const value = process.env[name];
if (value == null) return fallback;
return value === 'true';
}
function parseListEnv(name, fallback = []) {
const raw = process.env[name];
if (!raw) return fallback;
return raw.split(',').map((value) => Number.parseInt(value.trim(), 10)).filter((value) => Number.isFinite(value) && value >= 0);
}
export function computeRetryDelayMinutes(attemptNumber) {
return Math.min(5 * (2 ** Math.max(attemptNumber - 1, 0)), 60);
}
async function recordDeliveryAttempt(client, {
notificationId,
devicePushTokenId = null,
provider,
deliveryStatus,
providerMessageId = null,
attemptNumber,
errorCode = null,
errorMessage = null,
responsePayload = {},
sentAt = null,
}) {
await client.query(
`
INSERT INTO notification_deliveries (
notification_outbox_id,
device_push_token_id,
provider,
delivery_status,
provider_message_id,
attempt_number,
error_code,
error_message,
response_payload,
sent_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::timestamptz)
`,
[
notificationId,
devicePushTokenId,
provider,
deliveryStatus,
providerMessageId,
attemptNumber,
errorCode,
errorMessage,
JSON.stringify(responsePayload || {}),
sentAt,
]
);
}
async function claimDueNotifications(limit) {
return withTransaction(async (client) => {
const result = await client.query(
`
WITH due AS (
SELECT id
FROM notification_outbox
WHERE (
status = 'PENDING'
OR (
status = 'PROCESSING'
AND updated_at <= NOW() - INTERVAL '10 minutes'
)
)
AND scheduled_at <= NOW()
ORDER BY
CASE priority
WHEN 'CRITICAL' THEN 1
WHEN 'HIGH' THEN 2
WHEN 'NORMAL' THEN 3
ELSE 4
END,
scheduled_at ASC,
created_at ASC
LIMIT $1
FOR UPDATE SKIP LOCKED
)
UPDATE notification_outbox n
SET status = 'PROCESSING',
attempts = n.attempts + 1,
updated_at = NOW()
FROM due
WHERE n.id = due.id
RETURNING n.*
`,
[limit]
);
return result.rows;
});
}
async function markNotificationSent(notificationId) {
await query(
`
UPDATE notification_outbox
SET status = 'SENT',
sent_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE id = $1
`,
[notificationId]
);
}
async function markNotificationFailed(notificationId, lastError) {
await query(
`
UPDATE notification_outbox
SET status = 'FAILED',
last_error = $2,
updated_at = NOW()
WHERE id = $1
`,
[notificationId, lastError]
);
}
async function requeueNotification(notificationId, attemptNumber, lastError) {
const delayMinutes = computeRetryDelayMinutes(attemptNumber);
await query(
`
UPDATE notification_outbox
SET status = 'PENDING',
last_error = $2,
scheduled_at = NOW() + (($3::text || ' minutes')::interval),
updated_at = NOW()
WHERE id = $1
`,
[notificationId, lastError, String(delayMinutes)]
);
}
async function enqueueDueShiftReminders() {
const enabled = parseBooleanEnv('SHIFT_REMINDERS_ENABLED', true);
if (!enabled) {
return { enqueued: 0 };
}
const leadMinutesList = parseListEnv('SHIFT_REMINDER_LEAD_MINUTES', [60, 15]);
const reminderWindowMinutes = parseIntEnv('SHIFT_REMINDER_WINDOW_MINUTES', 5);
let enqueued = 0;
await withTransaction(async (client) => {
for (const leadMinutes of leadMinutesList) {
const result = await client.query(
`
SELECT
a.id,
a.tenant_id,
a.business_id,
a.shift_id,
a.staff_id,
s.title AS shift_title,
s.starts_at,
cp.label AS hub_label,
st.user_id
FROM assignments a
JOIN shifts s ON s.id = a.shift_id
JOIN staffs st ON st.id = a.staff_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE a.status IN ('ASSIGNED', 'ACCEPTED')
AND st.user_id IS NOT NULL
AND s.starts_at >= NOW() + (($1::int - $2::int) * INTERVAL '1 minute')
AND s.starts_at < NOW() + (($1::int + $2::int) * INTERVAL '1 minute')
`,
[leadMinutes, reminderWindowMinutes]
);
for (const row of result.rows) {
const dedupeKey = [
'notify',
'SHIFT_START_REMINDER',
row.id,
leadMinutes,
].join(':');
await enqueueNotification(client, {
tenantId: row.tenant_id,
businessId: row.business_id,
shiftId: row.shift_id,
assignmentId: row.id,
audienceType: 'USER',
recipientUserId: row.user_id,
channel: 'PUSH',
notificationType: 'SHIFT_START_REMINDER',
priority: leadMinutes <= 15 ? 'HIGH' : 'NORMAL',
dedupeKey,
subject: leadMinutes <= 15 ? 'Shift starting soon' : 'Upcoming shift reminder',
body: `${row.shift_title || 'Your shift'} at ${row.hub_label || 'the assigned hub'} starts in ${leadMinutes} minutes`,
payload: {
assignmentId: row.id,
shiftId: row.shift_id,
leadMinutes,
startsAt: row.starts_at,
},
});
enqueued += 1;
}
}
});
return { enqueued };
}
async function settleNotification(notification, deliveryResults, maxAttempts) {
const successCount = deliveryResults.filter((result) => result.deliveryStatus === 'SENT').length;
const simulatedCount = deliveryResults.filter((result) => result.deliveryStatus === 'SIMULATED').length;
const transientCount = deliveryResults.filter((result) => result.transient).length;
const invalidCount = deliveryResults.filter((result) => result.deliveryStatus === 'INVALID_TOKEN').length;
await withTransaction(async (client) => {
for (const result of deliveryResults) {
await recordDeliveryAttempt(client, {
notificationId: notification.id,
devicePushTokenId: result.tokenId,
provider: result.provider || 'FCM',
deliveryStatus: result.deliveryStatus,
providerMessageId: result.providerMessageId || null,
attemptNumber: notification.attempts,
errorCode: result.errorCode || null,
errorMessage: result.errorMessage || null,
responsePayload: result.responsePayload || {},
sentAt: result.deliveryStatus === 'SENT' || result.deliveryStatus === 'SIMULATED'
? new Date().toISOString()
: null,
});
if (result.deliveryStatus === 'INVALID_TOKEN' && result.tokenId) {
await markPushTokenInvalid(client, result.tokenId, result.errorCode || 'INVALID_TOKEN');
}
if ((result.deliveryStatus === 'SENT' || result.deliveryStatus === 'SIMULATED') && result.tokenId) {
await touchPushTokenDelivery(client, result.tokenId);
}
}
});
if (successCount > 0 || simulatedCount > 0) {
await markNotificationSent(notification.id);
return {
status: 'SENT',
successCount,
simulatedCount,
invalidCount,
};
}
if (transientCount > 0 && notification.attempts < maxAttempts) {
const errorSummary = deliveryResults
.map((result) => result.errorCode || result.errorMessage || result.deliveryStatus)
.filter(Boolean)
.join('; ');
await requeueNotification(notification.id, notification.attempts, errorSummary || 'Transient delivery failure');
return {
status: 'REQUEUED',
successCount,
simulatedCount,
invalidCount,
};
}
const failureSummary = deliveryResults
.map((result) => result.errorCode || result.errorMessage || result.deliveryStatus)
.filter(Boolean)
.join('; ');
await markNotificationFailed(notification.id, failureSummary || 'Push delivery failed');
return {
status: 'FAILED',
successCount,
simulatedCount,
invalidCount,
};
}
export async function dispatchPendingNotifications({
limit = parseIntEnv('NOTIFICATION_BATCH_LIMIT', 50),
sender = createPushSender(),
} = {}) {
const maxAttempts = parseIntEnv('NOTIFICATION_MAX_ATTEMPTS', 5);
const reminderSummary = await enqueueDueShiftReminders();
const claimed = await claimDueNotifications(limit);
const summary = {
remindersEnqueued: reminderSummary.enqueued,
claimed: claimed.length,
sent: 0,
requeued: 0,
failed: 0,
simulated: 0,
invalidTokens: 0,
skipped: 0,
};
for (const notification of claimed) {
const tokens = await resolveNotificationTargetTokens({ query }, notification);
if (tokens.length === 0) {
await withTransaction(async (client) => {
await recordDeliveryAttempt(client, {
notificationId: notification.id,
provider: 'FCM',
deliveryStatus: 'SKIPPED',
attemptNumber: notification.attempts,
errorCode: 'NO_ACTIVE_PUSH_TOKENS',
errorMessage: 'No active push tokens registered for notification recipient',
responsePayload: { recipient: notification.recipient_user_id || notification.recipient_staff_id || notification.recipient_business_membership_id || null },
});
});
await markNotificationFailed(notification.id, 'No active push tokens registered for notification recipient');
summary.failed += 1;
summary.skipped += 1;
continue;
}
const deliveryResults = await sender.send(notification, tokens);
const outcome = await settleNotification(notification, deliveryResults, maxAttempts);
if (outcome.status === 'SENT') summary.sent += 1;
if (outcome.status === 'REQUEUED') summary.requeued += 1;
if (outcome.status === 'FAILED') summary.failed += 1;
summary.simulated += outcome.simulatedCount || 0;
summary.invalidTokens += outcome.invalidCount || 0;
}
return summary;
}

View File

@@ -0,0 +1,116 @@
import { getFirebaseAdminMessaging } from './firebase-admin.js';
const INVALID_TOKEN_ERROR_CODES = new Set([
'messaging/invalid-registration-token',
'messaging/registration-token-not-registered',
]);
const TRANSIENT_ERROR_CODES = new Set([
'messaging/internal-error',
'messaging/server-unavailable',
'messaging/unknown-error',
'app/network-error',
]);
function mapPriority(priority) {
return priority === 'CRITICAL' || priority === 'HIGH' ? 'high' : 'normal';
}
function buildDataPayload(notification) {
return {
notificationId: notification.id,
notificationType: notification.notification_type,
priority: notification.priority,
tenantId: notification.tenant_id,
businessId: notification.business_id || '',
shiftId: notification.shift_id || '',
assignmentId: notification.assignment_id || '',
payload: JSON.stringify(notification.payload || {}),
};
}
export function classifyMessagingError(errorCode) {
if (!errorCode) return 'FAILED';
if (INVALID_TOKEN_ERROR_CODES.has(errorCode)) return 'INVALID_TOKEN';
if (TRANSIENT_ERROR_CODES.has(errorCode)) return 'RETRYABLE';
return 'FAILED';
}
export function createPushSender({ deliveryMode = process.env.PUSH_DELIVERY_MODE || 'live' } = {}) {
return {
async send(notification, tokens) {
if (tokens.length === 0) {
return [];
}
if (deliveryMode === 'log-only') {
return tokens.map((token) => ({
tokenId: token.id,
deliveryStatus: 'SIMULATED',
provider: token.provider,
providerMessageId: null,
errorCode: null,
errorMessage: null,
responsePayload: {
deliveryMode,
},
transient: false,
}));
}
const messages = tokens.map((token) => ({
token: token.pushToken,
notification: {
title: notification.subject || 'Krow update',
body: notification.body || '',
},
data: buildDataPayload(notification),
android: {
priority: mapPriority(notification.priority),
},
apns: {
headers: {
'apns-priority': mapPriority(notification.priority) === 'high' ? '10' : '5',
},
},
}));
const dryRun = deliveryMode === 'dry-run';
const response = await getFirebaseAdminMessaging().sendEach(messages, dryRun);
return response.responses.map((item, index) => {
const token = tokens[index];
if (item.success) {
return {
tokenId: token.id,
deliveryStatus: dryRun ? 'SIMULATED' : 'SENT',
provider: token.provider,
providerMessageId: item.messageId || null,
errorCode: null,
errorMessage: null,
responsePayload: {
deliveryMode,
messageId: item.messageId || null,
},
transient: false,
};
}
const errorCode = item.error?.code || 'messaging/unknown-error';
const errorMessage = item.error?.message || 'Push delivery failed';
const classification = classifyMessagingError(errorCode);
return {
tokenId: token.id,
deliveryStatus: classification === 'INVALID_TOKEN' ? 'INVALID_TOKEN' : 'FAILED',
provider: token.provider,
providerMessageId: null,
errorCode,
errorMessage,
responsePayload: {
deliveryMode,
},
transient: classification === 'RETRYABLE',
};
});
},
};
}

View File

@@ -0,0 +1,196 @@
export async function enqueueNotification(client, {
tenantId,
businessId = null,
shiftId = null,
assignmentId = null,
relatedIncidentId = null,
audienceType = 'USER',
recipientUserId = null,
recipientStaffId = null,
recipientBusinessMembershipId = null,
channel = 'PUSH',
notificationType,
priority = 'NORMAL',
dedupeKey = null,
subject = null,
body = null,
payload = {},
scheduledAt = null,
}) {
await client.query(
`
INSERT INTO notification_outbox (
tenant_id,
business_id,
shift_id,
assignment_id,
related_incident_id,
audience_type,
recipient_user_id,
recipient_staff_id,
recipient_business_membership_id,
channel,
notification_type,
priority,
dedupe_key,
subject,
body,
payload,
scheduled_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb, COALESCE($17::timestamptz, NOW())
)
ON CONFLICT (dedupe_key) DO NOTHING
`,
[
tenantId,
businessId,
shiftId,
assignmentId,
relatedIncidentId,
audienceType,
recipientUserId,
recipientStaffId,
recipientBusinessMembershipId,
channel,
notificationType,
priority,
dedupeKey,
subject,
body,
JSON.stringify(payload || {}),
scheduledAt,
]
);
}
async function loadHubNotificationRecipients(client, { tenantId, businessId, hubId }) {
const scoped = await client.query(
`
SELECT DISTINCT
hm.business_membership_id AS "businessMembershipId",
bm.user_id AS "userId"
FROM hub_managers hm
JOIN business_memberships bm ON bm.id = hm.business_membership_id
WHERE hm.tenant_id = $1
AND hm.hub_id = $2
AND bm.membership_status = 'ACTIVE'
`,
[tenantId, hubId]
);
if (scoped.rowCount > 0) {
return scoped.rows;
}
const fallback = await client.query(
`
SELECT id AS "businessMembershipId", user_id AS "userId"
FROM business_memberships
WHERE tenant_id = $1
AND business_id = $2
AND membership_status = 'ACTIVE'
AND business_role IN ('owner', 'manager')
`,
[tenantId, businessId]
);
return fallback.rows;
}
export async function enqueueHubManagerAlert(client, {
tenantId,
businessId,
shiftId = null,
assignmentId = null,
hubId = null,
relatedIncidentId = null,
notificationType,
priority = 'HIGH',
subject,
body,
payload = {},
dedupeScope,
}) {
if (!hubId && !businessId) {
return 0;
}
const recipients = await loadHubNotificationRecipients(client, {
tenantId,
businessId,
hubId,
});
let createdCount = 0;
for (const recipient of recipients) {
const dedupeKey = [
'notify',
notificationType,
dedupeScope || shiftId || assignmentId || relatedIncidentId || hubId || businessId,
recipient.userId || recipient.businessMembershipId,
].filter(Boolean).join(':');
await enqueueNotification(client, {
tenantId,
businessId,
shiftId,
assignmentId,
relatedIncidentId,
audienceType: recipient.userId ? 'USER' : 'BUSINESS_MEMBERSHIP',
recipientUserId: recipient.userId || null,
recipientBusinessMembershipId: recipient.businessMembershipId || null,
channel: 'PUSH',
notificationType,
priority,
dedupeKey,
subject,
body,
payload,
});
createdCount += 1;
}
return createdCount;
}
export async function enqueueUserAlert(client, {
tenantId,
businessId = null,
shiftId = null,
assignmentId = null,
relatedIncidentId = null,
recipientUserId,
notificationType,
priority = 'NORMAL',
subject = null,
body = null,
payload = {},
dedupeScope,
}) {
if (!recipientUserId) return;
const dedupeKey = [
'notify',
notificationType,
dedupeScope || shiftId || assignmentId || relatedIncidentId || recipientUserId,
recipientUserId,
].filter(Boolean).join(':');
await enqueueNotification(client, {
tenantId,
businessId,
shiftId,
assignmentId,
relatedIncidentId,
audienceType: 'USER',
recipientUserId,
channel: 'PUSH',
notificationType,
priority,
dedupeKey,
subject,
body,
payload,
});
}

View File

@@ -0,0 +1,46 @@
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createWorkerApp({ dispatch = async () => ({}) } = {}) {
const app = express();
app.use(
pinoHttp({
logger,
})
);
app.use(express.json({ limit: '256kb' }));
app.get('/health', (_req, res) => {
res.status(200).json({ ok: true, service: 'notification-worker-v2' });
});
app.get('/readyz', (_req, res) => {
res.status(200).json({ ok: true, service: 'notification-worker-v2' });
});
app.post('/tasks/dispatch-notifications', async (req, res) => {
try {
const summary = await dispatch();
res.status(200).json({ ok: true, summary });
} catch (error) {
req.log?.error?.({ err: error }, 'notification dispatch failed');
res.status(500).json({
ok: false,
error: error?.message || String(error),
});
}
});
app.use((_req, res) => {
res.status(404).json({
code: 'NOT_FOUND',
message: 'Route not found',
});
});
return app;
}

View File

@@ -0,0 +1,12 @@
import { createWorkerApp } from './worker-app.js';
import { dispatchPendingNotifications } from './services/notification-dispatcher.js';
const port = Number(process.env.PORT || 8080);
const app = createWorkerApp({
dispatch: () => dispatchPendingNotifications(),
});
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`krow-notification-worker listening on port ${port}`);
});

View File

@@ -6,9 +6,42 @@ import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-sto
process.env.AUTH_BYPASS = 'true';
const tenantId = '11111111-1111-4111-8111-111111111111';
const businessId = '22222222-2222-4222-8222-222222222222';
const shiftId = '33333333-3333-4333-8333-333333333333';
function validOrderCreatePayload() {
return {
tenantId,
businessId,
orderNumber: 'ORD-1001',
title: 'Cafe Event Staffing',
serviceType: 'EVENT',
shifts: [
{
shiftCode: 'SHIFT-1',
title: 'Morning Shift',
startsAt: '2026-03-11T08:00:00.000Z',
endsAt: '2026-03-11T16:00:00.000Z',
requiredWorkers: 2,
roles: [
{
roleCode: 'BARISTA',
roleName: 'Barista',
workersNeeded: 2,
payRateCents: 2200,
billRateCents: 3500,
},
],
},
],
};
}
beforeEach(() => {
process.env.IDEMPOTENCY_STORE = 'memory';
delete process.env.IDEMPOTENCY_DATABASE_URL;
delete process.env.DATABASE_URL;
__resetIdempotencyStoreForTests();
});
@@ -21,34 +54,65 @@ test('GET /healthz returns healthy response', async () => {
assert.equal(typeof res.body.requestId, 'string');
});
test('GET /readyz reports database not configured when no database env is present', async () => {
const app = createApp();
const res = await request(app).get('/readyz');
assert.equal(res.status, 503);
assert.equal(res.body.ok, false);
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
});
test('command route requires idempotency key', async () => {
const app = createApp();
const res = await request(app)
.post('/commands/orders/create')
.set('Authorization', 'Bearer test-token')
.send({ payload: {} });
.send(validOrderCreatePayload());
assert.equal(res.status, 400);
assert.equal(res.body.code, 'MISSING_IDEMPOTENCY_KEY');
});
test('command route is idempotent by key', async () => {
const app = createApp();
test('command route is idempotent by key and only executes handler once', async () => {
let callCount = 0;
const app = createApp({
commandHandlers: {
createOrder: async () => {
callCount += 1;
return {
orderId: '44444444-4444-4444-8444-444444444444',
orderNumber: 'ORD-1001',
status: 'OPEN',
shiftCount: 1,
shiftIds: [shiftId],
};
},
acceptShift: async () => assert.fail('acceptShift should not be called'),
clockIn: async () => assert.fail('clockIn should not be called'),
clockOut: async () => assert.fail('clockOut should not be called'),
addFavoriteStaff: async () => assert.fail('addFavoriteStaff should not be called'),
removeFavoriteStaff: async () => assert.fail('removeFavoriteStaff should not be called'),
createStaffReview: async () => assert.fail('createStaffReview should not be called'),
},
});
const first = await request(app)
.post('/commands/orders/create')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'abc-123')
.send({ payload: { order: 'x' } });
.send(validOrderCreatePayload());
const second = await request(app)
.post('/commands/orders/create')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'abc-123')
.send({ payload: { order: 'x' } });
.send(validOrderCreatePayload());
assert.equal(first.status, 200);
assert.equal(second.status, 200);
assert.equal(first.body.commandId, second.body.commandId);
assert.equal(callCount, 1);
assert.equal(first.body.orderId, second.body.orderId);
assert.equal(first.body.idempotencyKey, 'abc-123');
assert.equal(second.body.idempotencyKey, 'abc-123');
});

View File

@@ -0,0 +1,344 @@
import test, { beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from '../src/app.js';
import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-store.js';
process.env.AUTH_BYPASS = 'true';
beforeEach(() => {
process.env.IDEMPOTENCY_STORE = 'memory';
delete process.env.IDEMPOTENCY_DATABASE_URL;
delete process.env.DATABASE_URL;
__resetIdempotencyStoreForTests();
});
function createMobileHandlers() {
return {
createClientOneTimeOrder: async (_actor, payload) => ({
orderId: 'order-1',
orderType: 'ONE_TIME',
eventName: payload.eventName,
}),
createClientRecurringOrder: async (_actor, payload) => ({
orderId: 'order-2',
orderType: 'RECURRING',
recurrenceDays: payload.recurrenceDays,
}),
createClientPermanentOrder: async (_actor, payload) => ({
orderId: 'order-3',
orderType: 'PERMANENT',
horizonDays: payload.horizonDays || 28,
}),
createEditedOrderCopy: async (_actor, payload) => ({
sourceOrderId: payload.orderId,
orderId: 'order-4',
cloned: true,
}),
cancelClientOrder: async (_actor, payload) => ({
orderId: payload.orderId,
status: 'CANCELLED',
}),
createHub: async (_actor, payload) => ({
hubId: 'hub-1',
name: payload.name,
costCenterId: payload.costCenterId,
}),
approveInvoice: async (_actor, payload) => ({
invoiceId: payload.invoiceId,
status: 'APPROVED',
}),
registerClientPushToken: async (_actor, payload) => ({
tokenId: 'push-token-client-1',
platform: payload.platform,
notificationsEnabled: payload.notificationsEnabled ?? true,
}),
unregisterClientPushToken: async () => ({
removedCount: 1,
}),
applyForShift: async (_actor, payload) => ({
shiftId: payload.shiftId,
status: 'APPLIED',
}),
registerStaffPushToken: async (_actor, payload) => ({
tokenId: 'push-token-staff-1',
platform: payload.platform,
notificationsEnabled: payload.notificationsEnabled ?? true,
}),
unregisterStaffPushToken: async () => ({
removedCount: 1,
}),
staffClockIn: async (_actor, payload) => ({
assignmentId: payload.assignmentId || 'assignment-1',
status: 'CLOCK_IN',
proofNonce: payload.proofNonce || null,
}),
staffClockOut: async (_actor, payload) => ({
assignmentId: payload.assignmentId || 'assignment-1',
status: 'CLOCK_OUT',
}),
submitLocationStreamBatch: async (_actor, payload) => ({
assignmentId: payload.assignmentId || 'assignment-1',
pointCount: payload.points.length,
status: 'RECORDED',
}),
saveTaxFormDraft: async (_actor, payload) => ({
formType: payload.formType,
status: 'DRAFT',
}),
addStaffBankAccount: async (_actor, payload) => ({
accountType: payload.accountType,
last4: payload.accountNumber.slice(-4),
}),
};
}
test('POST /commands/client/orders/one-time forwards one-time order payload', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/client/orders/one-time')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'client-order-1')
.send({
hubId: '11111111-1111-4111-8111-111111111111',
vendorId: '22222222-2222-4222-8222-222222222222',
eventName: 'Google Cafe Coverage',
orderDate: '2026-03-20',
positions: [
{
roleId: '33333333-3333-4333-8333-333333333333',
startTime: '09:00',
endTime: '17:00',
workerCount: 2,
hourlyRateCents: 2800,
},
],
});
assert.equal(res.status, 200);
assert.equal(res.body.orderId, 'order-1');
assert.equal(res.body.orderType, 'ONE_TIME');
assert.equal(res.body.eventName, 'Google Cafe Coverage');
});
test('POST /commands/client/orders/:orderId/edit injects order id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/client/orders/44444444-4444-4444-8444-444444444444/edit')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'client-order-edit-1')
.send({
eventName: 'Edited Order Copy',
});
assert.equal(res.status, 200);
assert.equal(res.body.sourceOrderId, '44444444-4444-4444-8444-444444444444');
assert.equal(res.body.cloned, true);
});
test('POST /commands/client/hubs returns injected hub response', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/client/hubs')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'hub-create-1')
.send({
tenantId: '11111111-1111-4111-8111-111111111111',
businessId: '22222222-2222-4222-8222-222222222222',
name: 'Google North Hub',
locationName: 'North Campus',
timezone: 'America/Los_Angeles',
latitude: 37.422,
longitude: -122.084,
geofenceRadiusMeters: 100,
clockInMode: 'GEO_REQUIRED',
allowClockInOverride: true,
costCenterId: '44444444-4444-4444-8444-444444444444',
});
assert.equal(res.status, 200);
assert.equal(res.body.hubId, 'hub-1');
assert.equal(res.body.name, 'Google North Hub');
});
test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/client/billing/invoices/55555555-5555-4555-8555-555555555555/approve')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'invoice-approve-1')
.send({});
assert.equal(res.status, 200);
assert.equal(res.body.invoiceId, '55555555-5555-4555-8555-555555555555');
assert.equal(res.body.status, 'APPROVED');
});
test('POST /commands/client/devices/push-tokens registers a client push token', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/client/devices/push-tokens')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'client-push-token-1')
.send({
provider: 'FCM',
platform: 'IOS',
pushToken: 'f'.repeat(160),
deviceId: 'iphone-15-pro',
notificationsEnabled: true,
});
assert.equal(res.status, 200);
assert.equal(res.body.tokenId, 'push-token-client-1');
assert.equal(res.body.platform, 'IOS');
});
test('DELETE /commands/client/devices/push-tokens accepts tokenId from query params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.delete('/commands/client/devices/push-tokens?tokenId=11111111-1111-4111-8111-111111111111&reason=SMOKE_CLEANUP')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'client-push-token-delete-1');
assert.equal(res.status, 200);
assert.equal(res.body.removedCount, 1);
});
test('POST /commands/staff/shifts/:shiftId/apply injects shift id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/staff/shifts/66666666-6666-4666-8666-666666666666/apply')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'shift-apply-1')
.send({
note: 'Available tonight',
});
assert.equal(res.status, 200);
assert.equal(res.body.shiftId, '66666666-6666-4666-8666-666666666666');
assert.equal(res.body.status, 'APPLIED');
});
test('POST /commands/staff/clock-in accepts shift-based payload', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/staff/clock-in')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'clock-in-1')
.send({
shiftId: '77777777-7777-4777-8777-777777777777',
sourceType: 'GEO',
latitude: 37.422,
longitude: -122.084,
proofNonce: 'nonce-12345678',
overrideReason: 'GPS timed out near the hub',
});
assert.equal(res.status, 200);
assert.equal(res.body.status, 'CLOCK_IN');
assert.equal(res.body.proofNonce, 'nonce-12345678');
});
test('POST /commands/staff/clock-out accepts assignment-based payload', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/staff/clock-out')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'clock-out-1')
.send({
assignmentId: '88888888-8888-4888-8888-888888888888',
breakMinutes: 30,
});
assert.equal(res.status, 200);
assert.equal(res.body.status, 'CLOCK_OUT');
});
test('POST /commands/staff/location-streams accepts batched location payloads', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/staff/location-streams')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'location-stream-1')
.send({
assignmentId: '99999999-9999-4999-8999-999999999999',
sourceType: 'GEO',
deviceId: 'iphone-15',
points: [
{
capturedAt: '2026-03-16T08:00:00.000Z',
latitude: 37.422,
longitude: -122.084,
accuracyMeters: 12,
},
],
});
assert.equal(res.status, 200);
assert.equal(res.body.status, 'RECORDED');
assert.equal(res.body.pointCount, 1);
});
test('POST /commands/staff/devices/push-tokens registers a staff push token', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/staff/devices/push-tokens')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'staff-push-token-1')
.send({
provider: 'FCM',
platform: 'ANDROID',
pushToken: 'g'.repeat(170),
deviceId: 'pixel-9',
});
assert.equal(res.status, 200);
assert.equal(res.body.tokenId, 'push-token-staff-1');
assert.equal(res.body.platform, 'ANDROID');
});
test('DELETE /commands/staff/devices/push-tokens accepts tokenId from query params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.delete('/commands/staff/devices/push-tokens?tokenId=22222222-2222-4222-8222-222222222222&reason=SMOKE_CLEANUP')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'staff-push-token-delete-1');
assert.equal(res.status, 200);
assert.equal(res.body.removedCount, 1);
});
test('PUT /commands/staff/profile/tax-forms/:formType uppercases form type', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.put('/commands/staff/profile/tax-forms/w4')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'tax-form-1')
.send({
fields: {
filingStatus: 'single',
},
});
assert.equal(res.status, 200);
assert.equal(res.body.formType, 'W4');
assert.equal(res.body.status, 'DRAFT');
});
test('POST /commands/staff/profile/bank-accounts uppercases account type', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/staff/profile/bank-accounts')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'bank-account-1')
.send({
bankName: 'Demo Credit Union',
accountNumber: '1234567890',
routingNumber: '021000021',
accountType: 'checking',
});
assert.equal(res.status, 200);
assert.equal(res.body.accountType, 'CHECKING');
assert.equal(res.body.last4, '7890');
});

View File

@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { computeRetryDelayMinutes } from '../src/services/notification-dispatcher.js';
import { createPushSender, classifyMessagingError } from '../src/services/notification-fcm.js';
test('computeRetryDelayMinutes backs off exponentially with a cap', () => {
assert.equal(computeRetryDelayMinutes(1), 5);
assert.equal(computeRetryDelayMinutes(2), 10);
assert.equal(computeRetryDelayMinutes(3), 20);
assert.equal(computeRetryDelayMinutes(5), 60);
assert.equal(computeRetryDelayMinutes(9), 60);
});
test('classifyMessagingError distinguishes invalid and retryable push failures', () => {
assert.equal(classifyMessagingError('messaging/registration-token-not-registered'), 'INVALID_TOKEN');
assert.equal(classifyMessagingError('messaging/server-unavailable'), 'RETRYABLE');
assert.equal(classifyMessagingError('messaging/unknown-problem'), 'FAILED');
});
test('createPushSender log-only mode simulates successful delivery results', async () => {
const sender = createPushSender({ deliveryMode: 'log-only' });
const results = await sender.send(
{
id: 'notification-1',
notification_type: 'SHIFT_START_REMINDER',
priority: 'HIGH',
tenant_id: 'tenant-1',
payload: { assignmentId: 'assignment-1' },
},
[
{ id: 'token-1', provider: 'FCM', pushToken: 'demo-token' },
]
);
assert.equal(results.length, 1);
assert.equal(results[0].deliveryStatus, 'SIMULATED');
assert.equal(results[0].transient, false);
});

View File

@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createWorkerApp } from '../src/worker-app.js';
test('GET /readyz returns healthy response', async () => {
const app = createWorkerApp();
const res = await request(app).get('/readyz');
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.service, 'notification-worker-v2');
});
test('POST /tasks/dispatch-notifications returns dispatch summary', async () => {
const app = createWorkerApp({
dispatch: async () => ({
claimed: 2,
sent: 2,
}),
});
const res = await request(app)
.post('/tasks/dispatch-notifications')
.send({});
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.summary.claimed, 2);
assert.equal(res.body.summary.sent, 2);
});
test('POST /tasks/dispatch-notifications returns 500 on dispatch error', async () => {
const app = createWorkerApp({
dispatch: async () => {
throw new Error('dispatch exploded');
},
});
const res = await request(app)
.post('/tasks/dispatch-notifications')
.send({});
assert.equal(res.status, 500);
assert.equal(res.body.ok, false);
assert.match(res.body.error, /dispatch exploded/);
});

View File

@@ -13,6 +13,7 @@
"firebase-admin": "^13.0.2",
"google-auth-library": "^9.15.1",
"multer": "^2.0.2",
"pg": "^8.20.0",
"pino": "^9.6.0",
"pino-http": "^10.3.0",
"zod": "^3.24.2"
@@ -2037,6 +2038,95 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
@@ -2086,6 +2176,45 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",

View File

@@ -16,6 +16,7 @@
"firebase-admin": "^13.0.2",
"google-auth-library": "^9.15.1",
"multer": "^2.0.2",
"pg": "^8.20.0",
"pino": "^9.6.0",
"pino-http": "^10.3.0",
"zod": "^3.24.2"

View File

@@ -24,6 +24,12 @@ import {
retryVerificationJob,
reviewVerificationJob,
} from '../services/verification-jobs.js';
import {
deleteCertificate,
uploadCertificate,
uploadProfilePhoto,
uploadStaffDocument,
} from '../services/mobile-upload.js';
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
const DEFAULT_MAX_SIGNED_URL_SECONDS = 900;
@@ -56,6 +62,14 @@ const uploadMetaSchema = z.object({
visibility: z.enum(['public', 'private']).optional(),
});
const certificateUploadMetaSchema = z.object({
certificateType: z.string().min(1).max(120),
name: z.string().min(1).max(160),
issuer: z.string().max(160).optional(),
certificateNumber: z.string().max(160).optional(),
expiresAt: z.string().datetime().optional(),
});
function mockSignedUrl(fileUri, expiresInSeconds) {
const encoded = encodeURIComponent(fileUri);
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
@@ -292,7 +306,7 @@ async function handleCreateVerification(req, res, next) {
});
}
const created = createVerificationJob({
const created = await createVerificationJob({
actorUid: req.actor.uid,
payload,
});
@@ -305,10 +319,107 @@ async function handleCreateVerification(req, res, next) {
}
}
async function handleProfilePhotoUpload(req, res, next) {
try {
const file = req.file;
if (!file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
}
const result = await uploadProfilePhoto({
actorUid: req.actor.uid,
file,
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleDocumentUpload(req, res, next) {
try {
const file = req.file;
if (!file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
}
const result = await uploadStaffDocument({
actorUid: req.actor.uid,
documentId: req.params.documentId,
file,
routeType: 'document',
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleAttireUpload(req, res, next) {
try {
const file = req.file;
if (!file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
}
const result = await uploadStaffDocument({
actorUid: req.actor.uid,
documentId: req.params.documentId,
file,
routeType: 'attire',
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleCertificateUpload(req, res, next) {
try {
const file = req.file;
if (!file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
}
const payload = parseBody(certificateUploadMetaSchema, req.body || {});
const result = await uploadCertificate({
actorUid: req.actor.uid,
file,
payload,
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleCertificateDelete(req, res, next) {
try {
const result = await deleteCertificate({
actorUid: req.actor.uid,
certificateType: req.params.certificateType,
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleGetVerification(req, res, next) {
try {
const verificationId = req.params.verificationId;
const job = getVerificationJob(verificationId, req.actor.uid);
const job = await getVerificationJob(verificationId, req.actor.uid);
return res.status(200).json({
...job,
requestId: req.requestId,
@@ -322,7 +433,7 @@ async function handleReviewVerification(req, res, next) {
try {
const verificationId = req.params.verificationId;
const payload = parseBody(reviewVerificationSchema, req.body || {});
const updated = reviewVerificationJob(verificationId, req.actor.uid, payload);
const updated = await reviewVerificationJob(verificationId, req.actor.uid, payload);
return res.status(200).json({
...updated,
requestId: req.requestId,
@@ -335,7 +446,7 @@ async function handleReviewVerification(req, res, next) {
async function handleRetryVerification(req, res, next) {
try {
const verificationId = req.params.verificationId;
const updated = retryVerificationJob(verificationId, req.actor.uid);
const updated = await retryVerificationJob(verificationId, req.actor.uid);
return res.status(202).json({
...updated,
requestId: req.requestId,
@@ -353,6 +464,11 @@ export function createCoreRouter() {
router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe);
router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse);
router.post('/staff/profile/photo', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleProfilePhotoUpload);
router.post('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleDocumentUpload);
router.post('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload);
router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload);
router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete);
router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification);
router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification);
router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification);

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
export const healthRouter = Router();
@@ -13,3 +14,31 @@ function healthHandler(req, res) {
healthRouter.get('/health', healthHandler);
healthRouter.get('/healthz', healthHandler);
healthRouter.get('/readyz', async (req, res) => {
if (!isDatabaseConfigured()) {
return res.status(503).json({
ok: false,
service: 'krow-core-api',
status: 'DATABASE_NOT_CONFIGURED',
requestId: req.requestId,
});
}
const healthy = await checkDatabaseHealth().catch(() => false);
if (!healthy) {
return res.status(503).json({
ok: false,
service: 'krow-core-api',
status: 'DATABASE_UNAVAILABLE',
requestId: req.requestId,
});
}
return res.status(200).json({
ok: true,
service: 'krow-core-api',
status: 'READY',
requestId: req.requestId,
});
});

View File

@@ -0,0 +1,67 @@
import { AppError } from '../lib/errors.js';
import { query } from './db.js';
export async function loadActorContext(uid) {
const [userResult, tenantResult, staffResult] = await Promise.all([
query(
`
SELECT id AS "userId", email, display_name AS "displayName", phone, status
FROM users
WHERE id = $1
`,
[uid]
),
query(
`
SELECT tm.id AS "membershipId",
tm.tenant_id AS "tenantId",
tm.base_role AS role,
t.name AS "tenantName",
t.slug AS "tenantSlug"
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = $1
AND tm.membership_status = 'ACTIVE'
ORDER BY tm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT s.id AS "staffId",
s.tenant_id AS "tenantId",
s.full_name AS "fullName",
s.status,
s.metadata
FROM staffs s
WHERE s.user_id = $1
ORDER BY s.created_at ASC
LIMIT 1
`,
[uid]
),
]);
return {
user: userResult.rows[0] || null,
tenant: tenantResult.rows[0] || null,
staff: staffResult.rows[0] || null,
};
}
export async function requireTenantContext(uid) {
const context = await loadActorContext(uid);
if (!context.user || !context.tenant) {
throw new AppError('FORBIDDEN', 'Tenant context is required for this route', 403, { uid });
}
return context;
}
export async function requireStaffContext(uid) {
const context = await loadActorContext(uid);
if (!context.user || !context.tenant || !context.staff) {
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
}
return context;
}

View File

@@ -0,0 +1,98 @@
import pg from 'pg';
const { Pool, types } = pg;
function parseNumericDatabaseValue(value) {
if (value == null) return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
let pool;
function parseIntOrDefault(value, fallback) {
const parsed = Number.parseInt(`${value || fallback}`, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function resolveDatabasePoolConfig() {
if (process.env.DATABASE_URL) {
return {
connectionString: process.env.DATABASE_URL,
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
const user = process.env.DB_USER;
const password = process.env.DB_PASSWORD;
const database = process.env.DB_NAME;
const host = process.env.DB_HOST || (
process.env.INSTANCE_CONNECTION_NAME
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
: ''
);
if (!user || password == null || !database || !host) {
return null;
}
return {
host,
port: parseIntOrDefault(process.env.DB_PORT, 5432),
user,
password,
database,
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
export function isDatabaseConfigured() {
return Boolean(resolveDatabasePoolConfig());
}
function getPool() {
if (!pool) {
const resolved = resolveDatabasePoolConfig();
if (!resolved) {
throw new Error('Database connection settings are required');
}
pool = new Pool(resolved);
}
return pool;
}
export async function query(text, params = []) {
return getPool().query(text, params);
}
export async function withTransaction(work) {
const client = await getPool().connect();
try {
await client.query('BEGIN');
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
export async function checkDatabaseHealth() {
const result = await query('SELECT 1 AS ok');
return result.rows[0]?.ok === 1;
}
export async function closePool() {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -0,0 +1,260 @@
import { AppError } from '../lib/errors.js';
import { requireStaffContext } from './actor-context.js';
import { generateReadSignedUrl, uploadToGcs } from './storage.js';
import { query, withTransaction } from './db.js';
import { createVerificationJob } from './verification-jobs.js';
function safeName(value) {
return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_');
}
function uploadBucket() {
return process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private';
}
async function uploadActorFile({ actorUid, file, category }) {
const bucket = uploadBucket();
const objectPath = `uploads/${actorUid}/${category}/${Date.now()}_${safeName(file.originalname)}`;
const fileUri = `gs://${bucket}/${objectPath}`;
await uploadToGcs({
bucket,
objectPath,
contentType: file.mimetype,
buffer: file.buffer,
});
return { bucket, objectPath, fileUri };
}
async function createPreviewUrl(actorUid, fileUri) {
try {
return await generateReadSignedUrl({
fileUri,
actorUid,
expiresInSeconds: 900,
});
} catch {
return {
signedUrl: null,
expiresAt: null,
};
}
}
export async function uploadProfilePhoto({ actorUid, file }) {
const context = await requireStaffContext(actorUid);
const uploaded = await uploadActorFile({
actorUid,
file,
category: 'profile-photo',
});
await withTransaction(async (client) => {
await client.query(
`
UPDATE staffs
SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,
updated_at = NOW()
WHERE id = $1
`,
[context.staff.staffId, JSON.stringify({ profilePhotoUri: uploaded.fileUri })]
);
});
const preview = await createPreviewUrl(actorUid, uploaded.fileUri);
return {
staffId: context.staff.staffId,
fileUri: uploaded.fileUri,
signedUrl: preview.signedUrl,
expiresAt: preview.expiresAt,
};
}
async function requireDocument(tenantId, documentId, allowedTypes) {
const result = await query(
`
SELECT id, document_type, name
FROM documents
WHERE tenant_id = $1
AND id = $2
AND document_type = ANY($3::text[])
`,
[tenantId, documentId, allowedTypes]
);
if (result.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Document not found for requested upload type', 404, {
documentId,
allowedTypes,
});
}
return result.rows[0];
}
export async function uploadStaffDocument({ actorUid, documentId, file, routeType }) {
const context = await requireStaffContext(actorUid);
const document = await requireDocument(
context.tenant.tenantId,
documentId,
routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM']
);
const uploaded = await uploadActorFile({
actorUid,
file,
category: routeType,
});
const verification = await createVerificationJob({
actorUid,
payload: {
type: routeType === 'attire' ? 'attire' : 'government_id',
subjectType: routeType === 'attire' ? 'attire_item' : 'staff_document',
subjectId: documentId,
fileUri: uploaded.fileUri,
metadata: {
routeType,
documentType: document.document_type,
},
rules: {
expectedDocumentName: document.name,
},
},
});
await withTransaction(async (client) => {
await client.query(
`
INSERT INTO staff_documents (
tenant_id,
staff_id,
document_id,
file_uri,
status,
verification_job_id,
metadata
)
VALUES ($1, $2, $3, $4, 'PENDING', $5, $6::jsonb)
ON CONFLICT (staff_id, document_id) DO UPDATE
SET file_uri = EXCLUDED.file_uri,
status = 'PENDING',
verification_job_id = EXCLUDED.verification_job_id,
metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata,
updated_at = NOW()
`,
[
context.tenant.tenantId,
context.staff.staffId,
document.id,
uploaded.fileUri,
verification.verificationId,
JSON.stringify({
verificationStatus: verification.status,
routeType,
}),
]
);
});
const preview = await createPreviewUrl(actorUid, uploaded.fileUri);
return {
documentId: document.id,
documentType: document.document_type,
fileUri: uploaded.fileUri,
signedUrl: preview.signedUrl,
expiresAt: preview.expiresAt,
verification,
};
}
export async function uploadCertificate({ actorUid, file, payload }) {
const context = await requireStaffContext(actorUid);
const uploaded = await uploadActorFile({
actorUid,
file,
category: 'certificate',
});
const verification = await createVerificationJob({
actorUid,
payload: {
type: 'certification',
subjectType: 'certificate',
subjectId: payload.certificateType,
fileUri: uploaded.fileUri,
metadata: {
certificateType: payload.certificateType,
name: payload.name,
issuer: payload.issuer || null,
certificateNumber: payload.certificateNumber || null,
},
rules: {
certificateType: payload.certificateType,
name: payload.name,
},
},
});
const certificateResult = await withTransaction(async (client) => {
return client.query(
`
INSERT INTO certificates (
tenant_id,
staff_id,
certificate_type,
certificate_number,
issued_at,
expires_at,
status,
file_uri,
verification_job_id,
metadata
)
VALUES ($1, $2, $3, $4, NOW(), $5, 'PENDING', $6, $7, $8::jsonb)
RETURNING id
`,
[
context.tenant.tenantId,
context.staff.staffId,
payload.certificateType,
payload.certificateNumber || null,
payload.expiresAt || null,
uploaded.fileUri,
verification.verificationId,
JSON.stringify({
name: payload.name,
issuer: payload.issuer || null,
verificationStatus: verification.status,
}),
]
);
});
const preview = await createPreviewUrl(actorUid, uploaded.fileUri);
return {
certificateId: certificateResult.rows[0].id,
certificateType: payload.certificateType,
fileUri: uploaded.fileUri,
signedUrl: preview.signedUrl,
expiresAt: preview.expiresAt,
verification,
};
}
export async function deleteCertificate({ actorUid, certificateType }) {
const context = await requireStaffContext(actorUid);
const result = await query(
`
DELETE FROM certificates
WHERE tenant_id = $1
AND staff_id = $2
AND certificate_type = $3
RETURNING id
`,
[context.tenant.tenantId, context.staff.staffId, certificateType]
);
if (result.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Certificate not found for current staff user', 404, {
certificateType,
});
}
return {
certificateId: result.rows[0].id,
deleted: true,
};
}

View File

@@ -1,9 +1,8 @@
import crypto from 'node:crypto';
import { AppError } from '../lib/errors.js';
import { isDatabaseConfigured, query, withTransaction } from './db.js';
import { requireTenantContext } from './actor-context.js';
import { invokeVertexMultimodalModel } from './llm.js';
const jobs = new Map();
export const VerificationStatus = Object.freeze({
PENDING: 'PENDING',
PROCESSING: 'PROCESSING',
@@ -15,82 +14,96 @@ export const VerificationStatus = Object.freeze({
ERROR: 'ERROR',
});
const MACHINE_TERMINAL_STATUSES = new Set([
VerificationStatus.AUTO_PASS,
VerificationStatus.AUTO_FAIL,
VerificationStatus.NEEDS_REVIEW,
VerificationStatus.ERROR,
]);
const HUMAN_TERMINAL_STATUSES = new Set([
VerificationStatus.APPROVED,
VerificationStatus.REJECTED,
]);
function nowIso() {
return new Date().toISOString();
const memoryVerificationJobs = new Map();
function useMemoryStore() {
if (process.env.VERIFICATION_STORE === 'memory') {
return true;
}
return !isDatabaseConfigured() && (process.env.NODE_ENV === 'test' || process.env.AUTH_BYPASS === 'true');
}
function nextVerificationId() {
if (typeof crypto?.randomUUID === 'function') {
return crypto.randomUUID();
}
return `verification_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
function loadMemoryJob(verificationId) {
const job = memoryVerificationJobs.get(verificationId);
if (!job) {
throw new AppError('NOT_FOUND', 'Verification not found', 404, {
verificationId,
});
}
return job;
}
async function processVerificationJobInMemory(verificationId) {
const job = memoryVerificationJobs.get(verificationId);
if (!job || job.status !== VerificationStatus.PENDING) {
return;
}
job.status = VerificationStatus.PROCESSING;
job.updated_at = new Date().toISOString();
memoryVerificationJobs.set(verificationId, job);
const workItem = {
id: job.id,
type: job.type,
fileUri: job.file_uri,
subjectType: job.subject_type,
subjectId: job.subject_id,
rules: job.metadata?.rules || {},
metadata: job.metadata || {},
};
try {
const result = workItem.type === 'attire'
? await runAttireChecks(workItem)
: await runThirdPartyChecks(workItem, workItem.type);
const updated = {
...job,
status: result.status,
confidence: result.confidence,
reasons: result.reasons || [],
extracted: result.extracted || {},
provider_name: result.provider?.name || null,
provider_reference: result.provider?.reference || null,
updated_at: new Date().toISOString(),
};
memoryVerificationJobs.set(verificationId, updated);
} catch (error) {
const updated = {
...job,
status: VerificationStatus.ERROR,
reasons: [error?.message || 'Verification processing failed'],
provider_name: 'verification-worker',
provider_reference: `error:${error?.code || 'unknown'}`,
updated_at: new Date().toISOString(),
};
memoryVerificationJobs.set(verificationId, updated);
}
}
function accessMode() {
return process.env.VERIFICATION_ACCESS_MODE || 'authenticated';
}
function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) {
return {
id: crypto.randomUUID(),
fromStatus,
toStatus,
actorType,
actorId,
details,
createdAt: nowIso(),
};
function providerTimeoutMs() {
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
}
function toPublicJob(job) {
return {
verificationId: job.id,
type: job.type,
subjectType: job.subjectType,
subjectId: job.subjectId,
fileUri: job.fileUri,
status: job.status,
confidence: job.confidence,
reasons: job.reasons,
extracted: job.extracted,
provider: job.provider,
review: job.review,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
};
}
function assertAccess(job, actorUid) {
if (accessMode() === 'authenticated') {
return;
}
if (job.ownerUid !== actorUid) {
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
}
}
function requireJob(id) {
const job = jobs.get(id);
if (!job) {
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId: id });
}
return job;
}
function normalizeMachineStatus(status) {
if (
status === VerificationStatus.AUTO_PASS
|| status === VerificationStatus.AUTO_FAIL
|| status === VerificationStatus.NEEDS_REVIEW
) {
return status;
}
return VerificationStatus.NEEDS_REVIEW;
function attireModel() {
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
}
function clampConfidence(value, fallback = 0.5) {
@@ -108,12 +121,89 @@ function asReasonList(reasons, fallback) {
return [fallback];
}
function providerTimeoutMs() {
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
function normalizeMachineStatus(status) {
if (
status === VerificationStatus.AUTO_PASS
|| status === VerificationStatus.AUTO_FAIL
|| status === VerificationStatus.NEEDS_REVIEW
) {
return status;
}
return VerificationStatus.NEEDS_REVIEW;
}
function attireModel() {
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
function toPublicJob(row) {
if (!row) return null;
return {
verificationId: row.id,
type: row.type,
subjectType: row.subject_type,
subjectId: row.subject_id,
fileUri: row.file_uri,
status: row.status,
confidence: row.confidence == null ? null : Number(row.confidence),
reasons: Array.isArray(row.reasons) ? row.reasons : [],
extracted: row.extracted || {},
provider: row.provider_name
? {
name: row.provider_name,
reference: row.provider_reference || null,
}
: null,
review: row.review || {},
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function assertAccess(row, actorUid) {
if (accessMode() === 'authenticated') {
return;
}
if (row.owner_user_id !== actorUid) {
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
}
}
async function loadJob(verificationId) {
const result = await query(
`
SELECT *
FROM verification_jobs
WHERE id = $1
`,
[verificationId]
);
if (result.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Verification not found', 404, {
verificationId,
});
}
return result.rows[0];
}
async function appendVerificationEvent(client, {
verificationJobId,
fromStatus,
toStatus,
actorType,
actorId,
details = {},
}) {
await client.query(
`
INSERT INTO verification_events (
verification_job_id,
from_status,
to_status,
actor_type,
actor_id,
details
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
`,
[verificationJobId, fromStatus, toStatus, actorType, actorId, JSON.stringify(details)]
);
}
async function runAttireChecks(job) {
@@ -258,47 +348,26 @@ async function runThirdPartyChecks(job, type) {
signal: controller.signal,
});
const bodyText = await response.text();
let body = {};
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.35,
reasons: [`${provider.name} returned ${response.status}`],
extracted: {},
provider: {
name: provider.name,
reference: body?.reference || null,
},
};
throw new Error(payload?.error || payload?.message || `${provider.name} failed`);
}
return {
status: normalizeMachineStatus(body.status),
confidence: clampConfidence(body.confidence, 0.6),
reasons: asReasonList(body.reasons, `${provider.name} completed check`),
extracted: body.extracted || {},
status: normalizeMachineStatus(payload.status),
confidence: clampConfidence(payload.confidence, 0.6),
reasons: asReasonList(payload.reasons, `${provider.name} completed`),
extracted: payload.extracted || {},
provider: {
name: provider.name,
reference: body.reference || null,
reference: payload.reference || null,
},
};
} catch (error) {
const isAbort = error?.name === 'AbortError';
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.3,
reasons: [
isAbort
? `${provider.name} timeout, manual review required`
: `${provider.name} unavailable, manual review required`,
],
confidence: 0.35,
reasons: [error?.message || `${provider.name} unavailable`],
extracted: {},
provider: {
name: provider.name,
@@ -310,201 +379,462 @@ async function runThirdPartyChecks(job, type) {
}
}
async function runMachineChecks(job) {
if (job.type === 'attire') {
return runAttireChecks(job);
}
async function processVerificationJob(verificationId) {
const startedJob = await withTransaction(async (client) => {
const result = await client.query(
`
SELECT *
FROM verification_jobs
WHERE id = $1
FOR UPDATE
`,
[verificationId]
);
if (job.type === 'government_id') {
return runThirdPartyChecks(job, 'government_id');
}
if (result.rowCount === 0) {
return null;
}
return runThirdPartyChecks(job, 'certification');
}
const job = result.rows[0];
if (job.status !== VerificationStatus.PENDING) {
return null;
}
async function processVerificationJob(id) {
const job = requireJob(id);
if (job.status !== VerificationStatus.PENDING) {
await client.query(
`
UPDATE verification_jobs
SET status = $2,
updated_at = NOW()
WHERE id = $1
`,
[verificationId, VerificationStatus.PROCESSING]
);
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: job.status,
toStatus: VerificationStatus.PROCESSING,
actorType: 'worker',
actorId: 'verification-worker',
});
return {
id: verificationId,
type: job.type,
fileUri: job.file_uri,
subjectType: job.subject_type,
subjectId: job.subject_id,
rules: job.metadata?.rules || {},
metadata: job.metadata || {},
};
});
if (!startedJob) {
return;
}
const beforeProcessing = job.status;
job.status = VerificationStatus.PROCESSING;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus: beforeProcessing,
toStatus: VerificationStatus.PROCESSING,
actorType: 'system',
actorId: 'verification-worker',
})
);
try {
const outcome = await runMachineChecks(job);
if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) {
throw new Error(`Invalid machine outcome status: ${outcome.status}`);
}
const fromStatus = job.status;
job.status = outcome.status;
job.confidence = outcome.confidence;
job.reasons = outcome.reasons;
job.extracted = outcome.extracted;
job.provider = outcome.provider;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
toStatus: job.status,
actorType: 'system',
const result = startedJob.type === 'attire'
? await runAttireChecks(startedJob)
: await runThirdPartyChecks(startedJob, startedJob.type);
await withTransaction(async (client) => {
await client.query(
`
UPDATE verification_jobs
SET status = $2,
confidence = $3,
reasons = $4::jsonb,
extracted = $5::jsonb,
provider_name = $6,
provider_reference = $7,
updated_at = NOW()
WHERE id = $1
`,
[
verificationId,
result.status,
result.confidence,
JSON.stringify(result.reasons || []),
JSON.stringify(result.extracted || {}),
result.provider?.name || null,
result.provider?.reference || null,
]
);
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: VerificationStatus.PROCESSING,
toStatus: result.status,
actorType: 'worker',
actorId: 'verification-worker',
details: {
confidence: job.confidence,
reasons: job.reasons,
provider: job.provider,
confidence: result.confidence,
},
})
);
});
});
} catch (error) {
const fromStatus = job.status;
job.status = VerificationStatus.ERROR;
job.confidence = null;
job.reasons = [error?.message || 'Verification processing failed'];
job.extracted = {};
job.provider = {
name: 'verification-worker',
reference: null,
};
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
await withTransaction(async (client) => {
await client.query(
`
UPDATE verification_jobs
SET status = $2,
reasons = $3::jsonb,
provider_name = 'verification-worker',
provider_reference = $4,
updated_at = NOW()
WHERE id = $1
`,
[
verificationId,
VerificationStatus.ERROR,
JSON.stringify([error?.message || 'Verification processing failed']),
`error:${error?.code || 'unknown'}`,
]
);
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: VerificationStatus.PROCESSING,
toStatus: VerificationStatus.ERROR,
actorType: 'system',
actorType: 'worker',
actorId: 'verification-worker',
details: {
error: error?.message || 'Verification processing failed',
},
})
);
}
}
function queueVerificationProcessing(id) {
setTimeout(() => {
processVerificationJob(id).catch(() => {});
}, 0);
}
export function createVerificationJob({ actorUid, payload }) {
const now = nowIso();
const id = `ver_${crypto.randomUUID()}`;
const job = {
id,
type: payload.type,
subjectType: payload.subjectType || null,
subjectId: payload.subjectId || null,
ownerUid: actorUid,
fileUri: payload.fileUri,
rules: payload.rules || {},
metadata: payload.metadata || {},
status: VerificationStatus.PENDING,
confidence: null,
reasons: [],
extracted: {},
provider: null,
review: null,
createdAt: now,
updatedAt: now,
events: [
eventRecord({
fromStatus: null,
toStatus: VerificationStatus.PENDING,
actorType: 'system',
actorId: actorUid,
}),
],
};
jobs.set(id, job);
queueVerificationProcessing(id);
return toPublicJob(job);
}
export function getVerificationJob(verificationId, actorUid) {
const job = requireJob(verificationId);
assertAccess(job, actorUid);
return toPublicJob(job);
}
export function reviewVerificationJob(verificationId, actorUid, review) {
const job = requireJob(verificationId);
assertAccess(job, actorUid);
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
verificationId,
status: job.status,
});
});
}
}
const fromStatus = job.status;
job.status = review.decision;
job.review = {
decision: review.decision,
reviewedBy: actorUid,
reviewedAt: nowIso(),
note: review.note || '',
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
};
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
toStatus: job.status,
function queueVerificationProcessing(verificationId) {
setImmediate(() => {
const worker = useMemoryStore() ? processVerificationJobInMemory : processVerificationJob;
worker(verificationId).catch(() => {});
});
}
export async function createVerificationJob({ actorUid, payload }) {
if (useMemoryStore()) {
const timestamp = new Date().toISOString();
const created = {
id: nextVerificationId(),
tenant_id: null,
staff_id: null,
owner_user_id: actorUid,
type: payload.type,
subject_type: payload.subjectType || null,
subject_id: payload.subjectId || null,
file_uri: payload.fileUri,
status: VerificationStatus.PENDING,
confidence: null,
reasons: [],
extracted: {},
provider_name: null,
provider_reference: null,
review: {},
metadata: {
...(payload.metadata || {}),
rules: payload.rules || {},
},
created_at: timestamp,
updated_at: timestamp,
};
memoryVerificationJobs.set(created.id, created);
queueVerificationProcessing(created.id);
return toPublicJob(created);
}
const context = await requireTenantContext(actorUid);
const created = await withTransaction(async (client) => {
const result = await client.query(
`
INSERT INTO verification_jobs (
tenant_id,
staff_id,
document_id,
owner_user_id,
type,
subject_type,
subject_id,
file_uri,
status,
reasons,
extracted,
review,
metadata
)
VALUES (
$1,
$2,
NULL,
$3,
$4,
$5,
$6,
$7,
'PENDING',
'[]'::jsonb,
'{}'::jsonb,
'{}'::jsonb,
$8::jsonb
)
RETURNING *
`,
[
context.tenant.tenantId,
context.staff?.staffId || null,
actorUid,
payload.type,
payload.subjectType || null,
payload.subjectId || null,
payload.fileUri,
JSON.stringify({
...(payload.metadata || {}),
rules: payload.rules || {},
}),
]
);
await appendVerificationEvent(client, {
verificationJobId: result.rows[0].id,
fromStatus: null,
toStatus: VerificationStatus.PENDING,
actorType: 'system',
actorId: actorUid,
});
return result.rows[0];
});
queueVerificationProcessing(created.id);
return toPublicJob(created);
}
export async function getVerificationJob(verificationId, actorUid) {
if (useMemoryStore()) {
const job = loadMemoryJob(verificationId);
assertAccess(job, actorUid);
return toPublicJob(job);
}
const job = await loadJob(verificationId);
assertAccess(job, actorUid);
return toPublicJob(job);
}
export async function reviewVerificationJob(verificationId, actorUid, review) {
if (useMemoryStore()) {
const job = loadMemoryJob(verificationId);
assertAccess(job, actorUid);
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
verificationId,
status: job.status,
});
}
const reviewPayload = {
decision: review.decision,
reviewedBy: actorUid,
reviewedAt: new Date().toISOString(),
note: review.note || '',
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
};
const updated = {
...job,
status: review.decision,
review: reviewPayload,
updated_at: new Date().toISOString(),
};
memoryVerificationJobs.set(verificationId, updated);
return toPublicJob(updated);
}
const context = await requireTenantContext(actorUid);
const updated = await withTransaction(async (client) => {
const result = await client.query(
`
SELECT *
FROM verification_jobs
WHERE id = $1
FOR UPDATE
`,
[verificationId]
);
if (result.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId });
}
const job = result.rows[0];
assertAccess(job, actorUid);
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
verificationId,
status: job.status,
});
}
const reviewPayload = {
decision: review.decision,
reviewedBy: actorUid,
reviewedAt: new Date().toISOString(),
note: review.note || '',
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
};
await client.query(
`
UPDATE verification_jobs
SET status = $2,
review = $3::jsonb,
updated_at = NOW()
WHERE id = $1
`,
[verificationId, review.decision, JSON.stringify(reviewPayload)]
);
await client.query(
`
INSERT INTO verification_reviews (
verification_job_id,
reviewer_user_id,
decision,
note,
reason_code
)
VALUES ($1, $2, $3, $4, $5)
`,
[verificationId, actorUid, review.decision, review.note || null, review.reasonCode || 'MANUAL_REVIEW']
);
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: job.status,
toStatus: review.decision,
actorType: 'reviewer',
actorId: actorUid,
details: {
reasonCode: job.review.reasonCode,
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
},
})
);
});
return toPublicJob(job);
return {
...job,
status: review.decision,
review: reviewPayload,
updated_at: new Date().toISOString(),
};
});
void context;
return toPublicJob(updated);
}
export function retryVerificationJob(verificationId, actorUid) {
const job = requireJob(verificationId);
assertAccess(job, actorUid);
export async function retryVerificationJob(verificationId, actorUid) {
if (useMemoryStore()) {
const job = loadMemoryJob(verificationId);
assertAccess(job, actorUid);
if (job.status === VerificationStatus.PROCESSING) {
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
verificationId,
});
}
if (job.status === VerificationStatus.PROCESSING) {
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
verificationId,
});
const updated = {
...job,
status: VerificationStatus.PENDING,
confidence: null,
reasons: [],
extracted: {},
provider_name: null,
provider_reference: null,
review: {},
updated_at: new Date().toISOString(),
};
memoryVerificationJobs.set(verificationId, updated);
queueVerificationProcessing(verificationId);
return toPublicJob(updated);
}
const fromStatus = job.status;
job.status = VerificationStatus.PENDING;
job.confidence = null;
job.reasons = [];
job.extracted = {};
job.provider = null;
job.review = null;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
const updated = await withTransaction(async (client) => {
const result = await client.query(
`
SELECT *
FROM verification_jobs
WHERE id = $1
FOR UPDATE
`,
[verificationId]
);
if (result.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId });
}
const job = result.rows[0];
assertAccess(job, actorUid);
if (job.status === VerificationStatus.PROCESSING) {
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
verificationId,
});
}
await client.query(
`
UPDATE verification_jobs
SET status = $2,
confidence = NULL,
reasons = '[]'::jsonb,
extracted = '{}'::jsonb,
provider_name = NULL,
provider_reference = NULL,
review = '{}'::jsonb,
updated_at = NOW()
WHERE id = $1
`,
[verificationId, VerificationStatus.PENDING]
);
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: job.status,
toStatus: VerificationStatus.PENDING,
actorType: 'reviewer',
actorId: actorUid,
details: {
retried: true,
},
})
);
});
return {
...job,
status: VerificationStatus.PENDING,
confidence: null,
reasons: [],
extracted: {},
provider_name: null,
provider_reference: null,
review: {},
updated_at: new Date().toISOString(),
};
});
queueVerificationProcessing(verificationId);
return toPublicJob(job);
return toPublicJob(updated);
}
export function __resetVerificationJobsForTests() {
jobs.clear();
export async function __resetVerificationJobsForTests() {
if (process.env.NODE_ENV !== 'test' && process.env.AUTH_BYPASS !== 'true') {
return;
}
memoryVerificationJobs.clear();
try {
await query('DELETE FROM verification_reviews');
await query('DELETE FROM verification_events');
await query('DELETE FROM verification_jobs');
} catch {
// Intentionally ignore when tests run without a configured database.
}
}

View File

@@ -5,7 +5,7 @@ import { createApp } from '../src/app.js';
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js';
import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js';
beforeEach(() => {
beforeEach(async () => {
process.env.AUTH_BYPASS = 'true';
process.env.LLM_MOCK = 'true';
process.env.SIGNED_URL_MOCK = 'true';
@@ -15,8 +15,9 @@ beforeEach(() => {
process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false';
process.env.VERIFICATION_ACCESS_MODE = 'authenticated';
process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock';
process.env.VERIFICATION_STORE = 'memory';
__resetLlmRateLimitForTests();
__resetVerificationJobsForTests();
await __resetVerificationJobsForTests();
});
async function waitForMachineStatus(app, verificationId, maxAttempts = 30) {
@@ -49,6 +50,22 @@ test('GET /healthz returns healthy response', async () => {
assert.equal(typeof res.headers['x-request-id'], 'string');
});
test('GET /readyz reports database not configured when env is absent', async () => {
delete process.env.DATABASE_URL;
delete process.env.DB_HOST;
delete process.env.DB_NAME;
delete process.env.DB_USER;
delete process.env.DB_PASSWORD;
delete process.env.INSTANCE_CONNECTION_NAME;
delete process.env.VERIFICATION_STORE;
const app = createApp();
const res = await request(app).get('/readyz');
assert.equal(res.status, 503);
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
});
test('POST /core/create-signed-url requires auth', async () => {
process.env.AUTH_BYPASS = 'false';
const app = createApp();

View File

@@ -16,15 +16,18 @@ mutation unseedAll @auth(level: USER) {
invoiceTemplate_deleteMany(all: true)
customRateCard_deleteMany(all: true)
vendorRate_deleteMany(all: true)
vendorBenefitPlan_deleteMany(all: true)
benefitsData_deleteMany(all: true)
workforce_deleteMany(all: true)
staffCourse_deleteMany(all: true)
staffDocument_deleteMany(all: true)
staffAttire_deleteMany(all: true)
staffRole_deleteMany(all: true)
staffAvailability_deleteMany(all: true)
staffAvailabilityStats_deleteMany(all: true)
emergencyContact_deleteMany(all: true)
taxForm_deleteMany(all: true)
certificate_deleteMany(all: true)
vendorBenefitPlan_deleteMany(all: true)
# ----------------------------------
# Tasks / Shifts / Orders
@@ -33,6 +36,7 @@ mutation unseedAll @auth(level: USER) {
shiftRole_deleteMany(all: true)
shift_deleteMany(all: true)
order_deleteMany(all: true)
costCenter_deleteMany(all: true)
# ----------------------------------
# Teams / Hubs / Org
@@ -52,7 +56,6 @@ mutation unseedAll @auth(level: USER) {
level_deleteMany(all: true)
course_deleteMany(all: true)
faqData_deleteMany(all: true)
benefitsData_deleteMany(all: true)
attireOption_deleteMany(all: true)
document_deleteMany(all: true)

View File

@@ -493,6 +493,208 @@ mutation seedAll @transaction {
}
)
# Workforce assignments
workforce_1: workforce_insert(
data: {
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1001"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
workforceNumber: "WF-1001"
employmentType: W2
status: ACTIVE
createdBy: "seed-script"
}
)
workforce_2: workforce_insert(
data: {
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1002"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
staffId: "9631581a-1601-4e06-8e5e-600e9f305bcf"
workforceNumber: "WF-1002"
employmentType: W1099
status: ACTIVE
createdBy: "seed-script"
}
)
workforce_3: workforce_insert(
data: {
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1003"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
staffId: "2b678a6d-b8cd-4d5e-95ae-f35e4569f92c"
workforceNumber: "WF-1003"
employmentType: W2
status: ACTIVE
createdBy: "seed-script"
}
)
workforce_4: workforce_insert(
data: {
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1004"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
staffId: "d62605f9-366d-42c5-8f3b-f276c0d27ea3"
workforceNumber: "WF-1004"
employmentType: W2
status: ACTIVE
createdBy: "seed-script"
}
)
workforce_5: workforce_insert(
data: {
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1005"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
staffId: "c6428f90-9c29-4e5c-b362-dc67a9a8cbba"
workforceNumber: "WF-1005"
employmentType: W1099
status: ACTIVE
createdBy: "seed-script"
}
)
workforce_6: workforce_insert(
data: {
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1006"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
staffId: "56d7178c-f4ab-4c50-9b1f-d6efe25ba50b"
workforceNumber: "WF-1006"
employmentType: CONTRACT
status: ACTIVE
createdBy: "seed-script"
}
)
workforce_7: workforce_insert(
data: {
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1007"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
workforceNumber: "WF-1007"
employmentType: W2
status: ACTIVE
createdBy: "seed-script"
}
)
# Benefit plans
benefit_plan_1: vendorBenefitPlan_insert(
data: {
id: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01001"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
title: "Paid Time Off"
description: "Annual paid time off allowance."
requestLabel: "Request PTO"
total: 80
isActive: true
}
)
benefit_plan_2: vendorBenefitPlan_insert(
data: {
id: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01002"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
title: "Sick Leave"
description: "Paid sick leave balance."
requestLabel: "Request Sick Leave"
total: 40
isActive: true
}
)
benefit_plan_3: vendorBenefitPlan_insert(
data: {
id: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01003"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
title: "Training Hours"
description: "Vendor-sponsored training time."
requestLabel: "Request Training Hours"
total: 24
isActive: true
}
)
# Benefits balances (remaining hours)
benefits_data_1: benefitsData_insert(
data: {
id: "aa8bf762-141e-4c69-ae15-7c5416fd1101"
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01001"
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
current: 52
}
)
benefits_data_2: benefitsData_insert(
data: {
id: "aa8bf762-141e-4c69-ae15-7c5416fd1102"
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01002"
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
current: 30
}
)
benefits_data_3: benefitsData_insert(
data: {
id: "aa8bf762-141e-4c69-ae15-7c5416fd1103"
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01003"
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
current: 16
}
)
benefits_data_4: benefitsData_insert(
data: {
id: "aa8bf762-141e-4c69-ae15-7c5416fd1104"
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01001"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
current: 64
}
)
benefits_data_5: benefitsData_insert(
data: {
id: "aa8bf762-141e-4c69-ae15-7c5416fd1105"
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01002"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
current: 36
}
)
benefits_data_6: benefitsData_insert(
data: {
id: "aa8bf762-141e-4c69-ae15-7c5416fd1106"
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01003"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
current: 20
}
)
# Bank accounts (client + staff)
account_1: account_insert(
data: {
id: "ed6fd954-3f25-4ab7-b44f-2f03f1ca5101"
bank: "Bank of America"
type: CHECKING
last4: "4455"
isPrimary: true
accountNumber: "883104455"
routeNumber: "121000358"
ownerId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
}
)
account_2: account_insert(
data: {
id: "ed6fd954-3f25-4ab7-b44f-2f03f1ca5102"
bank: "Chase"
type: CHECKING
last4: "3301"
isPrimary: true
accountNumber: "009813301"
routeNumber: "322271627"
ownerId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
}
)
account_3: account_insert(
data: {
id: "ed6fd954-3f25-4ab7-b44f-2f03f1ca5103"
bank: "Wells Fargo"
type: SAVINGS
last4: "7812"
isPrimary: true
accountNumber: "114927812"
routeNumber: "121042882"
ownerId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
}
)
# Documents
document_1: document_insert(
data: {
@@ -575,6 +777,41 @@ mutation seedAll @transaction {
verificationId: "verif_staff3_id_001"
}
)
staff_document_5: staffDocument_insert(
data: {
id: "bf01f474-2f2d-40f5-8ca7-0b6a1e52dd8a"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
staffName: "Test Staff"
documentId: "9fd8b1bb-63b4-4480-a53e-c65ae5f03ea8"
status: VERIFIED
documentUrl: "https://storage.googleapis.com/krow-workforce-dev/docs/staff-7/w4.pdf"
verificationId: "verif_staff7_w4_001"
verifiedAt: "2026-02-20T17:15:00Z"
}
)
staff_document_6: staffDocument_insert(
data: {
id: "bf01f474-2f2d-40f5-8ca7-0b6a1e52dd8b"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
staffName: "Test Staff"
documentId: "f3389b80-9407-45ca-bdbe-6aeb873f2f5d"
status: VERIFIED
documentUrl: "https://storage.googleapis.com/krow-workforce-dev/docs/staff-7/direct-deposit.pdf"
verificationId: "verif_staff7_dd_001"
verifiedAt: "2026-02-20T18:00:00Z"
}
)
staff_document_7: staffDocument_insert(
data: {
id: "bf01f474-2f2d-40f5-8ca7-0b6a1e52dd8c"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
staffName: "Test Staff"
documentId: "2eb0f5e0-b5f7-4ec2-8994-ae5b12ec8f57"
status: PENDING
documentUrl: "https://storage.googleapis.com/krow-workforce-dev/docs/staff-7/id-copy.png"
verificationId: "verif_staff7_id_001"
}
)
# Certificates
certificate_1: certificate_insert(
@@ -618,6 +855,34 @@ mutation seedAll @transaction {
certificateNumber: "RBS-STAFF2-1103"
}
)
certificate_4: certificate_insert(
data: {
id: "67b9ec44-6f9b-4f3a-9c8d-23f370883a90"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
certificationType: BACKGROUND_CHECK
name: "Background Check Clearance"
status: CURRENT
issuer: "Checkr"
fileUrl: "https://storage.googleapis.com/krow-workforce-dev/certs/staff-7/background.pdf"
validationStatus: APPROVED
certificateNumber: "BG-STAFF7-2026"
expiry: "2027-02-20T00:00:00Z"
}
)
certificate_5: certificate_insert(
data: {
id: "67b9ec44-6f9b-4f3a-9c8d-23f370883a91"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
certificationType: FOOD_HANDLER
name: "Food Handler Card"
status: CURRENT
issuer: "ServSafe"
fileUrl: "https://storage.googleapis.com/krow-workforce-dev/certs/staff-7/food-handler.pdf"
validationStatus: AI_VERIFIED
certificateNumber: "FH-STAFF7-2204"
expiry: "2026-12-30T00:00:00Z"
}
)
# Orders (20 total)
order_01: order_insert(
@@ -900,6 +1165,90 @@ mutation seedAll @transaction {
total: 224
}
)
order_21: order_insert(
data: {
id: "f201e540-70cd-4e49-b3b1-4c85df9a2101"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderType: ONE_TIME
status: PARTIAL_STAFFED
eventName: "Client Demo Breakfast Support"
teamHubId: "22a0b119-e6dc-4011-9043-d857cd4c12f3"
date: "2026-03-04T05:00:00Z"
requested: 2
total: 320
}
)
order_22: order_insert(
data: {
id: "f201e540-70cd-4e49-b3b1-4c85df9a2102"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderType: ONE_TIME
status: PARTIAL_STAFFED
eventName: "Conference Lounge Coverage"
teamHubId: "9c8eb9c6-c186-4d55-877e-35be852c3e86"
date: "2026-03-05T05:00:00Z"
requested: 3
total: 624
}
)
order_23: order_insert(
data: {
id: "f201e540-70cd-4e49-b3b1-4c85df9a2103"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderType: ONE_TIME
status: FULLY_STAFFED
eventName: "Executive Lunch Prep"
teamHubId: "75c70d83-1680-4b28-ab61-2fe64a74fc5f"
date: "2026-03-06T05:00:00Z"
requested: 2
total: 288
}
)
order_24: order_insert(
data: {
id: "f201e540-70cd-4e49-b3b1-4c85df9a2104"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderType: ONE_TIME
status: COMPLETED
eventName: "Late Shift Cleanup"
teamHubId: "22a0b119-e6dc-4011-9043-d857cd4c12f3"
date: "2026-03-03T05:00:00Z"
requested: 1
total: 208
}
)
order_25: order_insert(
data: {
id: "f201e540-70cd-4e49-b3b1-4c85df9a2105"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderType: ONE_TIME
status: POSTED
eventName: "Sunday Brunch Standby"
teamHubId: "9c8eb9c6-c186-4d55-877e-35be852c3e86"
date: "2026-03-08T05:00:00Z"
requested: 1
total: 120
}
)
order_26: order_insert(
data: {
id: "f201e540-70cd-4e49-b3b1-4c85df9a2106"
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderType: ONE_TIME
status: FULLY_STAFFED
eventName: "Security Night Rotation"
teamHubId: "75c70d83-1680-4b28-ab61-2fe64a74fc5f"
date: "2026-03-10T05:00:00Z"
requested: 1
total: 280
}
)
# Shifts (1 per order)
shift_01: shift_insert(
@@ -1362,6 +1711,148 @@ mutation seedAll @transaction {
filled: 0
}
)
shift_21: shift_insert(
data: {
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e301"
title: "Client Demo Breakfast Support Shift"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2101"
date: "2026-03-04T05:00:00Z"
startTime: "2026-03-04T14:00:00Z"
endTime: "2026-03-04T22:00:00Z"
hours: 8
cost: 320
locationAddress: "5000 San Jose Street, Granada Hills, CA, USA"
city: "Los Angeles"
state: "CA"
street: "San Jose Street"
country: "US"
placeId: "Eiw1MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
latitude: 34.2611486
longitude: -118.5010287
status: IN_PROGRESS
workersNeeded: 2
filled: 1
filledAt: "2026-03-03T20:00:00Z"
}
)
shift_22: shift_insert(
data: {
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e302"
title: "Conference Lounge Coverage Shift"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2102"
date: "2026-03-05T05:00:00Z"
startTime: "2026-03-05T15:00:00Z"
endTime: "2026-03-05T23:00:00Z"
hours: 8
cost: 624
locationAddress: "4000 San Jose Street, Granada Hills, CA, USA"
city: "Los Angeles"
state: "CA"
street: "San Jose Street"
country: "US"
placeId: "Eiw0MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
latitude: 34.2611486
longitude: -118.5010287
status: OPEN
workersNeeded: 3
filled: 2
}
)
shift_23: shift_insert(
data: {
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e303"
title: "Executive Lunch Prep Shift"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2103"
date: "2026-03-06T05:00:00Z"
startTime: "2026-03-06T16:00:00Z"
endTime: "2026-03-06T22:00:00Z"
hours: 6
cost: 288
locationAddress: "6800 San Jose Street, Granada Hills, CA, USA"
city: "Los Angeles"
state: "CA"
street: "San Jose Street"
country: "US"
placeId: "Eiw2ODAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
latitude: 34.2611486
longitude: -118.5010287
status: FILLED
workersNeeded: 2
filled: 2
filledAt: "2026-03-04T18:10:00Z"
}
)
shift_24: shift_insert(
data: {
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e304"
title: "Late Shift Cleanup Shift"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2104"
date: "2026-03-03T05:00:00Z"
startTime: "2026-03-03T14:00:00Z"
endTime: "2026-03-03T22:00:00Z"
hours: 8
cost: 208
locationAddress: "5000 San Jose Street, Granada Hills, CA, USA"
city: "Los Angeles"
state: "CA"
street: "San Jose Street"
country: "US"
placeId: "Eiw1MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
latitude: 34.2611486
longitude: -118.5010287
status: COMPLETED
workersNeeded: 1
filled: 1
filledAt: "2026-03-02T22:30:00Z"
}
)
shift_25: shift_insert(
data: {
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e305"
title: "Sunday Brunch Standby Shift"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2105"
date: "2026-03-08T05:00:00Z"
startTime: "2026-03-08T15:00:00Z"
endTime: "2026-03-08T20:00:00Z"
hours: 5
cost: 120
locationAddress: "4000 San Jose Street, Granada Hills, CA, USA"
city: "Los Angeles"
state: "CA"
street: "San Jose Street"
country: "US"
placeId: "Eiw0MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
latitude: 34.2611486
longitude: -118.5010287
status: OPEN
workersNeeded: 1
filled: 0
}
)
shift_26: shift_insert(
data: {
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e306"
title: "Security Night Rotation Shift"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2106"
date: "2026-03-10T05:00:00Z"
startTime: "2026-03-10T14:00:00Z"
endTime: "2026-03-11T00:00:00Z"
hours: 10
cost: 280
locationAddress: "6800 San Jose Street, Granada Hills, CA, USA"
city: "Los Angeles"
state: "CA"
street: "San Jose Street"
country: "US"
placeId: "Eiw2ODAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
latitude: 34.2611486
longitude: -118.5010287
status: FILLED
workersNeeded: 1
filled: 1
filledAt: "2026-03-09T19:00:00Z"
}
)
# Shift Roles (1 per shift)
shift_role_01: shiftRole_insert(
@@ -1644,6 +2135,90 @@ mutation seedAll @transaction {
totalValue: 224
}
)
shift_role_21: shiftRole_insert(
data: {
id: "360616bf-8083-4dff-8d22-82380304d901"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e301"
roleId: "73fdb09b-ecbd-402e-8eb4-e7d79237d017"
count: 2
assigned: 1
startTime: "2026-03-04T14:00:00Z"
endTime: "2026-03-04T22:00:00Z"
hours: 8
breakType: MIN_30
totalValue: 320
}
)
shift_role_22: shiftRole_insert(
data: {
id: "360616bf-8083-4dff-8d22-82380304d902"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302"
roleId: "7de956ce-743b-4271-b826-73313a5f07f5"
count: 3
assigned: 2
startTime: "2026-03-05T15:00:00Z"
endTime: "2026-03-05T23:00:00Z"
hours: 8
breakType: MIN_30
totalValue: 624
}
)
shift_role_23: shiftRole_insert(
data: {
id: "360616bf-8083-4dff-8d22-82380304d903"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303"
roleId: "e51f3553-f2ee-400b-91e6-92b534239697"
count: 2
assigned: 2
startTime: "2026-03-06T16:00:00Z"
endTime: "2026-03-06T22:00:00Z"
hours: 6
breakType: MIN_15
totalValue: 288
}
)
shift_role_24: shiftRole_insert(
data: {
id: "360616bf-8083-4dff-8d22-82380304d904"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e304"
roleId: "7de956ce-743b-4271-b826-73313a5f07f5"
count: 1
assigned: 1
startTime: "2026-03-03T14:00:00Z"
endTime: "2026-03-03T22:00:00Z"
hours: 8
breakType: MIN_30
totalValue: 208
}
)
shift_role_25: shiftRole_insert(
data: {
id: "360616bf-8083-4dff-8d22-82380304d905"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e305"
roleId: "e51f3553-f2ee-400b-91e6-92b534239697"
count: 1
assigned: 0
startTime: "2026-03-08T15:00:00Z"
endTime: "2026-03-08T20:00:00Z"
hours: 5
breakType: MIN_15
totalValue: 120
}
)
shift_role_26: shiftRole_insert(
data: {
id: "360616bf-8083-4dff-8d22-82380304d906"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e306"
roleId: "67ab1dcb-5b54-4dd9-aeb5-9cc58bdda0ed"
count: 1
assigned: 1
startTime: "2026-03-10T14:00:00Z"
endTime: "2026-03-11T00:00:00Z"
hours: 10
breakType: MIN_30
totalValue: 280
}
)
# Applications
application_01: application_insert(
@@ -1836,6 +2411,80 @@ mutation seedAll @transaction {
origin: STAFF
}
)
application_20: application_insert(
data: {
id: "b8c4b723-346d-4bcd-9667-35944ba5dbbe"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e301"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
roleId: "73fdb09b-ecbd-402e-8eb4-e7d79237d017"
status: CHECKED_IN
checkInTime: "2026-03-04T14:02:00Z"
origin: STAFF
}
)
application_21: application_insert(
data: {
id: "b8c4b723-346d-4bcd-9667-35944ba5dbbf"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e304"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
roleId: "7de956ce-743b-4271-b826-73313a5f07f5"
status: COMPLETED
checkInTime: "2026-03-03T14:05:00Z"
checkOutTime: "2026-03-03T22:01:00Z"
origin: STAFF
}
)
application_22: application_insert(
data: {
id: "b8c4b723-346d-4bcd-9667-35944ba5dbc0"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303"
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
roleId: "e51f3553-f2ee-400b-91e6-92b534239697"
status: CONFIRMED
origin: STAFF
}
)
application_23: application_insert(
data: {
id: "b8c4b723-346d-4bcd-9667-35944ba5dbc1"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302"
staffId: "9631581a-1601-4e06-8e5e-600e9f305bcf"
roleId: "7de956ce-743b-4271-b826-73313a5f07f5"
status: LATE
checkInTime: "2026-03-05T15:25:00Z"
origin: STAFF
}
)
application_24: application_insert(
data: {
id: "b8c4b723-346d-4bcd-9667-35944ba5dbc2"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302"
staffId: "2b678a6d-b8cd-4d5e-95ae-f35e4569f92c"
roleId: "7de956ce-743b-4271-b826-73313a5f07f5"
status: NO_SHOW
origin: STAFF
}
)
application_25: application_insert(
data: {
id: "b8c4b723-346d-4bcd-9667-35944ba5dbc3"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e306"
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
roleId: "67ab1dcb-5b54-4dd9-aeb5-9cc58bdda0ed"
status: CONFIRMED
origin: STAFF
}
)
application_26: application_insert(
data: {
id: "b8c4b723-346d-4bcd-9667-35944ba5dbc4"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303"
staffId: "56d7178c-f4ab-4c50-9b1f-d6efe25ba50b"
roleId: "e51f3553-f2ee-400b-91e6-92b534239697"
status: CONFIRMED
origin: STAFF
}
)
# Invoices (for completed orders)
invoice_01: invoice_insert(
@@ -2030,6 +2679,91 @@ mutation seedAll @transaction {
chargesCount: 1
}
)
invoice_13: invoice_insert(
data: {
id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f1"
status: PENDING
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2101"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e301"
paymentTerms: NET_30
invoiceNumber: "INV-2026-0013"
issueDate: "2026-03-04T05:00:00Z"
dueDate: "2026-04-03T05:00:00Z"
amount: 320
staffCount: 2
chargesCount: 1
}
)
invoice_14: invoice_insert(
data: {
id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f2"
status: OVERDUE
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2102"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302"
paymentTerms: NET_30
invoiceNumber: "INV-2026-0014"
issueDate: "2026-02-20T05:00:00Z"
dueDate: "2026-03-01T05:00:00Z"
amount: 624
staffCount: 3
chargesCount: 1
}
)
invoice_15: invoice_insert(
data: {
id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f3"
status: APPROVED
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2103"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303"
paymentTerms: NET_30
invoiceNumber: "INV-2026-0015"
issueDate: "2026-03-06T05:00:00Z"
dueDate: "2026-04-05T05:00:00Z"
amount: 288
staffCount: 2
chargesCount: 1
}
)
invoice_16: invoice_insert(
data: {
id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f4"
status: PAID
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2104"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e304"
paymentTerms: NET_30
invoiceNumber: "INV-2026-0016"
issueDate: "2026-03-03T05:00:00Z"
dueDate: "2026-04-02T05:00:00Z"
amount: 208
staffCount: 1
chargesCount: 1
}
)
invoice_17: invoice_insert(
data: {
id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f5"
status: PENDING_REVIEW
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2106"
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e306"
paymentTerms: NET_30
invoiceNumber: "INV-2026-0017"
issueDate: "2026-03-10T05:00:00Z"
dueDate: "2026-04-09T05:00:00Z"
amount: 280
staffCount: 1
chargesCount: 1
}
)
# Recent Payments (only for PAID invoices)
recent_payment_01: recentPayment_insert(
@@ -2062,6 +2796,46 @@ mutation seedAll @transaction {
invoiceId: "ba0529be-7906-417f-8ec7-c866d0633fee"
}
)
recent_payment_04: recentPayment_insert(
data: {
id: "4d45192e-34fe-4e07-a4f9-708e7591a9b6"
workedTime: "8h"
status: PAID
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbbf"
invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f4"
}
)
recent_payment_05: recentPayment_insert(
data: {
id: "4d45192e-34fe-4e07-a4f9-708e7591a9b7"
workedTime: "8h"
status: PENDING
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbbe"
invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f1"
}
)
recent_payment_06: recentPayment_insert(
data: {
id: "4d45192e-34fe-4e07-a4f9-708e7591a9b8"
workedTime: "6h"
status: PENDING
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbc0"
invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f3"
}
)
recent_payment_07: recentPayment_insert(
data: {
id: "4d45192e-34fe-4e07-a4f9-708e7591a9b9"
workedTime: "6h"
status: PENDING
staffId: "56d7178c-f4ab-4c50-9b1f-d6efe25ba50b"
applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbc4"
invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f3"
}
)
# Attire Options (Required)
attire_1: attireOption_insert(
@@ -2220,5 +2994,54 @@ mutation seedAll @transaction {
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
staff_attire_1: staffAttire_insert(
data: {
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
attireOptionId: "4bce6592-e38e-4d90-a478-d1ce0f286146"
verificationStatus: APPROVED
verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-7/non-slip-shoes.jpg"
verificationId: "attire_verif_staff7_001"
verifiedAt: "2026-03-01T17:00:00Z"
}
)
staff_attire_2: staffAttire_insert(
data: {
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
attireOptionId: "786e9761-b398-42bd-b363-91a40938864e"
verificationStatus: APPROVED
verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-7/black-pants.jpg"
verificationId: "attire_verif_staff7_002"
verifiedAt: "2026-03-01T17:05:00Z"
}
)
staff_attire_3: staffAttire_insert(
data: {
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
attireOptionId: "bbff61b3-3f99-4637-9a2f-1d4c6fa61517"
verificationStatus: PROCESSING
verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-7/white-button-up.jpg"
verificationId: "attire_verif_staff7_003"
}
)
staff_attire_4: staffAttire_insert(
data: {
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
attireOptionId: "4bce6592-e38e-4d90-a478-d1ce0f286146"
verificationStatus: APPROVED
verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-1/non-slip-shoes.jpg"
verificationId: "attire_verif_staff1_001"
verifiedAt: "2026-02-21T16:20:00Z"
}
)
staff_attire_5: staffAttire_insert(
data: {
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
attireOptionId: "17b135e6-b8f0-4541-b12b-505e95de31ef"
verificationStatus: APPROVED
verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-1/black-socks.jpg"
verificationId: "attire_verif_staff1_002"
verifiedAt: "2026-02-21T16:23:00Z"
}
)
}
#v.3
#v.4

View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV PORT=8080
EXPOSE 8080
CMD ["node", "src/server.js"]

3039
backend/query-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"name": "@krow/query-api",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node src/server.js",
"test": "node --test"
},
"dependencies": {
"express": "^4.21.2",
"firebase-admin": "^13.0.2",
"pg": "^8.20.0",
"pino": "^9.6.0",
"pino-http": "^10.3.0"
},
"devDependencies": {
"supertest": "^7.0.0"
}
}

View File

@@ -0,0 +1,32 @@
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { requestContext } from './middleware/request-context.js';
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js';
import { createQueryRouter } from './routes/query.js';
import { createMobileQueryRouter } from './routes/mobile.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp(options = {}) {
const app = express();
app.use(requestContext);
app.use(
pinoHttp({
logger,
customProps: (req) => ({ requestId: req.requestId }),
})
);
app.use(express.json({ limit: '2mb' }));
app.use(healthRouter);
app.use('/query', createQueryRouter(options.queryService));
app.use('/query', createMobileQueryRouter(options.mobileQueryService));
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}

View File

@@ -0,0 +1,41 @@
export const FAQ_CATEGORIES = [
{
category: 'Getting Started',
items: [
{
question: 'How do I complete my worker profile?',
answer: 'Finish your personal info, preferred locations, experience, emergency contact, attire, and tax forms so shift applications and clock-in become available.',
},
{
question: 'Why can I not apply to shifts yet?',
answer: 'The worker profile must be complete before the platform allows applications and shift acceptance. Missing sections are returned by the profile completion endpoints.',
},
],
},
{
category: 'Shifts And Attendance',
items: [
{
question: 'How does clock-in work?',
answer: 'Clock-in validates that you are assigned to the shift, near the configured hub geofence, and using the expected clock-in source such as near-field communication when required.',
},
{
question: 'What happens if I request a swap?',
answer: 'The assignment moves to swap-requested status so operations can refill the shift while keeping an audit trail of the original assignment.',
},
],
},
{
category: 'Payments And Compliance',
items: [
{
question: 'When do I see my earnings?',
answer: 'Completed and processed time records appear in the worker payments summary, history, and time-card screens after attendance closes and payment processing runs.',
},
{
question: 'How are documents and certificates verified?',
answer: 'Uploads create verification jobs that run automatic checks first and then allow manual review when confidence is low or a provider is unavailable.',
},
],
},
];

View File

@@ -0,0 +1,26 @@
export class AppError extends Error {
constructor(code, message, status = 400, details = {}) {
super(message);
this.name = 'AppError';
this.code = code;
this.status = status;
this.details = details;
}
}
export function toErrorEnvelope(error, requestId) {
const status = error?.status && Number.isInteger(error.status) ? error.status : 500;
const code = error?.code || 'INTERNAL_ERROR';
const message = error?.message || 'Unexpected error';
const details = error?.details || {};
return {
status,
body: {
code,
message,
details,
requestId,
},
};
}

View File

@@ -0,0 +1,45 @@
import { AppError } from '../lib/errors.js';
import { can } from '../services/policy.js';
import { verifyFirebaseToken } from '../services/firebase-auth.js';
function getBearerToken(header) {
if (!header) return null;
const [scheme, token] = header.split(' ');
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null;
return token;
}
export async function requireAuth(req, _res, next) {
try {
const token = getBearerToken(req.get('Authorization'));
if (!token) {
throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401);
}
if (process.env.AUTH_BYPASS === 'true') {
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
return next();
}
const decoded = await verifyFirebaseToken(token);
req.actor = {
uid: decoded.uid,
email: decoded.email || null,
role: decoded.role || null,
};
return next();
} catch (error) {
if (error instanceof AppError) return next(error);
return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401));
}
}
export function requirePolicy(action, resource) {
return (req, _res, next) => {
if (!can(action, resource, req.actor)) {
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
}
return next();
};
}

View File

@@ -0,0 +1,25 @@
import { toErrorEnvelope } from '../lib/errors.js';
export function notFoundHandler(req, res) {
res.status(404).json({
code: 'NOT_FOUND',
message: `Route not found: ${req.method} ${req.path}`,
details: {},
requestId: req.requestId,
});
}
export function errorHandler(error, req, res, _next) {
const envelope = toErrorEnvelope(error, req.requestId);
if (req.log) {
req.log.error(
{
errCode: envelope.body.code,
status: envelope.status,
details: envelope.body.details,
},
envelope.body.message
);
}
res.status(envelope.status).json(envelope.body);
}

View File

@@ -0,0 +1,9 @@
import { randomUUID } from 'node:crypto';
export function requestContext(req, res, next) {
const incoming = req.get('X-Request-Id');
req.requestId = incoming || randomUUID();
res.setHeader('X-Request-Id', req.requestId);
res.locals.startedAt = Date.now();
next();
}

View File

@@ -0,0 +1,45 @@
import { Router } from 'express';
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
export const healthRouter = Router();
function healthHandler(req, res) {
res.status(200).json({
ok: true,
service: 'krow-query-api',
version: process.env.SERVICE_VERSION || 'dev',
requestId: req.requestId,
});
}
healthRouter.get('/health', healthHandler);
healthRouter.get('/healthz', healthHandler);
healthRouter.get('/readyz', async (req, res) => {
if (!isDatabaseConfigured()) {
return res.status(503).json({
ok: false,
service: 'krow-query-api',
status: 'DATABASE_NOT_CONFIGURED',
requestId: req.requestId,
});
}
try {
const ok = await checkDatabaseHealth();
return res.status(ok ? 200 : 503).json({
ok,
service: 'krow-query-api',
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
requestId: req.requestId,
});
} catch (error) {
return res.status(503).json({
ok: false,
service: 'krow-query-api',
status: 'DATABASE_UNAVAILABLE',
details: { message: error.message },
requestId: req.requestId,
});
}
});

View File

@@ -0,0 +1,662 @@
import { Router } from 'express';
import { requireAuth, requirePolicy } from '../middleware/auth.js';
import {
getCoverageReport,
getClientDashboard,
getClientSession,
getCoverageStats,
getCurrentAttendanceStatus,
getCurrentBill,
getDailyOpsReport,
getPaymentChart,
getPaymentsSummary,
getPersonalInfo,
getPerformanceReport,
getProfileSectionsStatus,
getPrivacySettings,
getForecastReport,
getNoShowReport,
getOrderReorderPreview,
listGeofenceIncidents,
getReportSummary,
getSavings,
getStaffDashboard,
getStaffProfileCompletion,
getStaffSession,
getStaffShiftDetail,
listAttireChecklist,
listAssignedShifts,
listBusinessAccounts,
listBusinessTeamMembers,
listCancelledShifts,
listCertificates,
listCostCenters,
listCoreTeam,
listCoverageByDate,
listCompletedShifts,
listEmergencyContacts,
listFaqCategories,
listHubManagers,
listHubs,
listIndustries,
listInvoiceHistory,
listOpenShifts,
listTaxForms,
listTimeCardEntries,
listOrderItemsByDateRange,
listPaymentsHistory,
listPendingAssignments,
listPendingInvoices,
listProfileDocuments,
listRecentReorders,
listSkills,
listStaffAvailability,
listStaffBankAccounts,
listStaffBenefits,
listTodayShifts,
listVendorRoles,
listVendors,
searchFaqs,
getSpendBreakdown,
getSpendReport,
} from '../services/mobile-query-service.js';
const defaultQueryService = {
getClientDashboard,
getClientSession,
getCoverageReport,
getCoverageStats,
getCurrentAttendanceStatus,
getCurrentBill,
getDailyOpsReport,
getPaymentChart,
getPaymentsSummary,
getPersonalInfo,
getPerformanceReport,
getProfileSectionsStatus,
getPrivacySettings,
getForecastReport,
getNoShowReport,
getOrderReorderPreview,
listGeofenceIncidents,
getReportSummary,
getSavings,
getSpendBreakdown,
getSpendReport,
getStaffDashboard,
getStaffProfileCompletion,
getStaffSession,
getStaffShiftDetail,
listAttireChecklist,
listAssignedShifts,
listBusinessAccounts,
listBusinessTeamMembers,
listCancelledShifts,
listCertificates,
listCostCenters,
listCoreTeam,
listCoverageByDate,
listCompletedShifts,
listEmergencyContacts,
listFaqCategories,
listHubManagers,
listHubs,
listIndustries,
listInvoiceHistory,
listOpenShifts,
listTaxForms,
listTimeCardEntries,
listOrderItemsByDateRange,
listPaymentsHistory,
listPendingAssignments,
listPendingInvoices,
listProfileDocuments,
listRecentReorders,
listSkills,
listStaffAvailability,
listStaffBankAccounts,
listStaffBenefits,
listTodayShifts,
listVendorRoles,
listVendors,
searchFaqs,
};
function requireQueryParam(name, value) {
if (!value) {
const error = new Error(`${name} is required`);
error.code = 'VALIDATION_ERROR';
error.status = 400;
error.details = { field: name };
throw error;
}
return value;
}
export function createMobileQueryRouter(queryService = defaultQueryService) {
const router = Router();
router.get('/client/session', requireAuth, requirePolicy('client.session.read', 'session'), async (req, res, next) => {
try {
const data = await queryService.getClientSession(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/dashboard', requireAuth, requirePolicy('client.dashboard.read', 'dashboard'), async (req, res, next) => {
try {
const data = await queryService.getClientDashboard(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reorders', requireAuth, requirePolicy('orders.reorder.read', 'order'), async (req, res, next) => {
try {
const items = await queryService.listRecentReorders(req.actor.uid, req.query.limit);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/accounts', requireAuth, requirePolicy('billing.accounts.read', 'billing'), async (req, res, next) => {
try {
const items = await queryService.listBusinessAccounts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/invoices/pending', requireAuth, requirePolicy('billing.invoices.read', 'billing'), async (req, res, next) => {
try {
const items = await queryService.listPendingInvoices(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/invoices/history', requireAuth, requirePolicy('billing.invoices.read', 'billing'), async (req, res, next) => {
try {
const items = await queryService.listInvoiceHistory(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/current-bill', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => {
try {
const data = await queryService.getCurrentBill(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/savings', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => {
try {
const data = await queryService.getSavings(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/billing/spend-breakdown', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => {
try {
const items = await queryService.getSpendBreakdown(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listCoverageByDate(req.actor.uid, { date: requireQueryParam('date', req.query.date) });
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/stats', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const data = await queryService.getCoverageStats(req.actor.uid, { date: requireQueryParam('date', req.query.date) });
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/core-team', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listCoreTeam(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/incidents', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listGeofenceIncidents(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listHubs(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/cost-centers', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listCostCenters(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/vendors', requireAuth, requirePolicy('vendors.read', 'vendor'), async (req, res, next) => {
try {
const items = await queryService.listVendors(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/vendors/:vendorId/roles', requireAuth, requirePolicy('vendors.read', 'vendor'), async (req, res, next) => {
try {
const items = await queryService.listVendorRoles(req.actor.uid, req.params.vendorId);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/hubs/:hubId/managers', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listHubManagers(req.actor.uid, req.params.hubId);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/team-members', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listBusinessTeamMembers(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
try {
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/orders/:orderId/reorder-preview', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
try {
const data = await queryService.getOrderReorderPreview(req.actor.uid, req.params.orderId);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/summary', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getReportSummary(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/daily-ops', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getDailyOpsReport(req.actor.uid, { date: requireQueryParam('date', req.query.date) });
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/spend', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getSpendReport(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/coverage', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getCoverageReport(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/forecast', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getForecastReport(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/performance', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getPerformanceReport(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/no-show', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getNoShowReport(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/session', requireAuth, requirePolicy('staff.session.read', 'session'), async (req, res, next) => {
try {
const data = await queryService.getStaffSession(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/dashboard', requireAuth, requirePolicy('staff.dashboard.read', 'dashboard'), async (req, res, next) => {
try {
const data = await queryService.getStaffDashboard(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile-completion', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const data = await queryService.getStaffProfileCompletion(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/availability', requireAuth, requirePolicy('staff.availability.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listStaffAvailability(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/clock-in/shifts/today', requireAuth, requirePolicy('attendance.read', 'attendance'), async (req, res, next) => {
try {
const items = await queryService.listTodayShifts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/clock-in/status', requireAuth, requirePolicy('attendance.read', 'attendance'), async (req, res, next) => {
try {
const data = await queryService.getCurrentAttendanceStatus(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/payments/summary', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => {
try {
const data = await queryService.getPaymentsSummary(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/payments/history', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => {
try {
const items = await queryService.listPaymentsHistory(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/payments/chart', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => {
try {
const items = await queryService.getPaymentChart(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/assigned', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listAssignedShifts(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/open', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listOpenShifts(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listPendingAssignments(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/cancelled', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listCancelledShifts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/completed', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listCompletedShifts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/:shiftId', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const data = await queryService.getStaffShiftDetail(req.actor.uid, req.params.shiftId);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/sections', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const data = await queryService.getProfileSectionsStatus(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/personal-info', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const data = await queryService.getPersonalInfo(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/industries', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listIndustries(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/skills', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listSkills(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/documents', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listProfileDocuments(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/attire', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listAttireChecklist(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/tax-forms', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listTaxForms(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/emergency-contacts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listEmergencyContacts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/certificates', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listCertificates(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/bank-accounts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listStaffBankAccounts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/benefits', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listStaffBenefits(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listTimeCardEntries(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/privacy', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const data = await queryService.getPrivacySettings(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/faqs', async (req, res, next) => {
try {
const items = await queryService.listFaqCategories();
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/faqs/search', async (req, res, next) => {
try {
const items = await queryService.searchFaqs(req.query.q || '');
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
return router;
}

View File

@@ -0,0 +1,138 @@
import { Router } from 'express';
import { AppError } from '../lib/errors.js';
import { requireAuth, requirePolicy } from '../middleware/auth.js';
import {
getAssignmentAttendance,
getOrderDetail,
getStaffReviewSummary,
listFavoriteStaff,
listOrders,
} from '../services/query-service.js';
const defaultQueryService = {
getAssignmentAttendance,
getOrderDetail,
getStaffReviewSummary,
listFavoriteStaff,
listOrders,
};
function requireUuid(value, field) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
throw new AppError('VALIDATION_ERROR', `${field} must be a UUID`, 400, { field });
}
return value;
}
export function createQueryRouter(queryService = defaultQueryService) {
const router = Router();
router.get(
'/tenants/:tenantId/orders',
requireAuth,
requirePolicy('orders.read', 'order'),
async (req, res, next) => {
try {
const tenantId = requireUuid(req.params.tenantId, 'tenantId');
const orders = await queryService.listOrders({
tenantId,
businessId: req.query.businessId,
status: req.query.status,
limit: req.query.limit,
offset: req.query.offset,
});
return res.status(200).json({
items: orders,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/orders/:orderId',
requireAuth,
requirePolicy('orders.read', 'order'),
async (req, res, next) => {
try {
const order = await queryService.getOrderDetail({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
orderId: requireUuid(req.params.orderId, 'orderId'),
});
return res.status(200).json({
...order,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/businesses/:businessId/favorite-staff',
requireAuth,
requirePolicy('business.favorite-staff.read', 'staff'),
async (req, res, next) => {
try {
const items = await queryService.listFavoriteStaff({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
businessId: requireUuid(req.params.businessId, 'businessId'),
limit: req.query.limit,
offset: req.query.offset,
});
return res.status(200).json({
items,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/staff/:staffId/review-summary',
requireAuth,
requirePolicy('staff.reviews.read', 'staff'),
async (req, res, next) => {
try {
const summary = await queryService.getStaffReviewSummary({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
staffId: requireUuid(req.params.staffId, 'staffId'),
limit: req.query.limit,
});
return res.status(200).json({
...summary,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/assignments/:assignmentId/attendance',
requireAuth,
requirePolicy('attendance.read', 'attendance'),
async (req, res, next) => {
try {
const attendance = await queryService.getAssignmentAttendance({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'),
});
return res.status(200).json({
...attendance,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
return router;
}

View File

@@ -0,0 +1,9 @@
import { createApp } from './app.js';
const port = Number(process.env.PORT || 8080);
const app = createApp();
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`krow-query-api listening on port ${port}`);
});

View File

@@ -0,0 +1,111 @@
import { AppError } from '../lib/errors.js';
import { query } from './db.js';
export async function loadActorContext(uid) {
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
query(
`
SELECT id AS "userId", email, display_name AS "displayName", phone, status
FROM users
WHERE id = $1
`,
[uid]
),
query(
`
SELECT tm.id AS "membershipId",
tm.tenant_id AS "tenantId",
tm.base_role AS role,
t.name AS "tenantName",
t.slug AS "tenantSlug"
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = $1
AND tm.membership_status = 'ACTIVE'
ORDER BY tm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT bm.id AS "membershipId",
bm.business_id AS "businessId",
bm.business_role AS role,
b.business_name AS "businessName",
b.slug AS "businessSlug",
bm.tenant_id AS "tenantId"
FROM business_memberships bm
JOIN businesses b ON b.id = bm.business_id
WHERE bm.user_id = $1
AND bm.membership_status = 'ACTIVE'
ORDER BY bm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT vm.id AS "membershipId",
vm.vendor_id AS "vendorId",
vm.vendor_role AS role,
v.company_name AS "vendorName",
v.slug AS "vendorSlug",
vm.tenant_id AS "tenantId"
FROM vendor_memberships vm
JOIN vendors v ON v.id = vm.vendor_id
WHERE vm.user_id = $1
AND vm.membership_status = 'ACTIVE'
ORDER BY vm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT s.id AS "staffId",
s.tenant_id AS "tenantId",
s.full_name AS "fullName",
s.email,
s.phone,
s.primary_role AS "primaryRole",
s.onboarding_status AS "onboardingStatus",
s.status,
s.metadata,
w.id AS "workforceId",
w.vendor_id AS "vendorId",
w.workforce_number AS "workforceNumber"
FROM staffs s
LEFT JOIN workforce w ON w.staff_id = s.id
WHERE s.user_id = $1
ORDER BY s.created_at ASC
LIMIT 1
`,
[uid]
),
]);
return {
user: userResult.rows[0] || null,
tenant: tenantResult.rows[0] || null,
business: businessResult.rows[0] || null,
vendor: vendorResult.rows[0] || null,
staff: staffResult.rows[0] || null,
};
}
export async function requireClientContext(uid) {
const context = await loadActorContext(uid);
if (!context.user || !context.tenant || !context.business) {
throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid });
}
return context;
}
export async function requireStaffContext(uid) {
const context = await loadActorContext(uid);
if (!context.user || !context.tenant || !context.staff) {
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
}
return context;
}

View File

@@ -0,0 +1,84 @@
import pg from 'pg';
const { Pool, types } = pg;
function parseNumericDatabaseValue(value) {
if (value == null) return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
// Mobile/frontend routes expect numeric JSON values for database aggregates.
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
let pool;
function parseIntOrDefault(value, fallback) {
const parsed = Number.parseInt(`${value || fallback}`, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function resolveDatabasePoolConfig() {
if (process.env.DATABASE_URL) {
return {
connectionString: process.env.DATABASE_URL,
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
const user = process.env.DB_USER;
const password = process.env.DB_PASSWORD;
const database = process.env.DB_NAME;
const host = process.env.DB_HOST || (
process.env.INSTANCE_CONNECTION_NAME
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
: ''
);
if (!user || password == null || !database || !host) {
return null;
}
return {
host,
port: parseIntOrDefault(process.env.DB_PORT, 5432),
user,
password,
database,
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
export function isDatabaseConfigured() {
return Boolean(resolveDatabasePoolConfig());
}
function getPool() {
if (!pool) {
const resolved = resolveDatabasePoolConfig();
if (!resolved) {
throw new Error('Database connection settings are required');
}
pool = new Pool(resolved);
}
return pool;
}
export async function query(text, params = []) {
return getPool().query(text, params);
}
export async function checkDatabaseHealth() {
const result = await query('SELECT 1 AS ok');
return result.rows[0]?.ok === 1;
}
export async function closePool() {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -0,0 +1,13 @@
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
function ensureAdminApp() {
if (getApps().length === 0) {
initializeApp({ credential: applicationDefault() });
}
}
export async function verifyFirebaseToken(token) {
ensureAdminApp();
return getAuth().verifyIdToken(token);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
export function can(action, resource, actor) {
void action;
void resource;
return Boolean(actor?.uid);
}

View File

@@ -0,0 +1,285 @@
import { AppError } from '../lib/errors.js';
import { query } from './db.js';
function parseLimit(value, fallback = 20, max = 100) {
const parsed = Number.parseInt(`${value || fallback}`, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(parsed, max);
}
function parseOffset(value) {
const parsed = Number.parseInt(`${value || 0}`, 10);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return parsed;
}
export async function listOrders({ tenantId, businessId, status, limit, offset }) {
const result = await query(
`
SELECT
o.id,
o.order_number AS "orderNumber",
o.title,
o.status,
o.service_type AS "serviceType",
o.starts_at AS "startsAt",
o.ends_at AS "endsAt",
o.location_name AS "locationName",
o.location_address AS "locationAddress",
o.created_at AS "createdAt",
b.id AS "businessId",
b.business_name AS "businessName",
v.id AS "vendorId",
v.company_name AS "vendorName",
COALESCE(COUNT(s.id), 0)::INTEGER AS "shiftCount",
COALESCE(SUM(s.required_workers), 0)::INTEGER AS "requiredWorkers",
COALESCE(SUM(s.assigned_workers), 0)::INTEGER AS "assignedWorkers"
FROM orders o
JOIN businesses b ON b.id = o.business_id
LEFT JOIN vendors v ON v.id = o.vendor_id
LEFT JOIN shifts s ON s.order_id = o.id
WHERE o.tenant_id = $1
AND ($2::uuid IS NULL OR o.business_id = $2::uuid)
AND ($3::text IS NULL OR o.status = $3::text)
GROUP BY o.id, b.id, v.id
ORDER BY o.created_at DESC
LIMIT $4 OFFSET $5
`,
[
tenantId,
businessId || null,
status || null,
parseLimit(limit),
parseOffset(offset),
]
);
return result.rows;
}
export async function getOrderDetail({ tenantId, orderId }) {
const orderResult = await query(
`
SELECT
o.id,
o.order_number AS "orderNumber",
o.title,
o.description,
o.status,
o.service_type AS "serviceType",
o.starts_at AS "startsAt",
o.ends_at AS "endsAt",
o.location_name AS "locationName",
o.location_address AS "locationAddress",
o.latitude,
o.longitude,
o.notes,
o.created_at AS "createdAt",
b.id AS "businessId",
b.business_name AS "businessName",
v.id AS "vendorId",
v.company_name AS "vendorName"
FROM orders o
JOIN businesses b ON b.id = o.business_id
LEFT JOIN vendors v ON v.id = o.vendor_id
WHERE o.tenant_id = $1
AND o.id = $2
`,
[tenantId, orderId]
);
if (orderResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Order not found', 404, { tenantId, orderId });
}
const shiftsResult = await query(
`
SELECT
s.id,
s.shift_code AS "shiftCode",
s.title,
s.status,
s.starts_at AS "startsAt",
s.ends_at AS "endsAt",
s.timezone,
s.location_name AS "locationName",
s.location_address AS "locationAddress",
s.required_workers AS "requiredWorkers",
s.assigned_workers AS "assignedWorkers",
cp.id AS "clockPointId",
cp.label AS "clockPointLabel"
FROM shifts s
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE s.tenant_id = $1
AND s.order_id = $2
ORDER BY s.starts_at ASC
`,
[tenantId, orderId]
);
const shiftIds = shiftsResult.rows.map((row) => row.id);
let rolesByShiftId = new Map();
if (shiftIds.length > 0) {
const rolesResult = await query(
`
SELECT
sr.id,
sr.shift_id AS "shiftId",
sr.role_code AS "roleCode",
sr.role_name AS "roleName",
sr.workers_needed AS "workersNeeded",
sr.assigned_count AS "assignedCount",
sr.pay_rate_cents AS "payRateCents",
sr.bill_rate_cents AS "billRateCents"
FROM shift_roles sr
WHERE sr.shift_id = ANY($1::uuid[])
ORDER BY sr.role_name ASC
`,
[shiftIds]
);
rolesByShiftId = rolesResult.rows.reduce((map, row) => {
const list = map.get(row.shiftId) || [];
list.push(row);
map.set(row.shiftId, list);
return map;
}, new Map());
}
return {
...orderResult.rows[0],
shifts: shiftsResult.rows.map((shift) => ({
...shift,
roles: rolesByShiftId.get(shift.id) || [],
})),
};
}
export async function listFavoriteStaff({ tenantId, businessId, limit, offset }) {
const result = await query(
`
SELECT
sf.id AS "favoriteId",
sf.created_at AS "favoritedAt",
s.id AS "staffId",
s.full_name AS "fullName",
s.primary_role AS "primaryRole",
s.average_rating AS "averageRating",
s.rating_count AS "ratingCount",
s.status
FROM staff_favorites sf
JOIN staffs s ON s.id = sf.staff_id
WHERE sf.tenant_id = $1
AND sf.business_id = $2
ORDER BY sf.created_at DESC
LIMIT $3 OFFSET $4
`,
[tenantId, businessId, parseLimit(limit), parseOffset(offset)]
);
return result.rows;
}
export async function getStaffReviewSummary({ tenantId, staffId, limit }) {
const staffResult = await query(
`
SELECT
id AS "staffId",
full_name AS "fullName",
average_rating AS "averageRating",
rating_count AS "ratingCount",
primary_role AS "primaryRole",
status
FROM staffs
WHERE tenant_id = $1
AND id = $2
`,
[tenantId, staffId]
);
if (staffResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Staff not found', 404, { tenantId, staffId });
}
const reviewsResult = await query(
`
SELECT
sr.id AS "reviewId",
sr.rating,
sr.review_text AS "reviewText",
sr.tags,
sr.created_at AS "createdAt",
b.id AS "businessId",
b.business_name AS "businessName",
sr.assignment_id AS "assignmentId"
FROM staff_reviews sr
JOIN businesses b ON b.id = sr.business_id
WHERE sr.tenant_id = $1
AND sr.staff_id = $2
ORDER BY sr.created_at DESC
LIMIT $3
`,
[tenantId, staffId, parseLimit(limit, 10, 50)]
);
return {
...staffResult.rows[0],
reviews: reviewsResult.rows,
};
}
export async function getAssignmentAttendance({ tenantId, assignmentId }) {
const assignmentResult = await query(
`
SELECT
a.id AS "assignmentId",
a.status,
a.shift_id AS "shiftId",
a.staff_id AS "staffId",
s.title AS "shiftTitle",
s.starts_at AS "shiftStartsAt",
s.ends_at AS "shiftEndsAt",
attendance_sessions.id AS "sessionId",
attendance_sessions.status AS "sessionStatus",
attendance_sessions.check_in_at AS "checkInAt",
attendance_sessions.check_out_at AS "checkOutAt",
attendance_sessions.worked_minutes AS "workedMinutes"
FROM assignments a
JOIN shifts s ON s.id = a.shift_id
LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id
WHERE a.id = $1
AND a.tenant_id = $2
`,
[assignmentId, tenantId]
);
if (assignmentResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Assignment not found', 404, { tenantId, assignmentId });
}
const eventsResult = await query(
`
SELECT
id AS "attendanceEventId",
event_type AS "eventType",
source_type AS "sourceType",
source_reference AS "sourceReference",
nfc_tag_uid AS "nfcTagUid",
latitude,
longitude,
distance_to_clock_point_meters AS "distanceToClockPointMeters",
within_geofence AS "withinGeofence",
validation_status AS "validationStatus",
validation_reason AS "validationReason",
captured_at AS "capturedAt"
FROM attendance_events
WHERE assignment_id = $1
ORDER BY captured_at ASC
`,
[assignmentId]
);
return {
...assignmentResult.rows[0],
events: eventsResult.rows,
};
}

View File

@@ -0,0 +1,126 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from '../src/app.js';
process.env.AUTH_BYPASS = 'true';
const tenantId = '11111111-1111-4111-8111-111111111111';
const orderId = '22222222-2222-4222-8222-222222222222';
const businessId = '33333333-3333-4333-8333-333333333333';
const staffId = '44444444-4444-4444-8444-444444444444';
const assignmentId = '55555555-5555-4555-8555-555555555555';
test('GET /healthz returns healthy response', async () => {
const app = createApp();
const res = await request(app).get('/healthz');
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.service, 'krow-query-api');
assert.equal(typeof res.body.requestId, 'string');
assert.equal(typeof res.headers['x-request-id'], 'string');
});
test('GET /readyz reports database not configured when no database env is present', async () => {
delete process.env.DATABASE_URL;
delete process.env.DB_HOST;
delete process.env.DB_NAME;
delete process.env.DB_USER;
delete process.env.DB_PASSWORD;
delete process.env.INSTANCE_CONNECTION_NAME;
const app = createApp();
const res = await request(app).get('/readyz');
assert.equal(res.status, 503);
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
});
test('GET unknown route returns not found envelope', async () => {
const app = createApp();
const res = await request(app).get('/query/unknown');
assert.equal(res.status, 404);
assert.equal(res.body.code, 'NOT_FOUND');
assert.equal(typeof res.body.requestId, 'string');
});
test('GET /query/tenants/:tenantId/orders returns injected query result', async () => {
const app = createApp({
queryService: {
listOrders: async (params) => {
assert.equal(params.tenantId, tenantId);
return [{
id: orderId,
orderNumber: 'ORD-1001',
title: 'Cafe Event Staffing',
status: 'OPEN',
}];
},
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'),
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'),
},
});
const res = await request(app)
.get(`/query/tenants/${tenantId}/orders`)
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items.length, 1);
assert.equal(res.body.items[0].id, orderId);
});
test('GET /query/tenants/:tenantId/assignments/:assignmentId/attendance returns injected attendance', async () => {
const app = createApp({
queryService: {
listOrders: async () => assert.fail('listOrders should not be called'),
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'),
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
getAssignmentAttendance: async (params) => {
assert.equal(params.tenantId, tenantId);
assert.equal(params.assignmentId, assignmentId);
return {
assignmentId,
sessionStatus: 'OPEN',
events: [],
};
},
},
});
const res = await request(app)
.get(`/query/tenants/${tenantId}/assignments/${assignmentId}/attendance`)
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.assignmentId, assignmentId);
assert.equal(res.body.sessionStatus, 'OPEN');
});
test('GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff validates auth and handler wiring', async () => {
const app = createApp({
queryService: {
listOrders: async () => assert.fail('listOrders should not be called'),
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
listFavoriteStaff: async (params) => {
assert.equal(params.tenantId, tenantId);
assert.equal(params.businessId, businessId);
return [{ staffId, fullName: 'Ana Barista' }];
},
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'),
},
});
const res = await request(app)
.get(`/query/tenants/${tenantId}/businesses/${businessId}/favorite-staff`)
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].staffId, staffId);
});

View File

@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from '../src/app.js';
process.env.AUTH_BYPASS = 'true';
function createMobileQueryService() {
return {
getClientDashboard: async () => ({ businessName: 'Google Cafes' }),
getClientSession: async () => ({ business: { businessId: 'b1' } }),
getCoverageStats: async () => ({ totalCoveragePercentage: 100 }),
getCoverageReport: async () => ({ items: [{ shiftId: 'coverage-1' }] }),
getCurrentAttendanceStatus: async () => ({ attendanceStatus: 'NOT_CLOCKED_IN' }),
getCurrentBill: async () => ({ currentBillCents: 1000 }),
getDailyOpsReport: async () => ({ totals: { workedAssignments: 4 } }),
getForecastReport: async () => ({ totals: { projectedCoveragePercentage: 92 } }),
getNoShowReport: async () => ({ totals: { noShows: 1 } }),
getPaymentChart: async () => ([{ amountCents: 100 }]),
getPaymentsSummary: async () => ({ totalEarningsCents: 500 }),
getPersonalInfo: async () => ({ firstName: 'Ana' }),
getPerformanceReport: async () => ({ totals: { averageRating: 4.8 } }),
getProfileSectionsStatus: async () => ({ personalInfoCompleted: true }),
getPrivacySettings: async () => ({ profileVisibility: 'TEAM_ONLY' }),
getReportSummary: async () => ({ reportDate: '2026-03-13', totals: { orders: 3 } }),
getSavings: async () => ({ savingsCents: 200 }),
getSpendReport: async () => ({ totals: { amountCents: 2000 } }),
getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]),
getStaffDashboard: async () => ({ staffName: 'Ana Barista' }),
getStaffProfileCompletion: async () => ({ completed: true }),
getStaffSession: async () => ({ staff: { staffId: 's1' } }),
getStaffShiftDetail: async () => ({ shiftId: 'shift-1' }),
listAssignedShifts: async () => ([{ shiftId: 'assigned-1' }]),
listBusinessAccounts: async () => ([{ accountId: 'acc-1' }]),
listCancelledShifts: async () => ([{ shiftId: 'cancelled-1' }]),
listCertificates: async () => ([{ certificateId: 'cert-1' }]),
listCostCenters: async () => ([{ costCenterId: 'cc-1' }]),
listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]),
listCoreTeam: async () => ([{ staffId: 'core-1' }]),
listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]),
listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]),
listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]),
listGeofenceIncidents: async () => ([{ incidentId: 'incident-1' }]),
listHubManagers: async () => ([{ managerId: 'm1' }]),
listHubs: async () => ([{ hubId: 'hub-1' }]),
listIndustries: async () => (['CATERING']),
listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]),
listOpenShifts: async () => ([{ shiftId: 'open-1' }]),
getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }),
listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]),
listPaymentsHistory: async () => ([{ paymentId: 'pay-1' }]),
listPendingAssignments: async () => ([{ assignmentId: 'asg-1' }]),
listPendingInvoices: async () => ([{ invoiceId: 'pending-1' }]),
listProfileDocuments: async () => ([{ staffDocumentId: 'doc-1' }]),
listRecentReorders: async () => ([{ id: 'order-1' }]),
listBusinessTeamMembers: async () => ([{ userId: 'u-1' }]),
listSkills: async () => (['BARISTA']),
listStaffAvailability: async () => ([{ dayOfWeek: 1 }]),
listStaffBankAccounts: async () => ([{ accountId: 'acc-2' }]),
listStaffBenefits: async () => ([{ benefitId: 'benefit-1' }]),
listTaxForms: async () => ([{ formType: 'W4' }]),
listAttireChecklist: async () => ([{ documentId: 'attire-1' }]),
listTimeCardEntries: async () => ([{ entryId: 'tc-1' }]),
listTodayShifts: async () => ([{ shiftId: 'today-1' }]),
listVendorRoles: async () => ([{ roleId: 'role-1' }]),
listVendors: async () => ([{ vendorId: 'vendor-1' }]),
searchFaqs: async () => ([{ id: 'faq-2', title: 'Payments' }]),
};
}
test('GET /query/client/session returns injected client session', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/session')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.business.businessId, 'b1');
});
test('GET /query/client/coverage validates date query param', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/coverage')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 400);
assert.equal(res.body.code, 'VALIDATION_ERROR');
});
test('GET /query/staff/dashboard returns injected dashboard', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/staff/dashboard')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.staffName, 'Ana Barista');
});
test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/staff/shifts/shift-1')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.shiftId, 'shift-1');
});
test('GET /query/client/reports/summary returns injected report summary', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/reports/summary?date=2026-03-13')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.totals.orders, 3);
});
test('GET /query/client/coverage/core-team returns injected core team list', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/coverage/core-team?date=2026-03-13')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].staffId, 'core-1');
});
test('GET /query/client/coverage/incidents returns injected incidents list', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/coverage/incidents?startDate=2026-03-01&endDate=2026-03-16')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].incidentId, 'incident-1');
});
test('GET /query/staff/profile/tax-forms returns injected tax forms', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/staff/profile/tax-forms')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].formType, 'W4');
});
test('GET /query/staff/faqs/search returns injected faq search results', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/staff/faqs/search?q=payments')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].title, 'Payments');
});

View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV PORT=8080
EXPOSE 8080
CMD ["node", "src/server.js"]

3661
backend/unified-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"name": "@krow/unified-api",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node src/server.js",
"test": "node --test"
},
"dependencies": {
"express": "^4.21.2",
"firebase-admin": "^13.0.2",
"pg": "^8.20.0",
"pino": "^9.6.0",
"pino-http": "^10.3.0",
"zod": "^3.24.2"
},
"devDependencies": {
"supertest": "^7.0.0"
}
}

View File

@@ -0,0 +1,64 @@
import { signInWithPassword, signUpWithPassword } from '../src/services/identity-toolkit.js';
const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com';
const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com';
const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!';
const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!';
async function ensureUser({ email, password, displayName }) {
try {
const signedIn = await signInWithPassword({ email, password });
return {
uid: signedIn.localId,
email,
password,
created: false,
displayName,
};
} catch (error) {
const message = error?.message || '';
if (!message.includes('INVALID_LOGIN_CREDENTIALS') && !message.includes('EMAIL_NOT_FOUND')) {
throw error;
}
}
try {
const signedUp = await signUpWithPassword({ email, password });
return {
uid: signedUp.localId,
email,
password,
created: true,
displayName,
};
} catch (error) {
const message = error?.message || '';
if (message.includes('EMAIL_EXISTS')) {
throw new Error(`Firebase user ${email} exists but password does not match expected demo password.`);
}
throw error;
}
}
async function main() {
const owner = await ensureUser({
email: ownerEmail,
password: ownerPassword,
displayName: 'Legendary Demo Owner V2',
});
const staff = await ensureUser({
email: staffEmail,
password: staffPassword,
displayName: 'Ana Barista V2',
});
// eslint-disable-next-line no-console
console.log(JSON.stringify({ owner, staff }, null, 2));
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { requestContext } from './middleware/request-context.js';
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js';
import { createAuthRouter } from './routes/auth.js';
import { createProxyRouter } from './routes/proxy.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp(options = {}) {
const app = express();
app.use(requestContext);
app.use(
pinoHttp({
logger,
customProps: (req) => ({ requestId: req.requestId }),
})
);
app.use(healthRouter);
app.use('/auth', createAuthRouter({ fetchImpl: options.fetchImpl, authService: options.authService }));
app.use(createProxyRouter(options));
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}

View File

@@ -0,0 +1,26 @@
export class AppError extends Error {
constructor(code, message, status = 400, details = {}) {
super(message);
this.name = 'AppError';
this.code = code;
this.status = status;
this.details = details;
}
}
export function toErrorEnvelope(error, requestId) {
const status = error?.status && Number.isInteger(error.status) ? error.status : 500;
const code = error?.code || 'INTERNAL_ERROR';
const message = error?.message || 'Unexpected error';
const details = error?.details || {};
return {
status,
body: {
code,
message,
details,
requestId,
},
};
}

View File

@@ -0,0 +1,25 @@
import { toErrorEnvelope } from '../lib/errors.js';
export function notFoundHandler(req, res) {
res.status(404).json({
code: 'NOT_FOUND',
message: `Route not found: ${req.method} ${req.path}`,
details: {},
requestId: req.requestId,
});
}
export function errorHandler(error, req, res, _next) {
const envelope = toErrorEnvelope(error, req.requestId);
if (req.log) {
req.log.error(
{
errCode: envelope.body.code,
status: envelope.status,
details: envelope.body.details,
},
envelope.body.message
);
}
res.status(envelope.status).json(envelope.body);
}

View File

@@ -0,0 +1,9 @@
import { randomUUID } from 'node:crypto';
export function requestContext(req, res, next) {
const incoming = req.get('X-Request-Id');
req.requestId = incoming || randomUUID();
res.setHeader('X-Request-Id', req.requestId);
res.locals.startedAt = Date.now();
next();
}

View File

@@ -0,0 +1,170 @@
import express from 'express';
import { AppError } from '../lib/errors.js';
import {
getSessionForActor,
parseClientSignIn,
parseClientSignUp,
parseStaffPhoneStart,
parseStaffPhoneVerify,
signInClient,
signOutActor,
signUpClient,
startStaffPhoneAuth,
verifyStaffPhoneAuth,
} from '../services/auth-service.js';
import { verifyFirebaseToken } from '../services/firebase-auth.js';
const defaultAuthService = {
parseClientSignIn,
parseClientSignUp,
parseStaffPhoneStart,
parseStaffPhoneVerify,
signInClient,
signOutActor,
signUpClient,
startStaffPhoneAuth,
verifyStaffPhoneAuth,
getSessionForActor,
};
function getBearerToken(header) {
if (!header) return null;
const [scheme, token] = header.split(' ');
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null;
return token;
}
async function requireAuth(req, _res, next) {
try {
const token = getBearerToken(req.get('Authorization'));
if (!token) {
throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401);
}
if (process.env.AUTH_BYPASS === 'true') {
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
return next();
}
const decoded = await verifyFirebaseToken(token);
req.actor = {
uid: decoded.uid,
email: decoded.email || null,
role: decoded.role || null,
};
return next();
} catch (error) {
if (error instanceof AppError) return next(error);
return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401));
}
}
export function createAuthRouter(options = {}) {
const router = express.Router();
const fetchImpl = options.fetchImpl || fetch;
const authService = options.authService || defaultAuthService;
router.use(express.json({ limit: '1mb' }));
router.post('/client/sign-in', async (req, res, next) => {
try {
const payload = authService.parseClientSignIn(req.body);
const session = await authService.signInClient(payload, { fetchImpl });
return res.status(200).json({
...session,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/client/sign-up', async (req, res, next) => {
try {
const payload = authService.parseClientSignUp(req.body);
const session = await authService.signUpClient(payload, { fetchImpl });
return res.status(201).json({
...session,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/staff/phone/start', async (req, res, next) => {
try {
const payload = authService.parseStaffPhoneStart(req.body);
const result = await authService.startStaffPhoneAuth(payload, { fetchImpl });
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/staff/phone/verify', async (req, res, next) => {
try {
const payload = authService.parseStaffPhoneVerify(req.body);
const session = await authService.verifyStaffPhoneAuth(payload, { fetchImpl });
return res.status(200).json({
...session,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.get('/session', requireAuth, async (req, res, next) => {
try {
const session = await authService.getSessionForActor(req.actor);
return res.status(200).json({
...session,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/sign-out', requireAuth, async (req, res, next) => {
try {
const result = await authService.signOutActor(req.actor);
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/client/sign-out', requireAuth, async (req, res, next) => {
try {
const result = await authService.signOutActor(req.actor);
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
router.post('/staff/sign-out', requireAuth, async (req, res, next) => {
try {
const result = await authService.signOutActor(req.actor);
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
});
return router;
}

View File

@@ -0,0 +1,45 @@
import { Router } from 'express';
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
export const healthRouter = Router();
function healthHandler(req, res) {
res.status(200).json({
ok: true,
service: 'krow-api-v2',
version: process.env.SERVICE_VERSION || 'dev',
requestId: req.requestId,
});
}
healthRouter.get('/health', healthHandler);
healthRouter.get('/healthz', healthHandler);
healthRouter.get('/readyz', async (req, res) => {
if (!isDatabaseConfigured()) {
return res.status(503).json({
ok: false,
service: 'krow-api-v2',
status: 'DATABASE_NOT_CONFIGURED',
requestId: req.requestId,
});
}
try {
const ok = await checkDatabaseHealth();
return res.status(ok ? 200 : 503).json({
ok,
service: 'krow-api-v2',
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
requestId: req.requestId,
});
} catch (error) {
return res.status(503).json({
ok: false,
service: 'krow-api-v2',
status: 'DATABASE_UNAVAILABLE',
details: { message: error.message },
requestId: req.requestId,
});
}
});

View File

@@ -0,0 +1,156 @@
import { Router } from 'express';
import { AppError } from '../lib/errors.js';
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'content-length',
'host',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
const DIRECT_CORE_ALIASES = [
{ methods: new Set(['POST']), pattern: /^\/upload-file$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/create-signed-url$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` },
{
methods: new Set(['POST']),
pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/,
targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`,
},
{
methods: new Set(['POST']),
pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/,
targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`,
},
{
methods: new Set(['POST']),
pattern: /^\/staff\/profile\/certificates$/,
targetPath: () => '/core/staff/certificates/upload',
},
{
methods: new Set(['DELETE']),
pattern: /^\/staff\/profile\/certificates\/([^/]+)$/,
targetPath: (_pathname, match) => `/core/staff/certificates/${match[1]}`,
},
{ methods: new Set(['POST']), pattern: /^\/staff\/documents\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/staff\/attire\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/staff\/certificates\/upload$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['DELETE']), pattern: /^\/staff\/certificates\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/verifications$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['GET']), pattern: /^\/verifications\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/review$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/retry$/, targetPath: (pathname) => `/core${pathname}` },
];
function resolveTarget(pathname, method) {
const upperMethod = method.toUpperCase();
if (pathname.startsWith('/core')) {
return {
baseUrl: process.env.CORE_API_BASE_URL,
upstreamPath: pathname,
};
}
if (pathname.startsWith('/commands')) {
return {
baseUrl: process.env.COMMAND_API_BASE_URL,
upstreamPath: pathname,
};
}
if (pathname.startsWith('/query')) {
return {
baseUrl: process.env.QUERY_API_BASE_URL,
upstreamPath: pathname,
};
}
for (const alias of DIRECT_CORE_ALIASES) {
if (!alias.methods.has(upperMethod)) continue;
const match = pathname.match(alias.pattern);
if (!match) continue;
return {
baseUrl: process.env.CORE_API_BASE_URL,
upstreamPath: alias.targetPath(pathname, match),
};
}
if ((upperMethod === 'GET' || upperMethod === 'HEAD') && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) {
return {
baseUrl: process.env.QUERY_API_BASE_URL,
upstreamPath: `/query${pathname}`,
};
}
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(upperMethod) && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) {
return {
baseUrl: process.env.COMMAND_API_BASE_URL,
upstreamPath: `/commands${pathname}`,
};
}
return null;
}
function copyHeaders(source, target) {
for (const [key, value] of source.entries()) {
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
target.setHeader(key, value);
}
}
async function forwardRequest(req, res, next, fetchImpl) {
try {
const requestUrl = new URL(req.originalUrl, 'http://localhost');
const target = resolveTarget(requestUrl.pathname, req.method);
if (!target?.baseUrl) {
throw new AppError('NOT_FOUND', `No upstream configured for ${requestUrl.pathname}`, 404);
}
const url = new URL(`${target.upstreamPath}${requestUrl.search}`, target.baseUrl);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value == null || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
if (Array.isArray(value)) {
for (const item of value) headers.append(key, item);
} else {
headers.set(key, value);
}
}
headers.set('x-request-id', req.requestId);
const upstream = await fetchImpl(url, {
method: req.method,
headers,
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req,
duplex: req.method === 'GET' || req.method === 'HEAD' ? undefined : 'half',
});
copyHeaders(upstream.headers, res);
res.status(upstream.status);
const buffer = Buffer.from(await upstream.arrayBuffer());
return res.send(buffer);
} catch (error) {
return next(error);
}
}
export function createProxyRouter(options = {}) {
const router = Router();
const fetchImpl = options.fetchImpl || fetch;
router.use((req, res, next) => forwardRequest(req, res, next, fetchImpl));
return router;
}

View File

@@ -0,0 +1,9 @@
import { createApp } from './app.js';
const port = Number(process.env.PORT || 8080);
const app = createApp();
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`krow-api-v2 listening on port ${port}`);
});

View File

@@ -0,0 +1,304 @@
import { z } from 'zod';
import { AppError } from '../lib/errors.js';
import { withTransaction } from './db.js';
import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js';
import {
deleteAccount,
sendVerificationCode,
signInWithPassword,
signInWithPhoneNumber,
signUpWithPassword,
} from './identity-toolkit.js';
import { loadActorContext } from './user-context.js';
const clientSignInSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const clientSignUpSchema = z.object({
companyName: z.string().min(2).max(120),
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().min(2).max(120).optional(),
});
const staffPhoneStartSchema = z.object({
phoneNumber: z.string().min(6).max(40),
recaptchaToken: z.string().min(1).optional(),
iosReceipt: z.string().min(1).optional(),
iosSecret: z.string().min(1).optional(),
captchaResponse: z.string().min(1).optional(),
playIntegrityToken: z.string().min(1).optional(),
safetyNetToken: z.string().min(1).optional(),
});
const staffPhoneVerifySchema = z.object({
mode: z.enum(['sign-in', 'sign-up']).optional(),
idToken: z.string().min(1).optional(),
sessionInfo: z.string().min(1).optional(),
code: z.string().min(1).optional(),
}).superRefine((value, ctx) => {
if (value.idToken) return;
if (value.sessionInfo && value.code) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide idToken or sessionInfo and code',
});
});
function slugify(input) {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50);
}
function buildAuthEnvelope(authPayload, context) {
return {
sessionToken: authPayload.idToken,
refreshToken: authPayload.refreshToken,
expiresInSeconds: Number.parseInt(`${authPayload.expiresIn || 3600}`, 10),
user: {
id: context.user?.userId || authPayload.localId,
email: context.user?.email || null,
displayName: context.user?.displayName || null,
phone: context.user?.phone || null,
},
tenant: context.tenant,
business: context.business,
vendor: context.vendor,
staff: context.staff,
requiresProfileSetup: !context.staff,
};
}
async function upsertUserFromDecodedToken(decoded, fallbackProfile = {}) {
await withTransaction(async (client) => {
await client.query(
`
INSERT INTO users (id, email, display_name, phone, status, metadata)
VALUES ($1, $2, $3, $4, 'ACTIVE', COALESCE($5::jsonb, '{}'::jsonb))
ON CONFLICT (id) DO UPDATE
SET email = COALESCE(EXCLUDED.email, users.email),
display_name = COALESCE(EXCLUDED.display_name, users.display_name),
phone = COALESCE(EXCLUDED.phone, users.phone),
metadata = COALESCE(users.metadata, '{}'::jsonb) || COALESCE(EXCLUDED.metadata, '{}'::jsonb),
updated_at = NOW()
`,
[
decoded.uid,
decoded.email || fallbackProfile.email || null,
decoded.name || fallbackProfile.displayName || fallbackProfile.email || decoded.phone_number || null,
decoded.phone_number || fallbackProfile.phoneNumber || null,
JSON.stringify({
provider: decoded.firebase?.sign_in_provider || fallbackProfile.provider || null,
}),
]
);
});
}
async function hydrateAuthContext(authPayload, fallbackProfile = {}) {
const decoded = await verifyFirebaseToken(authPayload.idToken);
await upsertUserFromDecodedToken(decoded, fallbackProfile);
const context = await loadActorContext(decoded.uid);
return buildAuthEnvelope(authPayload, context);
}
export function parseClientSignIn(body) {
const parsed = clientSignInSchema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid client sign-in payload', 400, {
issues: parsed.error.issues,
});
}
return parsed.data;
}
export function parseClientSignUp(body) {
const parsed = clientSignUpSchema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid client sign-up payload', 400, {
issues: parsed.error.issues,
});
}
return parsed.data;
}
export function parseStaffPhoneStart(body) {
const parsed = staffPhoneStartSchema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid staff phone start payload', 400, {
issues: parsed.error.issues,
});
}
return parsed.data;
}
export function parseStaffPhoneVerify(body) {
const parsed = staffPhoneVerifySchema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid staff phone verify payload', 400, {
issues: parsed.error.issues,
});
}
return parsed.data;
}
export async function getSessionForActor(actor) {
return loadActorContext(actor.uid);
}
export async function signInClient(payload, { fetchImpl = fetch } = {}) {
const authPayload = await signInWithPassword(payload, fetchImpl);
const decoded = await verifyFirebaseToken(authPayload.idToken);
await upsertUserFromDecodedToken(decoded, payload);
const context = await loadActorContext(decoded.uid);
if (!context.user || !context.business) {
throw new AppError('FORBIDDEN', 'Authenticated user does not have a client business membership', 403, {
uid: decoded.uid,
email: decoded.email || null,
});
}
return buildAuthEnvelope(authPayload, context);
}
export async function signUpClient(payload, { fetchImpl = fetch } = {}) {
const authPayload = await signUpWithPassword(payload, fetchImpl);
try {
const decoded = await verifyFirebaseToken(authPayload.idToken);
const defaultDisplayName = payload.displayName || payload.companyName;
const tenantSlug = slugify(payload.companyName);
const businessSlug = tenantSlug;
await withTransaction(async (client) => {
await client.query(
`
INSERT INTO users (id, email, display_name, status, metadata)
VALUES ($1, $2, $3, 'ACTIVE', '{}'::jsonb)
ON CONFLICT (id) DO UPDATE
SET email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
updated_at = NOW()
`,
[decoded.uid, payload.email, defaultDisplayName]
);
const tenantResult = await client.query(
`
INSERT INTO tenants (slug, name, status, metadata)
VALUES ($1, $2, 'ACTIVE', '{"source":"unified-api-sign-up"}'::jsonb)
RETURNING id, slug, name
`,
[tenantSlug, payload.companyName]
);
const tenant = tenantResult.rows[0];
const businessResult = await client.query(
`
INSERT INTO businesses (
tenant_id, slug, business_name, status, contact_name, contact_email, metadata
)
VALUES ($1, $2, $3, 'ACTIVE', $4, $5, '{"source":"unified-api-sign-up"}'::jsonb)
RETURNING id, slug, business_name
`,
[tenant.id, businessSlug, payload.companyName, defaultDisplayName, payload.email]
);
const business = businessResult.rows[0];
await client.query(
`
INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata)
VALUES ($1, $2, 'ACTIVE', 'admin', '{"source":"sign-up"}'::jsonb)
`,
[tenant.id, decoded.uid]
);
await client.query(
`
INSERT INTO business_memberships (tenant_id, business_id, user_id, membership_status, business_role, metadata)
VALUES ($1, $2, $3, 'ACTIVE', 'owner', '{"source":"sign-up"}'::jsonb)
`,
[tenant.id, business.id, decoded.uid]
);
});
const context = await loadActorContext(decoded.uid);
return buildAuthEnvelope(authPayload, context);
} catch (error) {
await deleteAccount({ idToken: authPayload.idToken }, fetchImpl).catch(() => null);
throw error;
}
}
function shouldUseClientSdkStaffFlow(payload) {
return !payload.recaptchaToken && !payload.iosReceipt && !payload.captchaResponse && !payload.playIntegrityToken && !payload.safetyNetToken;
}
export async function startStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) {
if (shouldUseClientSdkStaffFlow(payload)) {
return {
mode: 'CLIENT_FIREBASE_SDK',
provider: 'firebase-phone-auth',
phoneNumber: payload.phoneNumber,
nextStep: 'Complete phone verification in the mobile client, then call /auth/staff/phone/verify with the Firebase idToken.',
};
}
const authPayload = await sendVerificationCode(
{
phoneNumber: payload.phoneNumber,
recaptchaToken: payload.recaptchaToken,
iosReceipt: payload.iosReceipt,
iosSecret: payload.iosSecret,
captchaResponse: payload.captchaResponse,
playIntegrityToken: payload.playIntegrityToken,
safetyNetToken: payload.safetyNetToken,
},
fetchImpl
);
return {
mode: 'IDENTITY_TOOLKIT_SMS',
phoneNumber: payload.phoneNumber,
sessionInfo: authPayload.sessionInfo,
};
}
export async function verifyStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) {
if (payload.idToken) {
return hydrateAuthContext(
{
idToken: payload.idToken,
refreshToken: null,
expiresIn: 3600,
},
{
provider: 'firebase-phone-auth',
}
);
}
const authPayload = await signInWithPhoneNumber(
{
sessionInfo: payload.sessionInfo,
code: payload.code,
operation: payload.mode === 'sign-up' ? 'SIGN_UP_OR_IN' : undefined,
},
fetchImpl
);
return hydrateAuthContext(authPayload, {
provider: 'firebase-phone-auth',
});
}
export async function signOutActor(actor) {
await revokeUserSessions(actor.uid);
return { signedOut: true };
}

View File

@@ -0,0 +1,87 @@
import { Pool } from 'pg';
let pool;
function parseIntOrDefault(value, fallback) {
const parsed = Number.parseInt(`${value || fallback}`, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function resolveDatabasePoolConfig() {
if (process.env.DATABASE_URL) {
return {
connectionString: process.env.DATABASE_URL,
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
const user = process.env.DB_USER;
const password = process.env.DB_PASSWORD;
const database = process.env.DB_NAME;
const host = process.env.DB_HOST || (
process.env.INSTANCE_CONNECTION_NAME
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
: ''
);
if (!user || password == null || !database || !host) {
return null;
}
return {
host,
port: parseIntOrDefault(process.env.DB_PORT, 5432),
user,
password,
database,
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
export function isDatabaseConfigured() {
return Boolean(resolveDatabasePoolConfig());
}
function getPool() {
if (!pool) {
const resolved = resolveDatabasePoolConfig();
if (!resolved) {
throw new Error('Database connection settings are required');
}
pool = new Pool(resolved);
}
return pool;
}
export async function query(text, params = []) {
return getPool().query(text, params);
}
export async function withTransaction(work) {
const client = await getPool().connect();
try {
await client.query('BEGIN');
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
export async function checkDatabaseHealth() {
const result = await query('SELECT 1 AS ok');
return result.rows[0]?.ok === 1;
}
export async function closePool() {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -0,0 +1,23 @@
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
function ensureAdminApp() {
if (getApps().length === 0) {
initializeApp({ credential: applicationDefault() });
}
}
export async function verifyFirebaseToken(token, { checkRevoked = false } = {}) {
ensureAdminApp();
return getAuth().verifyIdToken(token, checkRevoked);
}
export async function revokeUserSessions(uid) {
ensureAdminApp();
await getAuth().revokeRefreshTokens(uid);
}
export async function createCustomToken(uid) {
ensureAdminApp();
return getAuth().createCustomToken(uid);
}

View File

@@ -0,0 +1,92 @@
import { AppError } from '../lib/errors.js';
const IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1';
function getApiKey() {
const apiKey = process.env.FIREBASE_WEB_API_KEY;
if (!apiKey) {
throw new AppError('CONFIGURATION_ERROR', 'FIREBASE_WEB_API_KEY is required', 500);
}
return apiKey;
}
async function callIdentityToolkit(path, payload, fetchImpl = fetch) {
const response = await fetchImpl(`${IDENTITY_TOOLKIT_BASE_URL}/${path}?key=${getApiKey()}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(payload),
});
const json = await response.json().catch(() => ({}));
if (!response.ok) {
throw new AppError(
'AUTH_PROVIDER_ERROR',
json?.error?.message || `Identity Toolkit request failed: ${path}`,
response.status,
{ provider: 'firebase-identity-toolkit', path }
);
}
return json;
}
export async function signInWithPassword({ email, password }, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:signInWithPassword',
{
email,
password,
returnSecureToken: true,
},
fetchImpl
);
}
export async function signUpWithPassword({ email, password }, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:signUp',
{
email,
password,
returnSecureToken: true,
},
fetchImpl
);
}
export async function sendVerificationCode(payload, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:sendVerificationCode',
payload,
fetchImpl
);
}
export async function signInWithPhoneNumber(payload, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:signInWithPhoneNumber',
payload,
fetchImpl
);
}
export async function signInWithCustomToken(payload, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:signInWithCustomToken',
{
token: payload.token,
returnSecureToken: true,
},
fetchImpl
);
}
export async function deleteAccount({ idToken }, fetchImpl = fetch) {
return callIdentityToolkit(
'accounts:delete',
{ idToken },
fetchImpl
);
}

Some files were not shown because too many files have changed in this diff Show More