diff --git a/Dockerfile b/Dockerfile index a3482c5..4751a48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,12 +7,14 @@ RUN npm ci FROM deps AS builder +COPY prisma ./prisma +RUN npx prisma generate + COPY tsconfig.json ./ COPY src ./src RUN npm run build FROM deps AS runtime-deps -RUN npm prune --omit=dev FROM node:22-alpine AS runtime @@ -23,9 +25,12 @@ WORKDIR /app COPY package.json ./ COPY --from=runtime-deps /app/node_modules ./node_modules +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=builder /app/dist ./dist +COPY prisma ./prisma COPY scripts ./scripts EXPOSE 8000 -CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && node dist/index.js"] +CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && set +e && npx prisma migrate resolve --applied 0_init 2>/dev/null; set -e && npx prisma migrate deploy && node dist/index.js"] diff --git a/package-lock.json b/package-lock.json index 5e574a9..dbc3ee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@apollo/server": "^4.11.3", + "@prisma/client": "^6.5.0", "@sentry/node": "^9.5.0", "cors": "^2.8.5", "express": "^4.21.2", @@ -20,6 +21,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.13.0", + "prisma": "^6.5.0", "tsx": "^4.19.0", "typescript": "^5.7.0" } @@ -1330,6 +1332,91 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, "node_modules/@prisma/instrumentation": { "version": "6.11.1", "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.11.1.tgz", @@ -1503,6 +1590,13 @@ "@opentelemetry/semantic-conventions": "^1.34.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1809,6 +1903,35 @@ "node": ">= 0.8" } }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -1856,6 +1979,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -1874,6 +2023,23 @@ "node": ">= 0.8" } }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1936,6 +2102,16 @@ "ms": "2.0.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1953,6 +2129,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1971,6 +2154,13 @@ "node": ">= 0.8" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -1981,6 +2171,19 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2001,6 +2204,27 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -2158,6 +2382,36 @@ "url": "https://opencollective.com/express" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -2305,6 +2559,24 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2499,6 +2771,16 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/jose": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz", @@ -2673,6 +2955,38 @@ } } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2694,6 +3008,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2727,6 +3048,20 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -2758,6 +3093,18 @@ "node": ">=4" } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -2806,6 +3153,32 @@ "node": ">=0.10.0" } }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2819,6 +3192,23 @@ "node": ">= 0.10" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -2858,6 +3248,31 @@ "node": ">= 0.8" } }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/require-in-the-middle": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", @@ -3159,6 +3574,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -3245,7 +3670,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index b12c241..5a02485 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,12 @@ "type": "module", "scripts": { "dev": "tsx watch src/index.ts", - "build": "tsc", - "start": "node dist/index.js" + "build": "prisma generate && tsc", + "start": "prisma migrate deploy && node dist/index.js" }, "dependencies": { "@apollo/server": "^4.11.3", + "@prisma/client": "^6.5.0", "cors": "^2.8.5", "express": "^4.21.2", "graphql": "^16.10.0", @@ -21,6 +22,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.13.0", + "prisma": "^6.5.0", "tsx": "^4.19.0", "typescript": "^5.7.0" } diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..017a64b --- /dev/null +++ b/prisma/migrations/0_init/migration.sql @@ -0,0 +1,166 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateTable +CREATE TABLE "orders_tariff_reference" ( + "id" SERIAL NOT NULL, + "uuid" TEXT NOT NULL, + "team_uuid" VARCHAR(100) NOT NULL, + "name" VARCHAR(255) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'active', + "operation_code" VARCHAR(50), + "incoterms_code" VARCHAR(20), + "transport_type_code" VARCHAR(50), + "tare_type_code" VARCHAR(50), + "source_country_code" VARCHAR(10), + "destination_country_code" VARCHAR(10), + "source_hub_uuid" VARCHAR(100), + "destination_hub_uuid" VARCHAR(100), + "min_weight_kg" DECIMAL(12,2), + "max_weight_kg" DECIMAL(12,2), + "min_volume_cbm" DECIMAL(12,3), + "max_volume_cbm" DECIMAL(12,3), + "min_distance_km" INTEGER, + "max_distance_km" INTEGER, + "amount_usd" DECIMAL(12,2) NOT NULL, + "min_price_usd" DECIMAL(12,2), + "eta_days" INTEGER, + "priority" INTEGER NOT NULL DEFAULT 100, + "currency" VARCHAR(10) NOT NULL DEFAULT 'USD', + "dmn_expression" TEXT, + "notes" TEXT, + "created_by_user_id" VARCHAR(255), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "orders_tariff_reference_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "orders_quotation" ( + "id" SERIAL NOT NULL, + "uuid" TEXT NOT NULL, + "team_uuid" VARCHAR(100) NOT NULL, + "created_by_user_id" VARCHAR(255), + "title" VARCHAR(255) NOT NULL DEFAULT 'Quotation', + "status" VARCHAR(30) NOT NULL DEFAULT 'draft', + "operation_code" VARCHAR(50), + "incoterms_code" VARCHAR(20), + "transport_type_code" VARCHAR(50), + "tare_type_code" VARCHAR(50), + "source_country_code" VARCHAR(10), + "source_hub_uuid" VARCHAR(100), + "source_location_uuid" VARCHAR(100), + "source_location_name" VARCHAR(255), + "source_latitude" DOUBLE PRECISION, + "source_longitude" DOUBLE PRECISION, + "destination_country_code" VARCHAR(10), + "destination_hub_uuid" VARCHAR(100), + "destination_location_uuid" VARCHAR(100), + "destination_location_name" VARCHAR(255), + "destination_latitude" DOUBLE PRECISION, + "destination_longitude" DOUBLE PRECISION, + "chargeable_weight_kg" DECIMAL(12,2), + "gross_weight_kg" DECIMAL(12,2), + "volume_cbm" DECIMAL(12,3), + "units_count" INTEGER, + "route_distance_km" INTEGER, + "selected_tariff_id" INTEGER, + "tariff_match_summary" TEXT, + "tariff_snapshot" TEXT, + "total_amount" DECIMAL(12,2) NOT NULL DEFAULT 0, + "currency" VARCHAR(10) NOT NULL DEFAULT 'USD', + "eta_days" INTEGER, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "orders_quotation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "orders_quotation_change" ( + "id" SERIAL NOT NULL, + "uuid" TEXT NOT NULL, + "quotation_id" INTEGER NOT NULL, + "actor_user_id" VARCHAR(255), + "actor_label" VARCHAR(255), + "source" VARCHAR(50) NOT NULL, + "summary" TEXT, + "payload_json" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "orders_quotation_change_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "orders_order" ( + "id" SERIAL NOT NULL, + "uuid" TEXT NOT NULL, + "team_uuid" VARCHAR(100) NOT NULL, + "quotation_id" INTEGER, + "created_by_user_id" VARCHAR(255), + "name" VARCHAR(255) NOT NULL, + "status" VARCHAR(30) NOT NULL DEFAULT 'draft', + "total_amount" DECIMAL(12,2) NOT NULL DEFAULT 0, + "currency" VARCHAR(10) NOT NULL DEFAULT 'USD', + "source_location_uuid" VARCHAR(100), + "source_location_name" VARCHAR(255), + "source_country_code" VARCHAR(10), + "source_latitude" DOUBLE PRECISION, + "source_longitude" DOUBLE PRECISION, + "destination_location_uuid" VARCHAR(100), + "destination_location_name" VARCHAR(255), + "destination_country_code" VARCHAR(10), + "destination_latitude" DOUBLE PRECISION, + "destination_longitude" DOUBLE PRECISION, + "eta_days" INTEGER, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "orders_order_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_tariff_reference_uuid_key" ON "orders_tariff_reference"("uuid"); + +-- CreateIndex +CREATE INDEX "orders_tariff_reference_team_uuid_status_priority_idx" ON "orders_tariff_reference"("team_uuid", "status", "priority"); + +-- CreateIndex +CREATE INDEX "orders_tariff_reference_team_uuid_source_country_code_desti_idx" ON "orders_tariff_reference"("team_uuid", "source_country_code", "destination_country_code"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_quotation_uuid_key" ON "orders_quotation"("uuid"); + +-- CreateIndex +CREATE INDEX "orders_quotation_team_uuid_status_created_at_idx" ON "orders_quotation"("team_uuid", "status", "created_at"); + +-- CreateIndex +CREATE INDEX "orders_quotation_team_uuid_source_country_code_destination__idx" ON "orders_quotation"("team_uuid", "source_country_code", "destination_country_code"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_quotation_change_uuid_key" ON "orders_quotation_change"("uuid"); + +-- CreateIndex +CREATE INDEX "orders_quotation_change_quotation_id_created_at_idx" ON "orders_quotation_change"("quotation_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_order_uuid_key" ON "orders_order"("uuid"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_order_quotation_id_key" ON "orders_order"("quotation_id"); + +-- CreateIndex +CREATE INDEX "orders_order_team_uuid_created_at_idx" ON "orders_order"("team_uuid", "created_at"); + +-- AddForeignKey +ALTER TABLE "orders_quotation" ADD CONSTRAINT "orders_quotation_selected_tariff_id_fkey" FOREIGN KEY ("selected_tariff_id") REFERENCES "orders_tariff_reference"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders_quotation_change" ADD CONSTRAINT "orders_quotation_change_quotation_id_fkey" FOREIGN KEY ("quotation_id") REFERENCES "orders_quotation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders_order" ADD CONSTRAINT "orders_order_quotation_id_fkey" FOREIGN KEY ("quotation_id") REFERENCES "orders_quotation"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..d4759c3 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,139 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("ORDERS_DATABASE_URL") +} + +model TariffReference { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + teamUuid String @map("team_uuid") @db.VarChar(100) + name String @db.VarChar(255) + status String @default("active") @db.VarChar(20) + operationCode String? @map("operation_code") @db.VarChar(50) + incotermsCode String? @map("incoterms_code") @db.VarChar(20) + transportTypeCode String? @map("transport_type_code") @db.VarChar(50) + tareTypeCode String? @map("tare_type_code") @db.VarChar(50) + sourceCountryCode String? @map("source_country_code") @db.VarChar(10) + destinationCountryCode String? @map("destination_country_code") @db.VarChar(10) + sourceHubUuid String? @map("source_hub_uuid") @db.VarChar(100) + destinationHubUuid String? @map("destination_hub_uuid") @db.VarChar(100) + minWeightKg Decimal? @map("min_weight_kg") @db.Decimal(12, 2) + maxWeightKg Decimal? @map("max_weight_kg") @db.Decimal(12, 2) + minVolumeCbm Decimal? @map("min_volume_cbm") @db.Decimal(12, 3) + maxVolumeCbm Decimal? @map("max_volume_cbm") @db.Decimal(12, 3) + minDistanceKm Int? @map("min_distance_km") + maxDistanceKm Int? @map("max_distance_km") + amountUsd Decimal @map("amount_usd") @db.Decimal(12, 2) + minPriceUsd Decimal? @map("min_price_usd") @db.Decimal(12, 2) + etaDays Int? @map("eta_days") + priority Int @default(100) + currency String @default("USD") @db.VarChar(10) + dmnExpression String? @map("dmn_expression") + notes String? + createdByUserId String? @map("created_by_user_id") @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + quotations Quotation[] @relation("QuotationSelectedTariff") + + @@index([teamUuid, status, priority]) + @@index([teamUuid, sourceCountryCode, destinationCountryCode]) + @@map("orders_tariff_reference") +} + +model Quotation { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + teamUuid String @map("team_uuid") @db.VarChar(100) + createdByUserId String? @map("created_by_user_id") @db.VarChar(255) + title String @default("Quotation") @db.VarChar(255) + status String @default("draft") @db.VarChar(30) + operationCode String? @map("operation_code") @db.VarChar(50) + incotermsCode String? @map("incoterms_code") @db.VarChar(20) + transportTypeCode String? @map("transport_type_code") @db.VarChar(50) + tareTypeCode String? @map("tare_type_code") @db.VarChar(50) + sourceCountryCode String? @map("source_country_code") @db.VarChar(10) + sourceHubUuid String? @map("source_hub_uuid") @db.VarChar(100) + sourceLocationUuid String? @map("source_location_uuid") @db.VarChar(100) + sourceLocationName String? @map("source_location_name") @db.VarChar(255) + sourceLatitude Float? @map("source_latitude") + sourceLongitude Float? @map("source_longitude") + destinationCountryCode String? @map("destination_country_code") @db.VarChar(10) + destinationHubUuid String? @map("destination_hub_uuid") @db.VarChar(100) + destinationLocationUuid String? @map("destination_location_uuid") @db.VarChar(100) + destinationLocationName String? @map("destination_location_name") @db.VarChar(255) + destinationLatitude Float? @map("destination_latitude") + destinationLongitude Float? @map("destination_longitude") + chargeableWeightKg Decimal? @map("chargeable_weight_kg") @db.Decimal(12, 2) + grossWeightKg Decimal? @map("gross_weight_kg") @db.Decimal(12, 2) + volumeCbm Decimal? @map("volume_cbm") @db.Decimal(12, 3) + unitsCount Int? @map("units_count") + routeDistanceKm Int? @map("route_distance_km") + selectedTariffId Int? @map("selected_tariff_id") + selectedTariff TariffReference? @relation("QuotationSelectedTariff", fields: [selectedTariffId], references: [id], onDelete: SetNull) + tariffMatchSummary String? @map("tariff_match_summary") + tariffSnapshot String? @map("tariff_snapshot") + totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(12, 2) + currency String @default("USD") @db.VarChar(10) + etaDays Int? @map("eta_days") + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + orders Order[] + changes QuotationChange[] + + @@index([teamUuid, status, createdAt]) + @@index([teamUuid, sourceCountryCode, destinationCountryCode]) + @@map("orders_quotation") +} + +model QuotationChange { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + quotationId Int @map("quotation_id") + quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade) + actorUserId String? @map("actor_user_id") @db.VarChar(255) + actorLabel String? @map("actor_label") @db.VarChar(255) + source String @db.VarChar(50) + summary String? + payloadJson String? @map("payload_json") + createdAt DateTime @default(now()) @map("created_at") + + @@index([quotationId, createdAt]) + @@map("orders_quotation_change") +} + +model Order { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + teamUuid String @map("team_uuid") @db.VarChar(100) + quotationId Int? @unique @map("quotation_id") + quotation Quotation? @relation(fields: [quotationId], references: [id], onDelete: SetNull) + createdByUserId String? @map("created_by_user_id") @db.VarChar(255) + name String @db.VarChar(255) + status String @default("draft") @db.VarChar(30) + totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(12, 2) + currency String @default("USD") @db.VarChar(10) + sourceLocationUuid String? @map("source_location_uuid") @db.VarChar(100) + sourceLocationName String? @map("source_location_name") @db.VarChar(255) + sourceCountryCode String? @map("source_country_code") @db.VarChar(10) + sourceLatitude Float? @map("source_latitude") + sourceLongitude Float? @map("source_longitude") + destinationLocationUuid String? @map("destination_location_uuid") @db.VarChar(100) + destinationLocationName String? @map("destination_location_name") @db.VarChar(255) + destinationCountryCode String? @map("destination_country_code") @db.VarChar(10) + destinationLatitude Float? @map("destination_latitude") + destinationLongitude Float? @map("destination_longitude") + etaDays Int? @map("eta_days") + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([teamUuid, createdAt]) + @@map("orders_order") +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..6260dd0 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client' + +export const prisma = new PrismaClient() diff --git a/src/schemas/team.ts b/src/schemas/team.ts index dd7180a..88fefb5 100644 --- a/src/schemas/team.ts +++ b/src/schemas/team.ts @@ -1,90 +1,31 @@ import { GraphQLError } from 'graphql' +import { + Prisma, + type Order as PrismaOrder, + type Quotation as PrismaQuotation, + type QuotationChange as PrismaQuotationChange, + type TariffReference as PrismaTariffReference, +} from '@prisma/client' import { requireScopes, type AuthContext } from '../auth.js' +import { prisma } from '../db.js' const ODOO_INTERNAL_URL = process.env.ODOO_INTERNAL_URL || 'odoo:8069' -export const teamTypeDefs = `#graphql - type Company { - uuid: String - name: String - taxId: String - country: String - countryCode: String - active: Boolean - } +const quotationInclude = { + selectedTariff: true, + changes: { + orderBy: { + createdAt: 'desc' as const, + }, + }, +} satisfies Prisma.QuotationInclude - type Trip { - uuid: String - name: String - sequence: Int - company: Company - plannedLoadingDate: String - actualLoadingDate: String - realLoadingDate: String - plannedUnloadingDate: String - actualUnloadingDate: String - plannedWeight: Float - weightAtLoading: Float - weightAtUnloading: Float - } +const orderInclude = { + quotation: true, +} satisfies Prisma.OrderInclude - type Stage { - uuid: String - name: String - sequence: Int - stageType: String - transportType: String - sourceLocationName: String - sourceLatitude: Float - sourceLongitude: Float - destinationLocationName: String - destinationLatitude: Float - destinationLongitude: Float - locationName: String - locationLatitude: Float - locationLongitude: Float - selectedCompany: Company - trips: [Trip] - } - - type OrderLine { - uuid: String - productUuid: String - productName: String - quantity: Float - unit: String - priceUnit: Float - subtotal: Float - currency: String - notes: String - } - - type Order { - uuid: String - name: String - teamUuid: String - userId: String - status: String - totalAmount: Float - currency: String - sourceLocationUuid: String - sourceLocationName: String - sourceLatitude: Float - sourceLongitude: Float - destinationLocationUuid: String - destinationLocationName: String - createdAt: String - updatedAt: String - notes: String - orderLines: [OrderLine] - stages: [Stage] - } - - type Query { - getTeamOrders: [Order] - getOrder(orderUuid: String!): Order - } -` +type QuotationWithRelations = Prisma.QuotationGetPayload<{ include: typeof quotationInclude }> +type OrderWithRelations = Prisma.OrderGetPayload<{ include: typeof orderInclude }> interface OdooCompany { uuid?: string @@ -162,7 +103,395 @@ interface OdooOrder { stages?: OdooStage[] } -function mapCompany(c?: OdooCompany) { +interface TariffMatchResult { + tariff: PrismaTariffReference + score: number + reasons: string[] + projectedAmount: number + projectedEtaDays: number | null +} + +type CreateTariffReferenceInput = Record +type UpdateTariffReferenceInput = Record +type CreateQuotationInput = Record +type UpdateQuotationInput = Record + +export const teamTypeDefs = `#graphql + type Company { + uuid: String + name: String + taxId: String + country: String + countryCode: String + active: Boolean + } + + type Trip { + uuid: String + name: String + sequence: Int + company: Company + plannedLoadingDate: String + actualLoadingDate: String + realLoadingDate: String + plannedUnloadingDate: String + actualUnloadingDate: String + plannedWeight: Float + weightAtLoading: Float + weightAtUnloading: Float + } + + type Stage { + uuid: String + name: String + sequence: Int + stageType: String + transportType: String + sourceLocationName: String + sourceLatitude: Float + sourceLongitude: Float + destinationLocationName: String + destinationLatitude: Float + destinationLongitude: Float + locationName: String + locationLatitude: Float + locationLongitude: Float + selectedCompany: Company + trips: [Trip] + } + + type OrderLine { + uuid: String + productUuid: String + productName: String + quantity: Float + unit: String + priceUnit: Float + subtotal: Float + currency: String + notes: String + } + + type Order { + uuid: String + quotationUuid: String + name: String + teamUuid: String + userId: String + status: String + totalAmount: Float + currency: String + sourceCountryCode: String + sourceLocationUuid: String + sourceLocationName: String + sourceLatitude: Float + sourceLongitude: Float + destinationCountryCode: String + destinationLocationUuid: String + destinationLocationName: String + etaDays: Int + createdAt: String + updatedAt: String + notes: String + orderLines: [OrderLine] + stages: [Stage] + } + + type TariffReference { + uuid: String! + teamUuid: String! + name: String! + status: String! + operationCode: String + incotermsCode: String + transportTypeCode: String + tareTypeCode: String + sourceCountryCode: String + destinationCountryCode: String + sourceHubUuid: String + destinationHubUuid: String + minWeightKg: Float + maxWeightKg: Float + minVolumeCbm: Float + maxVolumeCbm: Float + minDistanceKm: Int + maxDistanceKm: Int + amountUsd: Float! + minPriceUsd: Float + etaDays: Int + priority: Int! + currency: String! + dmnExpression: String + notes: String + createdByUserId: String + createdAt: String! + updatedAt: String! + } + + type QuotationChange { + uuid: String! + actorUserId: String + actorLabel: String + source: String! + summary: String + payloadJson: String + createdAt: String! + } + + type Quotation { + uuid: String! + teamUuid: String! + createdByUserId: String + title: String! + status: String! + operationCode: String + incotermsCode: String + transportTypeCode: String + tareTypeCode: String + sourceCountryCode: String + sourceHubUuid: String + sourceLocationUuid: String + sourceLocationName: String + sourceLatitude: Float + sourceLongitude: Float + destinationCountryCode: String + destinationHubUuid: String + destinationLocationUuid: String + destinationLocationName: String + destinationLatitude: Float + destinationLongitude: Float + chargeableWeightKg: Float + grossWeightKg: Float + volumeCbm: Float + unitsCount: Int + routeDistanceKm: Int + selectedTariff: TariffReference + tariffMatchSummary: String + tariffSnapshot: String + totalAmount: Float! + currency: String! + etaDays: Int + notes: String + createdAt: String! + updatedAt: String! + changes: [QuotationChange!]! + } + + type QuotationTariffMatch { + tariffReference: TariffReference! + score: Int! + reasons: [String!]! + projectedAmount: Float! + projectedEtaDays: Int + isSelected: Boolean! + } + + input CreateTariffReferenceInput { + name: String! + status: String + operationCode: String + incotermsCode: String + transportTypeCode: String + tareTypeCode: String + sourceCountryCode: String + destinationCountryCode: String + sourceHubUuid: String + destinationHubUuid: String + minWeightKg: Float + maxWeightKg: Float + minVolumeCbm: Float + maxVolumeCbm: Float + minDistanceKm: Int + maxDistanceKm: Int + amountUsd: Float! + minPriceUsd: Float + etaDays: Int + priority: Int + currency: String + dmnExpression: String + notes: String + } + + input UpdateTariffReferenceInput { + name: String + status: String + operationCode: String + incotermsCode: String + transportTypeCode: String + tareTypeCode: String + sourceCountryCode: String + destinationCountryCode: String + sourceHubUuid: String + destinationHubUuid: String + minWeightKg: Float + maxWeightKg: Float + minVolumeCbm: Float + maxVolumeCbm: Float + minDistanceKm: Int + maxDistanceKm: Int + amountUsd: Float + minPriceUsd: Float + etaDays: Int + priority: Int + currency: String + dmnExpression: String + notes: String + } + + input CreateQuotationInput { + title: String + status: String + operationCode: String + incotermsCode: String + transportTypeCode: String + tareTypeCode: String + sourceCountryCode: String + sourceHubUuid: String + sourceLocationUuid: String + sourceLocationName: String + sourceLatitude: Float + sourceLongitude: Float + destinationCountryCode: String + destinationHubUuid: String + destinationLocationUuid: String + destinationLocationName: String + destinationLatitude: Float + destinationLongitude: Float + chargeableWeightKg: Float + grossWeightKg: Float + volumeCbm: Float + unitsCount: Int + routeDistanceKm: Int + notes: String + } + + input UpdateQuotationInput { + title: String + status: String + operationCode: String + incotermsCode: String + transportTypeCode: String + tareTypeCode: String + sourceCountryCode: String + sourceHubUuid: String + sourceLocationUuid: String + sourceLocationName: String + sourceLatitude: Float + sourceLongitude: Float + destinationCountryCode: String + destinationHubUuid: String + destinationLocationUuid: String + destinationLocationName: String + destinationLatitude: Float + destinationLongitude: Float + chargeableWeightKg: Float + grossWeightKg: Float + volumeCbm: Float + unitsCount: Int + routeDistanceKm: Int + notes: String + } + + type Query { + getTeamOrders: [Order] + getOrder(orderUuid: String!): Order + quotations(status: String): [Quotation!]! + quotation(quotationUuid: String!): Quotation + tariffReferences(status: String): [TariffReference!]! + tariffReference(tariffReferenceUuid: String!): TariffReference + quotationTariffMatches(quotationUuid: String!): [QuotationTariffMatch!]! + } + + type Mutation { + createTariffReference(input: CreateTariffReferenceInput!): TariffReference! + updateTariffReference(tariffReferenceUuid: String!, input: UpdateTariffReferenceInput!): TariffReference! + deleteTariffReference(tariffReferenceUuid: String!): Boolean! + createQuotation(input: CreateQuotationInput!): Quotation! + updateQuotation(quotationUuid: String!, input: UpdateQuotationInput!): Quotation! + refreshQuotationTariff(quotationUuid: String!): Quotation! + selectQuotationTariff(quotationUuid: String!, tariffReferenceUuid: String!): Quotation! + createOrderFromQuotation(quotationUuid: String!): Order! + } +` + +function hasOwn(input: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(input, key) +} + +function normalizeString(value: unknown): string | null { + if (value == null) return null + const next = String(value).trim() + return next ? next : null +} + +function normalizeUpper(value: unknown): string | null { + const next = normalizeString(value) + return next ? next.toUpperCase() : null +} + +function normalizeNumber(value: unknown): number | null { + if (value == null || value === '') return null + const next = Number(value) + if (!Number.isFinite(next)) { + throw new GraphQLError('Numeric field contains invalid value') + } + return next +} + +function normalizeInteger(value: unknown): number | null { + const next = normalizeNumber(value) + if (next == null) return null + return Math.round(next) +} + +function normalizeRequiredUpdateString(value: unknown): string | undefined { + const next = normalizeString(value) + return next ?? undefined +} + +function normalizeRequiredUpdateUpper(value: unknown): string | undefined { + const next = normalizeUpper(value) + return next ?? undefined +} + +function normalizeRequiredUpdateDecimal(value: unknown): Prisma.Decimal | undefined { + const next = normalizeDecimal(value) + return next ?? undefined +} + +function normalizeRequiredUpdateInteger(value: unknown): number | undefined { + const next = normalizeInteger(value) + return next ?? undefined +} + +function normalizeDecimal(value: unknown): Prisma.Decimal | null { + const next = normalizeNumber(value) + if (next == null) return null + return new Prisma.Decimal(next) +} + +function numberFromDecimal(value: Prisma.Decimal | number | null | undefined): number | null { + if (value == null) return null + return Number(value) +} + +function isoString(value: Date | null | undefined): string | null { + return value ? value.toISOString() : null +} + +function assertTeamAccess(ctx: AuthContext): { teamUuid: string; userId: string } { + requireScopes(ctx, 'teams:member') + if (!ctx.teamUuid || !ctx.userId) { + throw new GraphQLError('User not authenticated') + } + + return { + teamUuid: ctx.teamUuid, + userId: ctx.userId, + } +} + +function mapCompany(c?: OdooCompany | null) { if (!c) return null return { uuid: c.uuid ?? '', @@ -212,23 +541,27 @@ function mapStage(s: OdooStage) { } } -function mapOrder(o: OdooOrder) { +function mapOdooOrder(o: OdooOrder) { const stages = o.stages ?? [] const firstStage = stages[0] return { uuid: o.uuid, + quotationUuid: null, name: o.name, teamUuid: o.team_uuid, userId: o.user_id, - status: 'active', + status: o.status ?? 'active', totalAmount: o.total_amount, currency: o.currency, + sourceCountryCode: null, sourceLocationUuid: o.source_location_uuid, sourceLocationName: o.source_location_name, sourceLatitude: o.source_latitude || firstStage?.source_latitude, sourceLongitude: o.source_longitude || firstStage?.source_longitude, + destinationCountryCode: null, destinationLocationUuid: o.destination_location_uuid, destinationLocationName: o.destination_location_name, + etaDays: null, createdAt: o.created_at, updatedAt: o.updated_at, notes: o.notes ?? '', @@ -247,6 +580,119 @@ function mapOrder(o: OdooOrder) { } } +function mapTariffReference(item: PrismaTariffReference) { + return { + uuid: item.uuid, + teamUuid: item.teamUuid, + name: item.name, + status: item.status, + operationCode: item.operationCode, + incotermsCode: item.incotermsCode, + transportTypeCode: item.transportTypeCode, + tareTypeCode: item.tareTypeCode, + sourceCountryCode: item.sourceCountryCode, + destinationCountryCode: item.destinationCountryCode, + sourceHubUuid: item.sourceHubUuid, + destinationHubUuid: item.destinationHubUuid, + minWeightKg: numberFromDecimal(item.minWeightKg), + maxWeightKg: numberFromDecimal(item.maxWeightKg), + minVolumeCbm: numberFromDecimal(item.minVolumeCbm), + maxVolumeCbm: numberFromDecimal(item.maxVolumeCbm), + minDistanceKm: item.minDistanceKm, + maxDistanceKm: item.maxDistanceKm, + amountUsd: Number(item.amountUsd), + minPriceUsd: numberFromDecimal(item.minPriceUsd), + etaDays: item.etaDays, + priority: item.priority, + currency: item.currency, + dmnExpression: item.dmnExpression, + notes: item.notes, + createdByUserId: item.createdByUserId, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + } +} + +function mapQuotationChange(item: PrismaQuotationChange) { + return { + uuid: item.uuid, + actorUserId: item.actorUserId, + actorLabel: item.actorLabel, + source: item.source, + summary: item.summary, + payloadJson: item.payloadJson, + createdAt: item.createdAt.toISOString(), + } +} + +function mapQuotation(item: QuotationWithRelations) { + return { + uuid: item.uuid, + teamUuid: item.teamUuid, + createdByUserId: item.createdByUserId, + title: item.title, + status: item.status, + operationCode: item.operationCode, + incotermsCode: item.incotermsCode, + transportTypeCode: item.transportTypeCode, + tareTypeCode: item.tareTypeCode, + sourceCountryCode: item.sourceCountryCode, + sourceHubUuid: item.sourceHubUuid, + sourceLocationUuid: item.sourceLocationUuid, + sourceLocationName: item.sourceLocationName, + sourceLatitude: item.sourceLatitude, + sourceLongitude: item.sourceLongitude, + destinationCountryCode: item.destinationCountryCode, + destinationHubUuid: item.destinationHubUuid, + destinationLocationUuid: item.destinationLocationUuid, + destinationLocationName: item.destinationLocationName, + destinationLatitude: item.destinationLatitude, + destinationLongitude: item.destinationLongitude, + chargeableWeightKg: numberFromDecimal(item.chargeableWeightKg), + grossWeightKg: numberFromDecimal(item.grossWeightKg), + volumeCbm: numberFromDecimal(item.volumeCbm), + unitsCount: item.unitsCount, + routeDistanceKm: item.routeDistanceKm, + selectedTariff: item.selectedTariff ? mapTariffReference(item.selectedTariff) : null, + tariffMatchSummary: item.tariffMatchSummary, + tariffSnapshot: item.tariffSnapshot, + totalAmount: Number(item.totalAmount), + currency: item.currency, + etaDays: item.etaDays, + notes: item.notes, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + changes: item.changes.map(mapQuotationChange), + } +} + +function mapLocalOrder(item: OrderWithRelations) { + return { + uuid: item.uuid, + quotationUuid: item.quotation?.uuid ?? null, + name: item.name, + teamUuid: item.teamUuid, + userId: item.createdByUserId, + status: item.status, + totalAmount: Number(item.totalAmount), + currency: item.currency, + sourceCountryCode: item.sourceCountryCode, + sourceLocationUuid: item.sourceLocationUuid, + sourceLocationName: item.sourceLocationName, + sourceLatitude: item.sourceLatitude, + sourceLongitude: item.sourceLongitude, + destinationCountryCode: item.destinationCountryCode, + destinationLocationUuid: item.destinationLocationUuid, + destinationLocationName: item.destinationLocationName, + etaDays: item.etaDays, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + notes: item.notes ?? '', + orderLines: [], + stages: [], + } +} + async function fetchOdoo(path: string): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10000) @@ -257,43 +703,689 @@ async function fetchOdoo(path: string): Promise { } } +async function fetchTeamOdooOrders(teamUuid: string) { + try { + const res = await fetchOdoo(`/fastapi/orders/orders/team/${teamUuid}`) + if (!res.ok) return [] + const data = (await res.json()) as OdooOrder[] + return data.map(mapOdooOrder) + } catch (error) { + console.error('Error calling Odoo:', error) + return [] + } +} + +async function fetchSingleOdooOrder(orderUuid: string, teamUuid: string) { + try { + const res = await fetchOdoo(`/fastapi/orders/orders/${orderUuid}`) + if (!res.ok) return null + const data = (await res.json()) as OdooOrder + if (data.team_uuid !== teamUuid) { + throw new GraphQLError('Access denied: order belongs to different team') + } + return mapOdooOrder(data) + } catch (error) { + if (error instanceof GraphQLError) throw error + console.error('Error calling Odoo:', error) + return null + } +} + +function compareByCreatedAtDesc( + left: { createdAt: string | null | undefined }, + right: { createdAt: string | null | undefined }, +) { + const leftTime = left.createdAt ? new Date(left.createdAt).getTime() : 0 + const rightTime = right.createdAt ? new Date(right.createdAt).getTime() : 0 + return rightTime - leftTime +} + +function requiredString(value: unknown, label: string): string { + const next = normalizeString(value) + if (!next) { + throw new GraphQLError(`${label} is required`) + } + return next +} + +function buildCreateTariffData(input: CreateTariffReferenceInput, ctx: { teamUuid: string; userId: string }): Prisma.TariffReferenceCreateInput { + return { + teamUuid: ctx.teamUuid, + createdByUserId: ctx.userId, + name: requiredString(input.name, 'Tariff name'), + status: normalizeString(input.status) ?? 'active', + operationCode: normalizeUpper(input.operationCode), + incotermsCode: normalizeUpper(input.incotermsCode), + transportTypeCode: normalizeUpper(input.transportTypeCode), + tareTypeCode: normalizeUpper(input.tareTypeCode), + sourceCountryCode: normalizeUpper(input.sourceCountryCode), + destinationCountryCode: normalizeUpper(input.destinationCountryCode), + sourceHubUuid: normalizeString(input.sourceHubUuid), + destinationHubUuid: normalizeString(input.destinationHubUuid), + minWeightKg: normalizeDecimal(input.minWeightKg), + maxWeightKg: normalizeDecimal(input.maxWeightKg), + minVolumeCbm: normalizeDecimal(input.minVolumeCbm), + maxVolumeCbm: normalizeDecimal(input.maxVolumeCbm), + minDistanceKm: normalizeInteger(input.minDistanceKm), + maxDistanceKm: normalizeInteger(input.maxDistanceKm), + amountUsd: normalizeDecimal(input.amountUsd) ?? new Prisma.Decimal(0), + minPriceUsd: normalizeDecimal(input.minPriceUsd), + etaDays: normalizeInteger(input.etaDays), + priority: normalizeInteger(input.priority) ?? 100, + currency: normalizeUpper(input.currency) ?? 'USD', + dmnExpression: normalizeString(input.dmnExpression), + notes: normalizeString(input.notes), + } +} + +function buildUpdateTariffData(input: UpdateTariffReferenceInput): Prisma.TariffReferenceUpdateInput { + const data: Prisma.TariffReferenceUpdateInput = {} + + if (hasOwn(input, 'name')) data.name = normalizeRequiredUpdateString(input.name) + if (hasOwn(input, 'status')) data.status = normalizeRequiredUpdateString(input.status) + if (hasOwn(input, 'operationCode')) data.operationCode = normalizeUpper(input.operationCode) + if (hasOwn(input, 'incotermsCode')) data.incotermsCode = normalizeUpper(input.incotermsCode) + if (hasOwn(input, 'transportTypeCode')) data.transportTypeCode = normalizeUpper(input.transportTypeCode) + if (hasOwn(input, 'tareTypeCode')) data.tareTypeCode = normalizeUpper(input.tareTypeCode) + if (hasOwn(input, 'sourceCountryCode')) data.sourceCountryCode = normalizeUpper(input.sourceCountryCode) + if (hasOwn(input, 'destinationCountryCode')) data.destinationCountryCode = normalizeUpper(input.destinationCountryCode) + if (hasOwn(input, 'sourceHubUuid')) data.sourceHubUuid = normalizeString(input.sourceHubUuid) + if (hasOwn(input, 'destinationHubUuid')) data.destinationHubUuid = normalizeString(input.destinationHubUuid) + if (hasOwn(input, 'minWeightKg')) data.minWeightKg = normalizeDecimal(input.minWeightKg) + if (hasOwn(input, 'maxWeightKg')) data.maxWeightKg = normalizeDecimal(input.maxWeightKg) + if (hasOwn(input, 'minVolumeCbm')) data.minVolumeCbm = normalizeDecimal(input.minVolumeCbm) + if (hasOwn(input, 'maxVolumeCbm')) data.maxVolumeCbm = normalizeDecimal(input.maxVolumeCbm) + if (hasOwn(input, 'minDistanceKm')) data.minDistanceKm = normalizeInteger(input.minDistanceKm) + if (hasOwn(input, 'maxDistanceKm')) data.maxDistanceKm = normalizeInteger(input.maxDistanceKm) + if (hasOwn(input, 'amountUsd')) data.amountUsd = normalizeRequiredUpdateDecimal(input.amountUsd) + if (hasOwn(input, 'minPriceUsd')) data.minPriceUsd = normalizeDecimal(input.minPriceUsd) + if (hasOwn(input, 'etaDays')) data.etaDays = normalizeInteger(input.etaDays) + if (hasOwn(input, 'priority')) data.priority = normalizeRequiredUpdateInteger(input.priority) + if (hasOwn(input, 'currency')) data.currency = normalizeRequiredUpdateUpper(input.currency) + if (hasOwn(input, 'dmnExpression')) data.dmnExpression = normalizeString(input.dmnExpression) + if (hasOwn(input, 'notes')) data.notes = normalizeString(input.notes) + + return data +} + +function buildCreateQuotationData(input: CreateQuotationInput, ctx: { teamUuid: string; userId: string }): Prisma.QuotationCreateInput { + return { + teamUuid: ctx.teamUuid, + createdByUserId: ctx.userId, + title: normalizeString(input.title) ?? 'Quotation', + status: normalizeString(input.status) ?? 'draft', + operationCode: normalizeUpper(input.operationCode), + incotermsCode: normalizeUpper(input.incotermsCode), + transportTypeCode: normalizeUpper(input.transportTypeCode), + tareTypeCode: normalizeUpper(input.tareTypeCode), + sourceCountryCode: normalizeUpper(input.sourceCountryCode), + sourceHubUuid: normalizeString(input.sourceHubUuid), + sourceLocationUuid: normalizeString(input.sourceLocationUuid), + sourceLocationName: normalizeString(input.sourceLocationName), + sourceLatitude: normalizeNumber(input.sourceLatitude), + sourceLongitude: normalizeNumber(input.sourceLongitude), + destinationCountryCode: normalizeUpper(input.destinationCountryCode), + destinationHubUuid: normalizeString(input.destinationHubUuid), + destinationLocationUuid: normalizeString(input.destinationLocationUuid), + destinationLocationName: normalizeString(input.destinationLocationName), + destinationLatitude: normalizeNumber(input.destinationLatitude), + destinationLongitude: normalizeNumber(input.destinationLongitude), + chargeableWeightKg: normalizeDecimal(input.chargeableWeightKg), + grossWeightKg: normalizeDecimal(input.grossWeightKg), + volumeCbm: normalizeDecimal(input.volumeCbm), + unitsCount: normalizeInteger(input.unitsCount), + routeDistanceKm: normalizeInteger(input.routeDistanceKm), + notes: normalizeString(input.notes), + } +} + +function buildUpdateQuotationData(input: UpdateQuotationInput): Prisma.QuotationUpdateInput { + const data: Prisma.QuotationUpdateInput = {} + + if (hasOwn(input, 'title')) data.title = normalizeRequiredUpdateString(input.title) + if (hasOwn(input, 'status')) data.status = normalizeRequiredUpdateString(input.status) + if (hasOwn(input, 'operationCode')) data.operationCode = normalizeUpper(input.operationCode) + if (hasOwn(input, 'incotermsCode')) data.incotermsCode = normalizeUpper(input.incotermsCode) + if (hasOwn(input, 'transportTypeCode')) data.transportTypeCode = normalizeUpper(input.transportTypeCode) + if (hasOwn(input, 'tareTypeCode')) data.tareTypeCode = normalizeUpper(input.tareTypeCode) + if (hasOwn(input, 'sourceCountryCode')) data.sourceCountryCode = normalizeUpper(input.sourceCountryCode) + if (hasOwn(input, 'sourceHubUuid')) data.sourceHubUuid = normalizeString(input.sourceHubUuid) + if (hasOwn(input, 'sourceLocationUuid')) data.sourceLocationUuid = normalizeString(input.sourceLocationUuid) + if (hasOwn(input, 'sourceLocationName')) data.sourceLocationName = normalizeString(input.sourceLocationName) + if (hasOwn(input, 'sourceLatitude')) data.sourceLatitude = normalizeNumber(input.sourceLatitude) + if (hasOwn(input, 'sourceLongitude')) data.sourceLongitude = normalizeNumber(input.sourceLongitude) + if (hasOwn(input, 'destinationCountryCode')) data.destinationCountryCode = normalizeUpper(input.destinationCountryCode) + if (hasOwn(input, 'destinationHubUuid')) data.destinationHubUuid = normalizeString(input.destinationHubUuid) + if (hasOwn(input, 'destinationLocationUuid')) data.destinationLocationUuid = normalizeString(input.destinationLocationUuid) + if (hasOwn(input, 'destinationLocationName')) data.destinationLocationName = normalizeString(input.destinationLocationName) + if (hasOwn(input, 'destinationLatitude')) data.destinationLatitude = normalizeNumber(input.destinationLatitude) + if (hasOwn(input, 'destinationLongitude')) data.destinationLongitude = normalizeNumber(input.destinationLongitude) + if (hasOwn(input, 'chargeableWeightKg')) data.chargeableWeightKg = normalizeDecimal(input.chargeableWeightKg) + if (hasOwn(input, 'grossWeightKg')) data.grossWeightKg = normalizeDecimal(input.grossWeightKg) + if (hasOwn(input, 'volumeCbm')) data.volumeCbm = normalizeDecimal(input.volumeCbm) + if (hasOwn(input, 'unitsCount')) data.unitsCount = normalizeInteger(input.unitsCount) + if (hasOwn(input, 'routeDistanceKm')) data.routeDistanceKm = normalizeInteger(input.routeDistanceKm) + if (hasOwn(input, 'notes')) data.notes = normalizeString(input.notes) + + return data +} + +function matchesExact(ruleValue: string | null, actualValue: string | null, weight: number, reasons: string[], label: string): number | null { + if (!ruleValue) return 0 + if (!actualValue) return null + if (ruleValue !== actualValue) return null + reasons.push(`${label}: ${ruleValue}`) + return weight +} + +function matchesRange( + min: number | null, + max: number | null, + actual: number | null, + weight: number, + reasons: string[], + label: string, +): number | null { + if (min == null && max == null) return 0 + if (actual == null) return null + if (min != null && actual < min) return null + if (max != null && actual > max) return null + reasons.push(label) + return weight +} + +function computeTariffMatches( + quotation: PrismaQuotation, + tariffs: PrismaTariffReference[], +): TariffMatchResult[] { + const matches: TariffMatchResult[] = [] + + for (const tariff of tariffs) { + if (tariff.status !== 'active') continue + + const reasons: string[] = [] + let score = 0 + + const exactChecks = [ + matchesExact(tariff.operationCode, quotation.operationCode, 30, reasons, 'operation'), + matchesExact(tariff.incotermsCode, quotation.incotermsCode, 25, reasons, 'incoterms'), + matchesExact(tariff.transportTypeCode, quotation.transportTypeCode, 20, reasons, 'transport'), + matchesExact(tariff.tareTypeCode, quotation.tareTypeCode, 15, reasons, 'tare'), + matchesExact(tariff.sourceCountryCode, quotation.sourceCountryCode, 15, reasons, 'origin country'), + matchesExact(tariff.destinationCountryCode, quotation.destinationCountryCode, 15, reasons, 'destination country'), + matchesExact(tariff.sourceHubUuid, quotation.sourceHubUuid, 25, reasons, 'origin hub'), + matchesExact(tariff.destinationHubUuid, quotation.destinationHubUuid, 25, reasons, 'destination hub'), + ] + + if (exactChecks.some(item => item == null)) continue + const exactScoreParts = exactChecks.filter((item): item is number => item != null) + score += exactScoreParts.reduce((sum, item) => sum + item, 0) + + const rangeChecks = [ + matchesRange(numberFromDecimal(tariff.minWeightKg), numberFromDecimal(tariff.maxWeightKg), numberFromDecimal(quotation.chargeableWeightKg), 20, reasons, 'chargeable weight'), + matchesRange(numberFromDecimal(tariff.minVolumeCbm), numberFromDecimal(tariff.maxVolumeCbm), numberFromDecimal(quotation.volumeCbm), 12, reasons, 'volume'), + matchesRange(tariff.minDistanceKm, tariff.maxDistanceKm, quotation.routeDistanceKm, 12, reasons, 'distance'), + ] + + if (rangeChecks.some(item => item == null)) continue + const rangeScoreParts = rangeChecks.filter((item): item is number => item != null) + score += rangeScoreParts.reduce((sum, item) => sum + item, 0) + + const projectedAmount = Math.max(Number(tariff.amountUsd), numberFromDecimal(tariff.minPriceUsd) ?? 0) + matches.push({ + tariff, + score, + reasons, + projectedAmount, + projectedEtaDays: tariff.etaDays, + }) + } + + return matches.sort((left, right) => { + if (right.score !== left.score) return right.score - left.score + if (left.tariff.priority !== right.tariff.priority) return left.tariff.priority - right.tariff.priority + return right.tariff.updatedAt.getTime() - left.tariff.updatedAt.getTime() + }) +} + +async function loadQuotationOrThrow(quotationUuid: string, teamUuid: string) { + const item = await prisma.quotation.findFirst({ + where: { + uuid: quotationUuid, + teamUuid, + }, + include: quotationInclude, + }) + + if (!item) { + throw new GraphQLError('Quotation not found') + } + + return item +} + +async function loadTariffOrThrow(tariffReferenceUuid: string, teamUuid: string) { + const item = await prisma.tariffReference.findFirst({ + where: { + uuid: tariffReferenceUuid, + teamUuid, + }, + }) + + if (!item) { + throw new GraphQLError('Tariff reference not found') + } + + return item +} + +async function addQuotationChange( + quotationId: number, + actor: { userId: string; label: string }, + source: string, + summary: string, + payload?: Record, +) { + await prisma.quotationChange.create({ + data: { + quotationId, + actorUserId: actor.userId, + actorLabel: actor.label, + source, + summary, + payloadJson: payload ? JSON.stringify(payload) : null, + }, + }) +} + +function actorLabel(ctx: { userId: string; teamUuid: string }): string { + return `user:${ctx.userId}@${ctx.teamUuid}` +} + +async function refreshQuotationSelection(quotationUuid: string, teamUuid: string, actor: { userId: string; label: string }) { + const quotation = await prisma.quotation.findFirst({ + where: { + uuid: quotationUuid, + teamUuid, + }, + }) + + if (!quotation) { + throw new GraphQLError('Quotation not found') + } + + const tariffs = await prisma.tariffReference.findMany({ + where: { + teamUuid, + }, + }) + + const matches = computeTariffMatches(quotation, tariffs) + const bestMatch = matches[0] ?? null + + const tariffMatchSummary = bestMatch + ? `Matched ${bestMatch.tariff.name}; ${bestMatch.reasons.join(', ')}` + : 'No active tariff reference matched this quotation' + + const tariffSnapshot = JSON.stringify({ + quotationUuid, + matchedAt: new Date().toISOString(), + matchCount: matches.length, + selectedTariffUuid: bestMatch?.tariff.uuid ?? null, + matches: matches.slice(0, 10).map(item => ({ + tariffReferenceUuid: item.tariff.uuid, + score: item.score, + reasons: item.reasons, + projectedAmount: item.projectedAmount, + projectedEtaDays: item.projectedEtaDays, + })), + }) + + const updated = await prisma.quotation.update({ + where: { + id: quotation.id, + }, + data: { + selectedTariffId: bestMatch?.tariff.id ?? null, + totalAmount: new Prisma.Decimal(bestMatch?.projectedAmount ?? 0), + currency: bestMatch?.tariff.currency ?? quotation.currency, + etaDays: bestMatch?.projectedEtaDays ?? null, + tariffMatchSummary, + tariffSnapshot, + status: quotation.status === 'converted' + ? quotation.status + : bestMatch + ? 'priced' + : 'draft', + }, + include: quotationInclude, + }) + + await addQuotationChange( + quotation.id, + actor, + 'tariff-engine', + bestMatch ? `Tariff refreshed: ${bestMatch.tariff.name}` : 'Tariff refresh found no matching tariff', + { + selectedTariffUuid: bestMatch?.tariff.uuid ?? null, + matchCount: matches.length, + }, + ) + + return await loadQuotationOrThrow(updated.uuid, teamUuid) +} + +async function refreshTeamQuotations(teamUuid: string, actor: { userId: string; label: string }) { + const items = await prisma.quotation.findMany({ + where: { + teamUuid, + status: { + not: 'converted', + }, + }, + select: { + uuid: true, + }, + }) + + for (const item of items) { + await refreshQuotationSelection(item.uuid, teamUuid, actor) + } +} + export const teamResolvers = { Query: { getTeamOrders: async (_: unknown, __: unknown, ctx: AuthContext) => { - requireScopes(ctx, 'teams:member') - if (!ctx.userId) throw new GraphQLError('User not authenticated') - if (!ctx.teamUuid) return [] + const { teamUuid } = assertTeamAccess(ctx) - try { - const res = await fetchOdoo(`/fastapi/orders/orders/team/${ctx.teamUuid}`) - if (!res.ok) return [] - const data = (await res.json()) as OdooOrder[] - return data.map(mapOrder) - } catch (e) { - console.error('Error calling Odoo:', e) - return [] - } + const [localOrders, odooOrders] = await Promise.all([ + prisma.order.findMany({ + where: { + teamUuid, + }, + include: orderInclude, + orderBy: { + createdAt: 'desc', + }, + }), + fetchTeamOdooOrders(teamUuid), + ]) + + return [...localOrders.map(mapLocalOrder), ...odooOrders].sort(compareByCreatedAtDesc) }, getOrder: async (_: unknown, args: { orderUuid: string }, ctx: AuthContext) => { - requireScopes(ctx, 'teams:member') - if (!ctx.userId || !ctx.teamUuid) { - throw new GraphQLError('User not authenticated') + const { teamUuid } = assertTeamAccess(ctx) + + const localOrder = await prisma.order.findFirst({ + where: { + uuid: args.orderUuid, + teamUuid, + }, + include: orderInclude, + }) + + if (localOrder) { + return mapLocalOrder(localOrder) } - try { - const res = await fetchOdoo(`/fastapi/orders/orders/${args.orderUuid}`) - if (!res.ok) return null - const data = (await res.json()) as OdooOrder - if (data.team_uuid !== ctx.teamUuid) { - throw new GraphQLError('Access denied: order belongs to different team') - } - return mapOrder(data) - } catch (e) { - if (e instanceof GraphQLError) throw e - console.error('Error calling Odoo:', e) - return null + return await fetchSingleOdooOrder(args.orderUuid, teamUuid) + }, + + quotations: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => { + const { teamUuid } = assertTeamAccess(ctx) + + const items = await prisma.quotation.findMany({ + where: { + teamUuid, + status: normalizeString(args.status) ?? undefined, + }, + include: quotationInclude, + orderBy: { + createdAt: 'desc', + }, + }) + + return items.map(mapQuotation) + }, + + quotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { + const { teamUuid } = assertTeamAccess(ctx) + const item = await prisma.quotation.findFirst({ + where: { + uuid: args.quotationUuid, + teamUuid, + }, + include: quotationInclude, + }) + return item ? mapQuotation(item) : null + }, + + tariffReferences: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => { + const { teamUuid } = assertTeamAccess(ctx) + + const items = await prisma.tariffReference.findMany({ + where: { + teamUuid, + status: normalizeString(args.status) ?? undefined, + }, + orderBy: [ + { priority: 'asc' }, + { updatedAt: 'desc' }, + ], + }) + + return items.map(mapTariffReference) + }, + + tariffReference: async (_: unknown, args: { tariffReferenceUuid: string }, ctx: AuthContext) => { + const { teamUuid } = assertTeamAccess(ctx) + const item = await prisma.tariffReference.findFirst({ + where: { + uuid: args.tariffReferenceUuid, + teamUuid, + }, + }) + return item ? mapTariffReference(item) : null + }, + + quotationTariffMatches: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { + const { teamUuid } = assertTeamAccess(ctx) + const quotation = await loadQuotationOrThrow(args.quotationUuid, teamUuid) + const tariffs = await prisma.tariffReference.findMany({ + where: { + teamUuid, + status: 'active', + }, + }) + + const matches = computeTariffMatches(quotation, tariffs) + return matches.map(item => ({ + tariffReference: mapTariffReference(item.tariff), + score: item.score, + reasons: item.reasons, + projectedAmount: item.projectedAmount, + projectedEtaDays: item.projectedEtaDays, + isSelected: quotation.selectedTariff?.uuid === item.tariff.uuid, + })) + }, + }, + + Mutation: { + createTariffReference: async (_: unknown, args: { input: CreateTariffReferenceInput }, ctx: AuthContext) => { + const access = assertTeamAccess(ctx) + const actor = { userId: access.userId, label: actorLabel(access) } + const item = await prisma.tariffReference.create({ + data: buildCreateTariffData(args.input, access), + }) + await refreshTeamQuotations(access.teamUuid, actor) + return mapTariffReference(item) + }, + + updateTariffReference: async (_: unknown, args: { tariffReferenceUuid: string; input: UpdateTariffReferenceInput }, ctx: AuthContext) => { + const access = assertTeamAccess(ctx) + const actor = { userId: access.userId, label: actorLabel(access) } + const item = await loadTariffOrThrow(args.tariffReferenceUuid, access.teamUuid) + const updated = await prisma.tariffReference.update({ + where: { + id: item.id, + }, + data: buildUpdateTariffData(args.input), + }) + await refreshTeamQuotations(access.teamUuid, actor) + return mapTariffReference(updated) + }, + + deleteTariffReference: async (_: unknown, args: { tariffReferenceUuid: string }, ctx: AuthContext) => { + const access = assertTeamAccess(ctx) + const actor = { userId: access.userId, label: actorLabel(access) } + const item = await loadTariffOrThrow(args.tariffReferenceUuid, access.teamUuid) + await prisma.tariffReference.delete({ + where: { + id: item.id, + }, + }) + await refreshTeamQuotations(access.teamUuid, actor) + return true + }, + + createQuotation: async (_: unknown, args: { input: CreateQuotationInput }, ctx: AuthContext) => { + const access = assertTeamAccess(ctx) + const actor = { userId: access.userId, label: actorLabel(access) } + const created = await prisma.quotation.create({ + data: buildCreateQuotationData(args.input, access), + }) + + await addQuotationChange(created.id, actor, 'quotation', 'Quotation created', { + quotationUuid: created.uuid, + }) + + const refreshed = await refreshQuotationSelection(created.uuid, access.teamUuid, actor) + return mapQuotation(refreshed) + }, + + updateQuotation: async (_: unknown, args: { quotationUuid: string; input: UpdateQuotationInput }, ctx: AuthContext) => { + const access = assertTeamAccess(ctx) + const actor = { userId: access.userId, label: actorLabel(access) } + const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) + await prisma.quotation.update({ + where: { + id: current.id, + }, + data: buildUpdateQuotationData(args.input), + }) + + await addQuotationChange(current.id, actor, 'quotation', 'Quotation updated', { + quotationUuid: current.uuid, + updatedFields: Object.keys(args.input), + }) + + const refreshed = await refreshQuotationSelection(current.uuid, access.teamUuid, actor) + return mapQuotation(refreshed) + }, + + refreshQuotationTariff: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { + const access = assertTeamAccess(ctx) + const actor = { userId: access.userId, label: actorLabel(access) } + const refreshed = await refreshQuotationSelection(args.quotationUuid, access.teamUuid, actor) + return mapQuotation(refreshed) + }, + + selectQuotationTariff: async (_: unknown, args: { quotationUuid: string; tariffReferenceUuid: string }, ctx: AuthContext) => { + const access = assertTeamAccess(ctx) + const actor = { userId: access.userId, label: actorLabel(access) } + const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) + const tariff = await loadTariffOrThrow(args.tariffReferenceUuid, access.teamUuid) + + const projectedAmount = Math.max(Number(tariff.amountUsd), numberFromDecimal(tariff.minPriceUsd) ?? 0) + await prisma.quotation.update({ + where: { + id: quotation.id, + }, + data: { + selectedTariffId: tariff.id, + totalAmount: new Prisma.Decimal(projectedAmount), + currency: tariff.currency, + etaDays: tariff.etaDays, + tariffMatchSummary: `Manually selected ${tariff.name}`, + tariffSnapshot: JSON.stringify({ + quotationUuid: quotation.uuid, + selectedTariffUuid: tariff.uuid, + selectedAt: new Date().toISOString(), + selectionMode: 'manual', + }), + status: quotation.status === 'converted' ? quotation.status : 'priced', + }, + }) + + await addQuotationChange(quotation.id, actor, 'tariff-selection', `Tariff selected manually: ${tariff.name}`, { + tariffReferenceUuid: tariff.uuid, + }) + + const updated = await loadQuotationOrThrow(quotation.uuid, access.teamUuid) + return mapQuotation(updated) + }, + + createOrderFromQuotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { + const access = assertTeamAccess(ctx) + const actor = { userId: access.userId, label: actorLabel(access) } + const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) + + const existingOrder = await prisma.order.findFirst({ + where: { + quotationId: quotation.id, + teamUuid: access.teamUuid, + }, + include: orderInclude, + }) + + if (existingOrder) { + return mapLocalOrder(existingOrder) } + + if (!quotation.selectedTariff) { + throw new GraphQLError('Quotation must have a selected tariff before order creation') + } + + const order = await prisma.order.create({ + data: { + teamUuid: access.teamUuid, + quotationId: quotation.id, + createdByUserId: access.userId, + name: quotation.title.startsWith('Order') ? quotation.title : `Order for ${quotation.title}`, + status: 'created', + totalAmount: quotation.totalAmount, + currency: quotation.currency, + sourceLocationUuid: quotation.sourceLocationUuid, + sourceLocationName: quotation.sourceLocationName, + sourceCountryCode: quotation.sourceCountryCode, + sourceLatitude: quotation.sourceLatitude, + sourceLongitude: quotation.sourceLongitude, + destinationLocationUuid: quotation.destinationLocationUuid, + destinationLocationName: quotation.destinationLocationName, + destinationCountryCode: quotation.destinationCountryCode, + destinationLatitude: quotation.destinationLatitude, + destinationLongitude: quotation.destinationLongitude, + etaDays: quotation.etaDays, + notes: quotation.notes, + }, + include: orderInclude, + }) + + await prisma.quotation.update({ + where: { + id: quotation.id, + }, + data: { + status: 'converted', + }, + }) + + await addQuotationChange(quotation.id, actor, 'order', 'Order created from quotation', { + orderUuid: order.uuid, + }) + + return mapLocalOrder(order) }, }, }