Compare commits
6 Commits
delete_com
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a54940424 | ||
|
|
ce8a1049dd | ||
|
|
9c511c0619 | ||
|
|
9a79777e34 | ||
|
|
d65cb721b5 | ||
|
|
b7f85c9bf9 |
840
apps/backend/apps/frontend/package-lock.json
generated
840
apps/backend/apps/frontend/package-lock.json
generated
@ -1,840 +0,0 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tiptap/extension-color": "^3.23.1",
|
||||
"@tiptap/extension-highlight": "^3.23.1",
|
||||
"@tiptap/extension-image": "^3.23.1",
|
||||
"@tiptap/extension-link": "^3.23.1",
|
||||
"@tiptap/extension-placeholder": "^3.23.1",
|
||||
"@tiptap/extension-text-align": "^3.23.1",
|
||||
"@tiptap/extension-text-style": "^3.23.1",
|
||||
"@tiptap/extension-underline": "^3.23.1",
|
||||
"@tiptap/react": "^3.23.1",
|
||||
"@tiptap/starter-kit": "^3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.1.tgz",
|
||||
"integrity": "sha512-8YvSGiJTeU5wPuGiYIIYgyiyaaT1CAx+kJL0bju0w871OvbJJj0T/ywhcmxGXW6pOal2T8X2xt9ZqE+vib0VJw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.1.tgz",
|
||||
"integrity": "sha512-FdVZLZOkL06j3WLXOC2UeX7++Cj3qI2vfohruMJiz4vk1Q5UUH7G4+AykFzjzBJHrdEpkiRUkRpU1KZIWdbluw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.1.tgz",
|
||||
"integrity": "sha512-EAYdNzyOjlQh2VBY1EhdxtiTjVMaOAD6P0ezms60dKRjd4oj/8grfXfUqwgo4NVdFb11Ks85vXoHuXJSylfR4A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.1.tgz",
|
||||
"integrity": "sha512-1advMCpPkHD/3ucZhYmNau8B4tF0L6iRAFhUOglp5bBZDuq13+rYujh3cm4vFmjH9KqThzpcUDn+ZU2c+mTMyw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1",
|
||||
"@tiptap/pm": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.1.tgz",
|
||||
"integrity": "sha512-owWnBBI4t+jqVDY0naDjhsAmrNGldh4czouef2K+mEf032B7uGsDVCwKp1qaX1JZesyYDfvXOaIwT22hNID2mw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.1.tgz",
|
||||
"integrity": "sha512-nGuhb4YghgTfkejwWHrD9GSpwcC5kkVmm2sN/UY4yceDw+PkyysYKJWZehRLTOC8GNgSAhq/EeQeq14Xwk6dyg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.1.tgz",
|
||||
"integrity": "sha512-BdJGqM57CsKgYrQUZz78vIG8Yn7EpsE2pA7iKn5tYoSXpYtt0IaU4qB1heH7lwWD/vVCAm0YQVD7/0F+0++yhA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1",
|
||||
"@tiptap/pm": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-color": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.23.1.tgz",
|
||||
"integrity": "sha512-OYk/fT3h8Bz4B6GUVTQDvKGpPnpI5d6QHkuqjVhdFsgH3oo58PdLE1TdIGgeavuYPLaFxgBtEXmm3oTY9jPWxw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-text-style": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.1.tgz",
|
||||
"integrity": "sha512-NA5Rx59HRwG6Hb6LwLpC5lE7z6vCj6f90S7RNNsnE+CyiXNR/OhY2BcjuxiGnascHvsnsAbvxGU3ymKMDgvDVg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.1.tgz",
|
||||
"integrity": "sha512-WRN7e/h9m3uI5j9/+L6jcPhHbTL6aKxfFfQWZHNf5M8TqSL1P+/2h034td0XMj3n48i4fWyzjVUV9+sz6t2fDw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.1.tgz",
|
||||
"integrity": "sha512-XrYHpLn1DpLFSGTko9F9xgbNamL6fGpWkK4wqgwPVbg/SJwQCDO/9p5D3DtJTwD+xgw4sQ9as4O6rt6jx8JT+Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.23.1",
|
||||
"@tiptap/pm": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.1.tgz",
|
||||
"integrity": "sha512-E4hB0xquUpEXy7kboLBazrFyRCsN0j0fsTFR8udgQf5xetAVPhOexSTKuzOcU/n0kxsKJin7laYYEag/Fd2KNw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.1.tgz",
|
||||
"integrity": "sha512-XYkCKC5RVqMmmBk+nd22/6IDDx1OC54sdStH5VEHtfOrarriO0JztK8Mr0TijPPk9N4rKXsmndYZM2xyWZZytQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.1.tgz",
|
||||
"integrity": "sha512-1z9yCSp8fevgX3r/4kWXO3of0WFCQWfYjWfHANvoJ4JQTYBkARjXlj1tbk5rrAJBFDDfKRkUpZOurXKgGo+h+g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-highlight": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.23.1.tgz",
|
||||
"integrity": "sha512-Bf5u5KBb6SLveKzPgUEbHoU5uWTUcEvSSZr/mpQZpvCpE6MlXZbvengmFj1OkKnrNZWg0Um2p9e6zKnZHH07sA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.1.tgz",
|
||||
"integrity": "sha512-30XUHXdEZxcz1FCWjz9HW2EEq06NQcAye6rXGnvHo6Y60iJ6MRsrX5byvceFNF9DTVtOIcUFBQ/psIiRcoi0KA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1",
|
||||
"@tiptap/pm": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-image": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.23.1.tgz",
|
||||
"integrity": "sha512-rAyfh8HS0PfXS8PKl1VQUiDFzXtF5SlrILpOPmz+4Oc4pmI+/vN+ain4z8k6HRxWM03YVpvLvyeQ0OFwi/fq3A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.1.tgz",
|
||||
"integrity": "sha512-lZB9YCjoVNDoPMguya66nBvaS/2YpGN5iAcjAGx/JQkCAZeOAtl9+ALMzbWPKH6tQP6m98YtkY1T7RXr++T0bA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.1.tgz",
|
||||
"integrity": "sha512-uOeyLqYQI0WG62agpFG24kVHSn3Z48gD8Y0uLLJbtzh/nDFC3d9So2sQGWlSVyMzsgkJ4k/9jNnxxsVO8qgJOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1",
|
||||
"@tiptap/pm": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.1.tgz",
|
||||
"integrity": "sha512-v1AeXPpagslgRZdOp7WdjCoO4TjjNP8RM2R6Gqx0/inGaNXnM8zCMshOxZlAb03Ad7kq/4RGJmkpM/Jjsi6dEQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1",
|
||||
"@tiptap/pm": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.1.tgz",
|
||||
"integrity": "sha512-Fk/884un5OSLCFxe2TbOmfp3sLMB5b76CnMjaSrvgfiaZnsV2WlJZGPXxCAPbxNIATTykNlSBsVuMBO7we64Vg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.1.tgz",
|
||||
"integrity": "sha512-sHbE5sxiJzhgGn94GUAzD4qKM9SyImBrOlAGS/EIe+pausjqQE7xi+YW0gRo2jG+gXhSYl4/oAGXQXzmSInSUQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.1.tgz",
|
||||
"integrity": "sha512-3GG7YFhVJWw/HWmRxvMMUC296x7TPBQRLsH4ryEC1SMAmVJnbTIvetyvIcLqLEXGW7Rj41S7SO8qjOXVceSOTA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.1.tgz",
|
||||
"integrity": "sha512-GC7b6yAjASl1q9sNkPmukZmVYMfxx03EEhpMMrLYJY9GBz82Ald927yYQsOqf2aKA/Rjo/aZMYCGtjXkGk6aBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-placeholder": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.1.tgz",
|
||||
"integrity": "sha512-eKmQhDt+51GIPxcFXoT8wmS+ejt6evIiHtXBgsLaABG6wg9GHnpGEndPcXsDGVR1s5ZLawVB52PFCX5GqKoWlQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.1.tgz",
|
||||
"integrity": "sha512-+R5LG0ZW9SDZc4weA79uq6uUduVsCEph9tRcoQCRA82IVIiPYSTxTLew9odalmk/Mc7vdZvOK5jjtO5jUVw/rg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.1.tgz",
|
||||
"integrity": "sha512-k1Ki9bBV6mLz1mFP+Laqh1YHJ2MY0P8XzaMqpkgMndEBIJQ3XcpWQc5bfAlRnYcOI9ZXDbAgQ8CwgArxHmQWCQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-align": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.23.1.tgz",
|
||||
"integrity": "sha512-ap4ZN31v57mVX2P+0OoW5iO+ehsUNe0C5MgF/Ta2F/HRmTCc1M1mFqYUCk8zJYX1TFRV18vqK2j6STRBk0R8ng==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-style": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.23.1.tgz",
|
||||
"integrity": "sha512-q3GQQo+lBhrtNkqdbhYWnv/byG/RYAxVnNhYPQMubRzavGdXBU8NhpJ/47YYjPimG1sahzcs2aqy7amVd8ri/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.1.tgz",
|
||||
"integrity": "sha512-+PvHyVozHyxJ9oWCIQx5JHBZ7LAa/sFJUOFaKyfmel4gL9AbP52MmvrciXARlZHd1WCULJtdbLan0+x5/D/9hQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.1.tgz",
|
||||
"integrity": "sha512-7UIn+idaVTVhdlP0KmgzBh8Csmwck357Dq4te5DuAxhSkN1gsXHlq39mpx907UYKJdSOgd+GMFeyOziPwSmbOQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1",
|
||||
"@tiptap/pm": "3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.1.tgz",
|
||||
"integrity": "sha512-8G+TkNsUHHAAJYREpA6fw+Dw/m2Y3Go4/QMQM8RYepid+wTeE1wSv7sBA/CBrphhYmJSWeTyCPtgQIxnTJXMCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-model": "^1.24.1",
|
||||
"prosemirror-schema-list": "^1.5.0",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.38.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.1.tgz",
|
||||
"integrity": "sha512-43zUwKOcsxRIcgiDbcEUagojhPIez2OIryaNG/uiDcRzkrUteiTu2wSJndkQqwouwh3wJEm+KOw8xybNYvU+qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-equals": "^5.3.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.23.1",
|
||||
"@tiptap/extension-floating-menu": "^3.23.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.1",
|
||||
"@tiptap/pm": "3.23.1",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.1.tgz",
|
||||
"integrity": "sha512-CURePHQagBaZIDJrHH3of4Nmi0VYGpZ6yBlkdFxFHBxY9aeG2/h5kn+oHo8GbzkSFsRV+9olzRgDTOULVgs8pQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.23.1",
|
||||
"@tiptap/extension-blockquote": "^3.23.1",
|
||||
"@tiptap/extension-bold": "^3.23.1",
|
||||
"@tiptap/extension-bullet-list": "^3.23.1",
|
||||
"@tiptap/extension-code": "^3.23.1",
|
||||
"@tiptap/extension-code-block": "^3.23.1",
|
||||
"@tiptap/extension-document": "^3.23.1",
|
||||
"@tiptap/extension-dropcursor": "^3.23.1",
|
||||
"@tiptap/extension-gapcursor": "^3.23.1",
|
||||
"@tiptap/extension-hard-break": "^3.23.1",
|
||||
"@tiptap/extension-heading": "^3.23.1",
|
||||
"@tiptap/extension-horizontal-rule": "^3.23.1",
|
||||
"@tiptap/extension-italic": "^3.23.1",
|
||||
"@tiptap/extension-link": "^3.23.1",
|
||||
"@tiptap/extension-list": "^3.23.1",
|
||||
"@tiptap/extension-list-item": "^3.23.1",
|
||||
"@tiptap/extension-list-keymap": "^3.23.1",
|
||||
"@tiptap/extension-ordered-list": "^3.23.1",
|
||||
"@tiptap/extension-paragraph": "^3.23.1",
|
||||
"@tiptap/extension-strike": "^3.23.1",
|
||||
"@tiptap/extension-text": "^3.23.1",
|
||||
"@tiptap/extension-underline": "^3.23.1",
|
||||
"@tiptap/extensions": "^3.23.1",
|
||||
"@tiptap/pm": "^3.23.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.41.8",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tiptap/extension-color": "^3.23.1",
|
||||
"@tiptap/extension-highlight": "^3.23.1",
|
||||
"@tiptap/extension-image": "^3.23.1",
|
||||
"@tiptap/extension-link": "^3.23.1",
|
||||
"@tiptap/extension-placeholder": "^3.23.1",
|
||||
"@tiptap/extension-text-align": "^3.23.1",
|
||||
"@tiptap/extension-text-style": "^3.23.1",
|
||||
"@tiptap/extension-underline": "^3.23.1",
|
||||
"@tiptap/react": "^3.23.1",
|
||||
"@tiptap/starter-kit": "^3.23.1"
|
||||
}
|
||||
}
|
||||
@ -78,7 +78,7 @@ async function createTestBooking() {
|
||||
25.5, // volume_cbm
|
||||
3500, // weight_kg
|
||||
10, // pallet_count
|
||||
1850.5, // price_usd
|
||||
1850.50, // price_usd
|
||||
1665.45, // price_eur
|
||||
'USD', // primary_currency
|
||||
28, // transit_days
|
||||
@ -102,6 +102,7 @@ async function createTestBooking() {
|
||||
console.log('\n📧 URL API (pour curl):');
|
||||
console.log(` curl http://localhost:4000/api/v1/csv-bookings/accept/${confirmationToken}`);
|
||||
console.log('\n✅ Ce booking est en statut PENDING et peut être accepté/refusé.\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur:', error.message);
|
||||
console.error(error);
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
require('dotenv').config();
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
console.log("\n🔍 DEBUG - Flux d'envoi d'email transporteur\n");
|
||||
console.log('\n🔍 DEBUG - Flux d\'envoi d\'email transporteur\n');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// 1. Afficher la configuration
|
||||
@ -20,10 +20,7 @@ console.log('SMTP_HOST:', process.env.SMTP_HOST);
|
||||
console.log('SMTP_PORT:', process.env.SMTP_PORT);
|
||||
console.log('SMTP_SECURE:', process.env.SMTP_SECURE);
|
||||
console.log('SMTP_USER:', process.env.SMTP_USER);
|
||||
console.log(
|
||||
'SMTP_PASS:',
|
||||
process.env.SMTP_PASS ? '***' + process.env.SMTP_PASS.slice(-4) : 'NON DÉFINI'
|
||||
);
|
||||
console.log('SMTP_PASS:', process.env.SMTP_PASS ? '***' + process.env.SMTP_PASS.slice(-4) : 'NON DÉFINI');
|
||||
console.log('SMTP_FROM:', process.env.SMTP_FROM);
|
||||
console.log('APP_URL:', process.env.APP_URL);
|
||||
|
||||
@ -123,7 +120,7 @@ async function sendSimpleEmail() {
|
||||
console.log(' Rejected:', info.rejected);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ Échec d'envoi email simple:");
|
||||
console.error('❌ Échec d\'envoi email simple:');
|
||||
console.error(' Message:', error.message);
|
||||
console.error(' Code:', error.code);
|
||||
return false;
|
||||
@ -257,7 +254,7 @@ async function sendCarrierEmail() {
|
||||
console.log(' Sujet: Nouvelle demande de réservation - FRPAR → USNYC');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("\n❌ Échec d'envoi email transporteur:");
|
||||
console.error('\n❌ Échec d\'envoi email transporteur:');
|
||||
console.error(' Message:', error.message);
|
||||
console.error(' Code:', error.code);
|
||||
console.error(' ResponseCode:', error.responseCode);
|
||||
@ -285,7 +282,7 @@ async function runAllTests() {
|
||||
// Test 2: Email simple
|
||||
const simpleEmailOk = await sendSimpleEmail();
|
||||
if (!simpleEmailOk) {
|
||||
console.log("\n⚠️ L'email simple a échoué, mais on continue...");
|
||||
console.log('\n⚠️ L\'email simple a échoué, mais on continue...');
|
||||
}
|
||||
|
||||
// Test 3: Email transporteur
|
||||
@ -301,9 +298,9 @@ async function runAllTests() {
|
||||
|
||||
if (connectionOk && simpleEmailOk && carrierEmailOk) {
|
||||
console.log('\n✅ TOUS LES TESTS ONT RÉUSSI!');
|
||||
console.log(" Le système d'envoi d'email fonctionne correctement.");
|
||||
console.log(' Le système d\'envoi d\'email fonctionne correctement.');
|
||||
console.log(' Si vous ne recevez pas les emails dans le backend,');
|
||||
console.log(" le problème vient de l'intégration NestJS.");
|
||||
console.log(' le problème vient de l\'intégration NestJS.');
|
||||
} else {
|
||||
console.log('\n❌ CERTAINS TESTS ONT ÉCHOUÉ');
|
||||
console.log(' Vérifiez les erreurs ci-dessus pour comprendre le problème.');
|
||||
|
||||
@ -100,7 +100,7 @@ deleteTestDocuments()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -14,7 +14,7 @@ function fixImportsInFile(filePath) {
|
||||
|
||||
// Replace relative imports to ../ports/ with @domain/ports/
|
||||
modified = modified.replace(/from ['"]\.\.\/ports\//g, "from '@domain/ports/");
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/ports\//g, 'import $1@domain/ports/');
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/ports\//g, "import $1@domain/ports/");
|
||||
|
||||
if (modified !== content) {
|
||||
fs.writeFileSync(filePath, modified, 'utf8');
|
||||
|
||||
@ -37,7 +37,7 @@ async function fixDummyUrls() {
|
||||
const documents = row.documents;
|
||||
|
||||
// Update each document URL
|
||||
const updatedDocuments = documents.map(doc => {
|
||||
const updatedDocuments = documents.map((doc) => {
|
||||
if (doc.filePath && doc.filePath.includes('dummy-storage')) {
|
||||
// Extract filename from dummy URL
|
||||
const fileName = doc.fileName || doc.filePath.split('/').pop();
|
||||
@ -58,10 +58,10 @@ async function fixDummyUrls() {
|
||||
});
|
||||
|
||||
// Update the database
|
||||
await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
|
||||
JSON.stringify(updatedDocuments),
|
||||
bookingId,
|
||||
]);
|
||||
await client.query(
|
||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
||||
[JSON.stringify(updatedDocuments), bookingId]
|
||||
);
|
||||
|
||||
updatedCount++;
|
||||
console.log(`✅ Updated booking ${bookingId}\n`);
|
||||
@ -84,7 +84,7 @@ fixDummyUrls()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -24,13 +24,10 @@ function fixImportsInFile(filePath) {
|
||||
modified = modified.replace(/from ['"]\.\.\/domain\//g, "from '@domain/");
|
||||
|
||||
// Also fix import statements (not just from)
|
||||
modified = modified.replace(
|
||||
/import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g,
|
||||
'import $1@domain/'
|
||||
);
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, 'import $1@domain/');
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, 'import $1@domain/');
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, 'import $1@domain/');
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/");
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/");
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, "import $1@domain/");
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, "import $1@domain/");
|
||||
|
||||
if (modified !== content) {
|
||||
fs.writeFileSync(filePath, modified, 'utf8');
|
||||
|
||||
@ -34,7 +34,7 @@ async function fixMinioHostname() {
|
||||
const documents = row.documents;
|
||||
|
||||
// Update each document URL
|
||||
const updatedDocuments = documents.map(doc => {
|
||||
const updatedDocuments = documents.map((doc) => {
|
||||
if (doc.filePath && doc.filePath.includes('http://minio:9000')) {
|
||||
const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000');
|
||||
|
||||
@ -51,10 +51,10 @@ async function fixMinioHostname() {
|
||||
});
|
||||
|
||||
// Update the database
|
||||
await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
|
||||
JSON.stringify(updatedDocuments),
|
||||
bookingId,
|
||||
]);
|
||||
await client.query(
|
||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
||||
[JSON.stringify(updatedDocuments), bookingId]
|
||||
);
|
||||
|
||||
updatedCount++;
|
||||
console.log(`✅ Updated booking ${bookingId}\n`);
|
||||
@ -75,7 +75,7 @@ fixMinioHostname()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -86,7 +86,7 @@ listFiles()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -9,21 +9,18 @@ async function loginAndTestEmail() {
|
||||
console.log('🔐 Connexion...');
|
||||
const loginResponse = await axios.post(`${API_URL}/auth/login`, {
|
||||
email: 'admin@xpeditis.com',
|
||||
password: 'Admin123!@#',
|
||||
password: 'Admin123!@#'
|
||||
});
|
||||
|
||||
const token = loginResponse.data.accessToken;
|
||||
console.log('✅ Connecté avec succès\n');
|
||||
|
||||
// 2. Créer un CSV booking pour tester l'envoi d'email
|
||||
console.log("📧 Création d'une CSV booking pour tester l'envoi d'email...");
|
||||
console.log('📧 Création d\'une CSV booking pour tester l\'envoi d\'email...');
|
||||
|
||||
const form = new FormData();
|
||||
const testFile = Buffer.from('Test document PDF content');
|
||||
form.append('documents', testFile, {
|
||||
filename: 'test-doc.pdf',
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
form.append('documents', testFile, { filename: 'test-doc.pdf', contentType: 'application/pdf' });
|
||||
|
||||
form.append('carrierName', 'Test Carrier');
|
||||
form.append('carrierEmail', 'testcarrier@example.com');
|
||||
@ -42,8 +39,8 @@ async function loginAndTestEmail() {
|
||||
const bookingResponse = await axios.post(`${API_URL}/csv-bookings`, form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ CSV Booking créé:', bookingResponse.data.id);
|
||||
@ -53,6 +50,7 @@ async function loginAndTestEmail() {
|
||||
console.log('2. Vérifier Mailtrap inbox: https://mailtrap.io/inboxes');
|
||||
console.log('3. Email devrait être envoyé à: testcarrier@example.com');
|
||||
console.log('\n⏳ Attendez quelques secondes puis vérifiez les logs du backend...');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ERREUR:');
|
||||
if (error.response) {
|
||||
|
||||
@ -6,8 +6,6 @@
|
||||
"deleteOutDir": true,
|
||||
"builder": "tsc",
|
||||
"tsConfigPath": "tsconfig.build.json",
|
||||
"plugins": ["@nestjs/swagger"],
|
||||
"assets": [{ "include": "i18n/**/*.json", "outDir": "dist" }],
|
||||
"watchAssets": true
|
||||
"plugins": ["@nestjs/swagger"]
|
||||
}
|
||||
}
|
||||
|
||||
41
apps/backend/package-lock.json
generated
41
apps/backend/package-lock.json
generated
@ -43,7 +43,6 @@
|
||||
"joi": "^17.11.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"mjml": "^4.16.1",
|
||||
"nestjs-i18n": "^10.6.5",
|
||||
"nestjs-pino": "^4.4.1",
|
||||
"nodemailer": "^7.0.9",
|
||||
"opossum": "^8.1.3",
|
||||
@ -5762,12 +5761,6 @@
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accept-language-parser": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz",
|
||||
"integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@ -12176,34 +12169,6 @@
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nestjs-i18n": {
|
||||
"version": "10.6.5",
|
||||
"resolved": "https://registry.npmjs.org/nestjs-i18n/-/nestjs-i18n-10.6.5.tgz",
|
||||
"integrity": "sha512-jqbZ+H7LMEfAVYqS1FM0YfZjzPDwZQq97NE4BBIfPpxzAhlfnPzaQDGpNkPE/5Ft+rawtNJOuuuaWMpDhSLwaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accept-language-parser": "^1.5.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"cookie": "^0.7.0",
|
||||
"iterare": "^1.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"string-format": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "*",
|
||||
"@nestjs/core": "*",
|
||||
"class-validator": "*",
|
||||
"rxjs": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"class-validator": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nestjs-pino": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.4.1.tgz",
|
||||
@ -14507,12 +14472,6 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-format": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz",
|
||||
"integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==",
|
||||
"license": "WTFPL OR MIT"
|
||||
},
|
||||
"node_modules/string-length": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
||||
|
||||
@ -59,7 +59,6 @@
|
||||
"joi": "^17.11.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"mjml": "^4.16.1",
|
||||
"nestjs-i18n": "^10.6.5",
|
||||
"nestjs-pino": "^4.4.1",
|
||||
"nodemailer": "^7.0.9",
|
||||
"opossum": "^8.1.3",
|
||||
|
||||
@ -120,17 +120,11 @@ async function restoreDocumentReferences() {
|
||||
|
||||
// Determine document type
|
||||
let docType = 'OTHER';
|
||||
if (
|
||||
file.fileName.toLowerCase().includes('bill-of-lading') ||
|
||||
file.fileName.toLowerCase().includes('bol')
|
||||
) {
|
||||
if (file.fileName.toLowerCase().includes('bill-of-lading') || file.fileName.toLowerCase().includes('bol')) {
|
||||
docType = 'BILL_OF_LADING';
|
||||
} else if (file.fileName.toLowerCase().includes('packing-list')) {
|
||||
docType = 'PACKING_LIST';
|
||||
} else if (
|
||||
file.fileName.toLowerCase().includes('commercial-invoice') ||
|
||||
file.fileName.toLowerCase().includes('invoice')
|
||||
) {
|
||||
} else if (file.fileName.toLowerCase().includes('commercial-invoice') || file.fileName.toLowerCase().includes('invoice')) {
|
||||
docType = 'COMMERCIAL_INVOICE';
|
||||
}
|
||||
|
||||
@ -149,10 +143,10 @@ async function restoreDocumentReferences() {
|
||||
});
|
||||
|
||||
// Update the booking with new document references
|
||||
await pgClient.query('UPDATE csv_bookings SET documents = $1 WHERE id = $2', [
|
||||
JSON.stringify(newDocuments),
|
||||
bookingId,
|
||||
]);
|
||||
await pgClient.query(
|
||||
'UPDATE csv_bookings SET documents = $1 WHERE id = $2',
|
||||
[JSON.stringify(newDocuments), bookingId]
|
||||
);
|
||||
|
||||
updatedCount++;
|
||||
createdDocsCount += newDocuments.length;
|
||||
@ -176,7 +170,7 @@ restoreDocumentReferences()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -28,7 +28,7 @@ AppDataSource.initialize()
|
||||
console.log('✅ No pending migrations');
|
||||
} else {
|
||||
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
|
||||
migrations.forEach(migration => {
|
||||
migrations.forEach((migration) => {
|
||||
console.log(` - ${migration.name}`);
|
||||
});
|
||||
}
|
||||
@ -37,7 +37,7 @@ AppDataSource.initialize()
|
||||
console.log('✅ Database migrations completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('❌ Error during migration:');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
|
||||
@ -210,7 +210,10 @@ function parseSeaPorts(filePath: string): ParsedPort[] {
|
||||
|
||||
// Validate coordinates
|
||||
const [longitude, latitude] = port.coordinates;
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
if (
|
||||
latitude < -90 || latitude > 90 ||
|
||||
longitude < -180 || longitude > 180
|
||||
) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@ -241,8 +244,7 @@ function generateSQLInserts(ports: ParsedPort[]): string {
|
||||
|
||||
for (let i = 0; i < ports.length; i += batchSize) {
|
||||
const batch = ports.slice(i, i + batchSize);
|
||||
const values = batch
|
||||
.map(port => {
|
||||
const values = batch.map(port => {
|
||||
const name = port.name.replace(/'/g, "''");
|
||||
const city = port.city.replace(/'/g, "''");
|
||||
const countryName = port.countryName.replace(/'/g, "''");
|
||||
@ -259,8 +261,7 @@ function generateSQLInserts(ports: ParsedPort[]): string {
|
||||
${timezone},
|
||||
${port.isActive}
|
||||
)`;
|
||||
})
|
||||
.join(',\n ');
|
||||
}).join(',\n ');
|
||||
|
||||
batches.push(`
|
||||
// Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(ports.length / batchSize)} (${batch.length} ports)
|
||||
@ -320,9 +321,7 @@ async function main() {
|
||||
if (!fs.existsSync(seaPortsPath)) {
|
||||
console.error('❌ Error: /tmp/sea-ports.json not found!');
|
||||
console.log('Please download it first:');
|
||||
console.log(
|
||||
'curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json'
|
||||
);
|
||||
console.log('curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@ -343,10 +342,7 @@ async function main() {
|
||||
const migrationContent = generateMigration(ports);
|
||||
|
||||
// Write migration file
|
||||
const migrationsDir = path.join(
|
||||
__dirname,
|
||||
'../src/infrastructure/persistence/typeorm/migrations'
|
||||
);
|
||||
const migrationsDir = path.join(__dirname, '../src/infrastructure/persistence/typeorm/migrations');
|
||||
const timestamp = Date.now();
|
||||
const fileName = `${timestamp}-SeedPorts.ts`;
|
||||
const filePath = path.join(migrationsDir, fileName);
|
||||
|
||||
@ -5,10 +5,7 @@
|
||||
|
||||
const Stripe = require('stripe');
|
||||
|
||||
const stripe = new Stripe(
|
||||
process.env.STRIPE_SECRET_KEY ||
|
||||
'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr'
|
||||
);
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr');
|
||||
|
||||
async function listPrices() {
|
||||
console.log('Fetching Stripe prices...\n');
|
||||
@ -49,6 +46,7 @@ async function listPrices() {
|
||||
console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx');
|
||||
console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
|
||||
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching prices:', error.message);
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ setBucketPolicy()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -3,16 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import {
|
||||
AcceptLanguageResolver,
|
||||
CookieResolver,
|
||||
HeaderResolver,
|
||||
I18nModule,
|
||||
QueryResolver,
|
||||
} from 'nestjs-i18n';
|
||||
import * as path from 'path';
|
||||
import * as Joi from 'joi';
|
||||
import { UserPreferenceResolver } from './infrastructure/i18n/user-preference.resolver';
|
||||
|
||||
// Import feature modules
|
||||
import { AuthModule } from './application/auth/auth.module';
|
||||
@ -28,7 +19,6 @@ import { WebhooksModule } from './application/webhooks/webhooks.module';
|
||||
import { GDPRModule } from './application/gdpr/gdpr.module';
|
||||
import { CsvBookingsModule } from './application/csv-bookings.module';
|
||||
import { AdminModule } from './application/admin/admin.module';
|
||||
import { BlogModule } from './application/blog/blog.module';
|
||||
import { LogsModule } from './application/logs/logs.module';
|
||||
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
|
||||
import { ApiKeysModule } from './application/api-keys/api-keys.module';
|
||||
@ -120,29 +110,6 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// Internationalization (FR / EN)
|
||||
// Resolver chain (highest priority first):
|
||||
// 1. UserPreferenceResolver — authenticated user's preferredLanguage
|
||||
// 2. CookieResolver (NEXT_LOCALE) — set by frontend switcher
|
||||
// 3. HeaderResolver (x-lang / x-locale)
|
||||
// 4. QueryResolver (?lang=xx)
|
||||
// 5. AcceptLanguageResolver
|
||||
// 6. fallback → 'fr'
|
||||
I18nModule.forRoot({
|
||||
fallbackLanguage: 'fr',
|
||||
loaderOptions: {
|
||||
path: path.join(__dirname, '/i18n/'),
|
||||
watch: true,
|
||||
},
|
||||
resolvers: [
|
||||
UserPreferenceResolver,
|
||||
new CookieResolver(['NEXT_LOCALE', 'lang']),
|
||||
new HeaderResolver(['x-lang', 'x-locale']),
|
||||
new QueryResolver(['lang', 'locale']),
|
||||
AcceptLanguageResolver,
|
||||
],
|
||||
}),
|
||||
|
||||
// Database
|
||||
TypeOrmModule.forRootAsync({
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
@ -180,7 +147,6 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
WebhooksModule,
|
||||
GDPRModule,
|
||||
AdminModule,
|
||||
BlogModule,
|
||||
SubscriptionsModule,
|
||||
ApiKeysModule,
|
||||
LogsModule,
|
||||
|
||||
@ -29,20 +29,18 @@ import { CsvBookingsModule } from '../csv-bookings.module';
|
||||
// Email
|
||||
import { EmailModule } from '@infrastructure/email/email.module';
|
||||
|
||||
// Blog
|
||||
import { BlogModule } from '../blog/blog.module';
|
||||
|
||||
// Storage
|
||||
import { StorageModule } from '@infrastructure/storage/storage.module';
|
||||
|
||||
/**
|
||||
* Admin Module
|
||||
*
|
||||
* Provides admin-only endpoints for managing all data in the system.
|
||||
* All endpoints require ADMIN role.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
|
||||
ConfigModule,
|
||||
CsvBookingsModule,
|
||||
EmailModule,
|
||||
BlogModule,
|
||||
StorageModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
|
||||
@ -10,7 +10,13 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||
import { RequiresFeature } from '../decorators/requires-feature.decorator';
|
||||
@ -32,7 +38,7 @@ export class ApiKeysController {
|
||||
@ApiOperation({
|
||||
summary: 'Générer une nouvelle clé API',
|
||||
description:
|
||||
'Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.',
|
||||
"Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
|
||||
@ -23,7 +23,10 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]), SubscriptionsModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]),
|
||||
SubscriptionsModule,
|
||||
],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [
|
||||
ApiKeysService,
|
||||
|
||||
@ -8,7 +8,13 @@
|
||||
* - Validation for inbound API key authentication
|
||||
*/
|
||||
|
||||
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
|
||||
@ -41,12 +41,7 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||
}),
|
||||
|
||||
// 👇 Add this to register TypeORM repositories
|
||||
TypeOrmModule.forFeature([
|
||||
UserOrmEntity,
|
||||
OrganizationOrmEntity,
|
||||
InvitationTokenOrmEntity,
|
||||
PasswordResetTokenOrmEntity,
|
||||
]),
|
||||
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity, PasswordResetTokenOrmEntity]),
|
||||
|
||||
// Email module for sending invitations
|
||||
EmailModule,
|
||||
|
||||
@ -265,9 +265,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (resetToken.expiresAt < new Date()) {
|
||||
throw new BadRequestException(
|
||||
'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.'
|
||||
);
|
||||
throw new BadRequestException('Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findById(resetToken.userId);
|
||||
@ -288,7 +286,10 @@ export class AuthService {
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// Mark token as used
|
||||
await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() });
|
||||
await this.passwordResetTokenRepository.update(
|
||||
{ id: resetToken.id },
|
||||
{ usedAt: new Date() }
|
||||
);
|
||||
|
||||
this.logger.log(`Password reset successfully for user: ${user.email}`);
|
||||
}
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BlogController } from '../controllers/blog.controller';
|
||||
import { BlogService } from '../services/blog.service';
|
||||
import { BlogPostOrmEntity } from '../../infrastructure/persistence/typeorm/entities/blog-post.orm-entity';
|
||||
import { TypeOrmBlogPostRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository';
|
||||
import { BLOG_POST_REPOSITORY } from '@domain/ports/out/blog-post.repository';
|
||||
import { StorageModule } from '../../infrastructure/storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BlogPostOrmEntity]), StorageModule],
|
||||
controllers: [BlogController],
|
||||
providers: [
|
||||
BlogService,
|
||||
{
|
||||
provide: BLOG_POST_REPOSITORY,
|
||||
useClass: TypeOrmBlogPostRepository,
|
||||
},
|
||||
],
|
||||
exports: [BlogService],
|
||||
})
|
||||
export class BlogModule {}
|
||||
@ -6,7 +6,6 @@ import {
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
@ -16,22 +15,14 @@ import {
|
||||
BadRequestException,
|
||||
ParseUUIDPipe,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiConsumes,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
@ -65,25 +56,6 @@ import {
|
||||
// Email imports
|
||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||
|
||||
// Blog imports
|
||||
import { BlogService } from '../services/blog.service';
|
||||
import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto';
|
||||
import { BlogPost } from '@domain/entities/blog-post.entity';
|
||||
import type { BlogPostCategory } from '@domain/entities/blog-post.entity';
|
||||
|
||||
// Storage imports
|
||||
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
||||
|
||||
const BLOG_IMAGES_BUCKET = 'xpeditis-blog';
|
||||
const ALLOWED_IMAGE_MIMETYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/svg+xml',
|
||||
];
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
/**
|
||||
* Admin Controller
|
||||
*
|
||||
@ -108,9 +80,7 @@ export class AdminController {
|
||||
private readonly csvBookingService: CsvBookingService,
|
||||
@Inject(SIRET_VERIFICATION_PORT)
|
||||
private readonly siretVerificationPort: SiretVerificationPort,
|
||||
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort,
|
||||
private readonly blogService: BlogService,
|
||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort
|
||||
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort
|
||||
) {}
|
||||
|
||||
// ==================== USERS ENDPOINTS ====================
|
||||
@ -774,7 +744,10 @@ export class AdminController {
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Email sent successfully' })
|
||||
@ApiResponse({ status: 400, description: 'SMTP error — check the message field' })
|
||||
async sendTestEmail(@Body() body: { to: string }, @CurrentUser() user: UserPayload) {
|
||||
async sendTestEmail(
|
||||
@Body() body: { to: string },
|
||||
@CurrentUser() user: UserPayload
|
||||
) {
|
||||
if (!body?.to) {
|
||||
throw new BadRequestException('Field "to" is required');
|
||||
}
|
||||
@ -907,9 +880,7 @@ export class AdminController {
|
||||
@Param('documentId', ParseUUIDPipe) documentId: string,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
this.logger.log(
|
||||
`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`
|
||||
);
|
||||
this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`);
|
||||
|
||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
@ -923,9 +894,7 @@ export class AdminController {
|
||||
|
||||
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
|
||||
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } });
|
||||
if (ormBooking) {
|
||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
||||
id: doc.id,
|
||||
@ -942,134 +911,4 @@ export class AdminController {
|
||||
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
|
||||
return { success: true, message: 'Document deleted successfully' };
|
||||
}
|
||||
|
||||
// ==================== BLOG ENDPOINTS ====================
|
||||
|
||||
@Post('blog/images')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('image', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: MAX_IMAGE_SIZE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (ALLOWED_IMAGE_MIMETYPES.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(
|
||||
new BadRequestException('Only image files are allowed (jpg, png, webp, gif, svg)'),
|
||||
false
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload a blog image to storage (Admin only)' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } },
|
||||
})
|
||||
async uploadBlogImage(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<{ url: string; filename: string }> {
|
||||
if (!file) throw new BadRequestException('No image file provided');
|
||||
|
||||
this.logger.log(`[ADMIN: ${user.email}] Uploading blog image: ${file.originalname}`);
|
||||
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const sanitizedName = path
|
||||
.basename(file.originalname, ext)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `${uuidv4()}-${sanitizedName}${ext}`;
|
||||
const key = `blog-images/${filename}`;
|
||||
|
||||
await this.storage.upload({
|
||||
bucket: BLOG_IMAGES_BUCKET,
|
||||
key,
|
||||
body: file.buffer,
|
||||
contentType: file.mimetype,
|
||||
});
|
||||
|
||||
this.logger.log(`[ADMIN] Blog image uploaded: ${key}`);
|
||||
return { url: `/api/v1/blog/images/${filename}`, filename };
|
||||
}
|
||||
|
||||
@Get('blog')
|
||||
@ApiOperation({ summary: 'List all blog posts (Admin only)' })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'category', required: false })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||
async listBlogPosts(
|
||||
@Query('status') status?: any,
|
||||
@Query('category') category?: BlogPostCategory,
|
||||
@Query('search') search?: string,
|
||||
@Query('limit') limit = 50,
|
||||
@Query('offset') offset = 0,
|
||||
@CurrentUser() user?: UserPayload
|
||||
) {
|
||||
this.logger.log(`[ADMIN: ${user?.email}] Listing blog posts`);
|
||||
const { posts, total } = await this.blogService.listAllPosts({
|
||||
status,
|
||||
category,
|
||||
search,
|
||||
limit: Number(limit),
|
||||
offset: Number(offset),
|
||||
});
|
||||
return { posts: posts.map(this.mapBlogPostToDto), total };
|
||||
}
|
||||
|
||||
@Post('blog')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
@ApiOperation({ summary: 'Create a blog post (Admin only)' })
|
||||
async createBlogPost(@Body() dto: CreateBlogPostDto, @CurrentUser() user: UserPayload) {
|
||||
this.logger.log(`[ADMIN: ${user.email}] Creating blog post: ${dto.slug}`);
|
||||
const post = await this.blogService.createPost(dto);
|
||||
return this.mapBlogPostToDto(post);
|
||||
}
|
||||
|
||||
@Patch('blog/:id')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
@ApiOperation({ summary: 'Update a blog post (Admin only)' })
|
||||
async updateBlogPost(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateBlogPostDto,
|
||||
@CurrentUser() user: UserPayload
|
||||
) {
|
||||
this.logger.log(`[ADMIN: ${user.email}] Updating blog post: ${id}`);
|
||||
const post = await this.blogService.updatePost(id, dto);
|
||||
return this.mapBlogPostToDto(post);
|
||||
}
|
||||
|
||||
@Delete('blog/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Delete a blog post (Admin only)' })
|
||||
async deleteBlogPost(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<void> {
|
||||
this.logger.log(`[ADMIN: ${user.email}] Deleting blog post: ${id}`);
|
||||
await this.blogService.deletePost(id);
|
||||
}
|
||||
|
||||
private mapBlogPostToDto(post: BlogPost) {
|
||||
return {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
content: post.content,
|
||||
coverImageUrl: post.coverImageUrl,
|
||||
category: post.category,
|
||||
tags: post.tags,
|
||||
authorName: post.authorName,
|
||||
status: post.status,
|
||||
isFeatured: post.isFeatured,
|
||||
publishedAt: post.publishedAt,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -289,9 +289,7 @@ export class AuthController {
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send contact email: ${error}`);
|
||||
throw new InternalServerErrorException(
|
||||
"Erreur lors de l'envoi du message. Veuillez réessayer."
|
||||
);
|
||||
throw new InternalServerErrorException("Erreur lors de l'envoi du message. Veuillez réessayer.");
|
||||
}
|
||||
|
||||
return { message: 'Message envoyé avec succès.' };
|
||||
|
||||
@ -1,131 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Res,
|
||||
NotFoundException,
|
||||
Inject,
|
||||
Logger,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
import { BlogService } from '../services/blog.service';
|
||||
import { BlogPost } from '@domain/entities/blog-post.entity';
|
||||
import { BlogPostResponseDto, BlogPostListResponseDto } from '../dto/blog-post.dto';
|
||||
import type { BlogPostCategory } from '@domain/entities/blog-post.entity';
|
||||
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
||||
|
||||
const BLOG_IMAGES_BUCKET = 'xpeditis-blog';
|
||||
|
||||
@ApiTags('Blog')
|
||||
@Controller('blog')
|
||||
@Public()
|
||||
export class BlogController {
|
||||
private readonly logger = new Logger(BlogController.name);
|
||||
|
||||
constructor(
|
||||
private readonly blogService: BlogService,
|
||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'List published blog posts' })
|
||||
@ApiQuery({
|
||||
name: 'category',
|
||||
required: false,
|
||||
enum: ['industry', 'technology', 'guides', 'news'],
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||
@ApiResponse({ status: 200, type: BlogPostListResponseDto })
|
||||
async listPosts(
|
||||
@Query('category') category?: BlogPostCategory,
|
||||
@Query('search') search?: string,
|
||||
@Query('limit') limit = 20,
|
||||
@Query('offset') offset = 0
|
||||
): Promise<BlogPostListResponseDto> {
|
||||
const { posts, total } = await this.blogService.listPublishedPosts({
|
||||
category,
|
||||
search,
|
||||
limit: Number(limit),
|
||||
offset: Number(offset),
|
||||
});
|
||||
|
||||
return {
|
||||
posts: posts.map(this.mapToDto),
|
||||
total,
|
||||
limit: Number(limit),
|
||||
offset: Number(offset),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('images/:filename')
|
||||
@ApiOperation({ summary: 'Serve a blog image from storage' })
|
||||
@ApiParam({ name: 'filename' })
|
||||
async serveImage(
|
||||
@Param('filename') filename: string,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
): Promise<StreamableFile> {
|
||||
const key = `blog-images/${filename}`;
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await this.storage.download({ bucket: BLOG_IMAGES_BUCKET, key });
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Failed to serve blog image "${key}": ${err?.message}`);
|
||||
throw new NotFoundException(`Image not found: ${filename}`);
|
||||
}
|
||||
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
const contentType = contentTypeMap[ext] ?? 'application/octet-stream';
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Get(':slug')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Get a published blog post by slug' })
|
||||
@ApiParam({ name: 'slug' })
|
||||
@ApiResponse({ status: 200, type: BlogPostResponseDto })
|
||||
async getPost(@Param('slug') slug: string): Promise<BlogPostResponseDto> {
|
||||
const post = await this.blogService.getPublishedPostBySlug(slug);
|
||||
return this.mapToDto(post);
|
||||
}
|
||||
|
||||
private mapToDto(post: BlogPost): BlogPostResponseDto {
|
||||
return {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
content: post.content,
|
||||
coverImageUrl: post.coverImageUrl,
|
||||
category: post.category,
|
||||
tags: post.tags,
|
||||
authorName: post.authorName,
|
||||
status: post.status,
|
||||
isFeatured: post.isFeatured,
|
||||
publishedAt: post.publishedAt,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -15,8 +15,6 @@ import {
|
||||
HttpStatus,
|
||||
Res,
|
||||
Req,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { Response, Request } from 'express';
|
||||
@ -98,24 +96,6 @@ export class GDPRController {
|
||||
res.send(csv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user can self-delete their account
|
||||
*/
|
||||
@Get('can-self-delete')
|
||||
@ApiOperation({
|
||||
summary: 'Check if account self-deletion is possible',
|
||||
description: 'For managers: returns true only if another manager exists in the organization',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Self-deletion eligibility check' })
|
||||
async canSelfDelete(
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<{ canSelfDelete: boolean; otherManagersCount: number }> {
|
||||
if (user.role !== 'MANAGER') {
|
||||
return { canSelfDelete: true, otherManagersCount: 0 };
|
||||
}
|
||||
return this.gdprService.canManagerSelfDelete(user.id, user.organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user data (GDPR Right to Erasure)
|
||||
*/
|
||||
@ -127,57 +107,20 @@ export class GDPRController {
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 204,
|
||||
description: 'Account deleted',
|
||||
description: 'Account deletion initiated',
|
||||
})
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: { reason?: string; confirmEmail: string }
|
||||
): Promise<void> {
|
||||
// Verify email confirmation (security measure)
|
||||
if (body.confirmEmail !== user.email) {
|
||||
throw new BadRequestException('Email confirmation does not match');
|
||||
}
|
||||
|
||||
if (user.role === 'MANAGER') {
|
||||
const { canSelfDelete } = await this.gdprService.canManagerSelfDelete(
|
||||
user.id,
|
||||
user.organizationId
|
||||
);
|
||||
if (!canSelfDelete) {
|
||||
throw new ForbiddenException(
|
||||
'You are the only manager in your organization. Delete the entire organization or assign another manager first.'
|
||||
);
|
||||
}
|
||||
throw new Error('Email confirmation does not match');
|
||||
}
|
||||
|
||||
await this.gdprService.deleteUserData(user.id, body.reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete entire organization and all its data (manager only)
|
||||
*/
|
||||
@Delete('delete-organization')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: 'Delete organization and all its data',
|
||||
description:
|
||||
'Permanently deletes the organization, all its users, and all associated bookings. Manager only.',
|
||||
})
|
||||
@ApiResponse({ status: 204, description: 'Organization deleted' })
|
||||
async deleteOrganization(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: { reason?: string; confirmEmail: string }
|
||||
): Promise<void> {
|
||||
if (user.role !== 'MANAGER') {
|
||||
throw new ForbiddenException('Only managers can delete an organization');
|
||||
}
|
||||
|
||||
if (body.confirmEmail !== user.email) {
|
||||
throw new BadRequestException('Email confirmation does not match');
|
||||
}
|
||||
|
||||
await this.gdprService.deleteOrganizationData(user.organizationId, body.reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record consent
|
||||
*/
|
||||
|
||||
@ -153,7 +153,10 @@ export class InvitationsController {
|
||||
@ApiResponse({ status: 204, description: 'Invitation cancelled' })
|
||||
@ApiResponse({ status: 404, description: 'Invitation not found' })
|
||||
@ApiResponse({ status: 400, description: 'Invitation already used' })
|
||||
async cancelInvitation(@Param('id') id: string, @CurrentUser() user: UserPayload): Promise<void> {
|
||||
async cancelInvitation(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<void> {
|
||||
this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`);
|
||||
await this.invitationService.cancelInvitation(id, user.organizationId);
|
||||
}
|
||||
|
||||
@ -166,16 +166,27 @@ export class RatesController {
|
||||
);
|
||||
|
||||
try {
|
||||
// Map DTO to domain input
|
||||
const searchInput = {
|
||||
origin: dto.origin,
|
||||
destination: dto.destination,
|
||||
volumeCBM: dto.volumeCBM,
|
||||
weightKG: dto.weightKG,
|
||||
palletCount: dto.palletCount ?? 0,
|
||||
containerType: dto.containerType,
|
||||
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
||||
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
||||
|
||||
// Service requirements for detailed pricing
|
||||
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
||||
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
|
||||
requiresTailgate: dto.requiresTailgate ?? false,
|
||||
requiresStraps: dto.requiresStraps ?? false,
|
||||
requiresThermalCover: dto.requiresThermalCover ?? false,
|
||||
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
|
||||
requiresAppointment: dto.requiresAppointment ?? false,
|
||||
};
|
||||
|
||||
// Execute CSV rate search
|
||||
const result = await this.csvRateSearchService.execute(searchInput);
|
||||
|
||||
// Map domain output to response DTO
|
||||
@ -230,16 +241,27 @@ export class RatesController {
|
||||
);
|
||||
|
||||
try {
|
||||
// Map DTO to domain input
|
||||
const searchInput = {
|
||||
origin: dto.origin,
|
||||
destination: dto.destination,
|
||||
volumeCBM: dto.volumeCBM,
|
||||
weightKG: dto.weightKG,
|
||||
palletCount: dto.palletCount ?? 0,
|
||||
containerType: dto.containerType,
|
||||
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
||||
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
||||
|
||||
// Service requirements for detailed pricing
|
||||
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
||||
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
|
||||
requiresTailgate: dto.requiresTailgate ?? false,
|
||||
requiresStraps: dto.requiresStraps ?? false,
|
||||
requiresThermalCover: dto.requiresThermalCover ?? false,
|
||||
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
|
||||
requiresAppointment: dto.requiresAppointment ?? false,
|
||||
};
|
||||
|
||||
// Execute CSV rate search WITH OFFERS GENERATION
|
||||
const result = await this.csvRateSearchService.executeWithOffers(searchInput);
|
||||
|
||||
// Map domain output to response DTO
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Locale } from '@domain/value-objects/locale.vo';
|
||||
|
||||
/**
|
||||
* User payload interface extracted from JWT
|
||||
@ -11,7 +10,6 @@ export interface UserPayload {
|
||||
organizationId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
preferredLanguage?: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,150 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity';
|
||||
|
||||
const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news'];
|
||||
|
||||
export class CreateBlogPostDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: 'URL-friendly slug, e.g. "my-article"' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'Slug must be lowercase alphanumeric with hyphens',
|
||||
})
|
||||
slug: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(10)
|
||||
excerpt: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
coverImageUrl?: string;
|
||||
|
||||
@ApiProperty({ enum: CATEGORIES })
|
||||
@IsEnum(CATEGORIES)
|
||||
category: BlogPostCategory;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
authorName: string;
|
||||
}
|
||||
|
||||
export class UpdateBlogPostDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'Slug must be lowercase alphanumeric with hyphens',
|
||||
})
|
||||
slug?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
excerpt?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
coverImageUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CATEGORIES })
|
||||
@IsOptional()
|
||||
@IsEnum(CATEGORIES)
|
||||
category?: BlogPostCategory;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
authorName?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: BlogPostStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(BlogPostStatus)
|
||||
status?: BlogPostStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isFeatured?: boolean;
|
||||
}
|
||||
|
||||
export class BlogPostResponseDto {
|
||||
@ApiProperty() id: string;
|
||||
@ApiProperty() title: string;
|
||||
@ApiProperty() slug: string;
|
||||
@ApiProperty() excerpt: string;
|
||||
@ApiProperty() content: string;
|
||||
@ApiPropertyOptional() coverImageUrl?: string;
|
||||
@ApiProperty() category: string;
|
||||
@ApiProperty({ type: [String] }) tags: string[];
|
||||
@ApiProperty() authorName: string;
|
||||
@ApiProperty({ enum: BlogPostStatus }) status: BlogPostStatus;
|
||||
@ApiProperty() isFeatured: boolean;
|
||||
@ApiPropertyOptional() publishedAt?: Date;
|
||||
@ApiProperty() createdAt: Date;
|
||||
@ApiProperty() updatedAt: Date;
|
||||
}
|
||||
|
||||
export class BlogPostListResponseDto {
|
||||
@ApiProperty({ type: [BlogPostResponseDto] }) posts: BlogPostResponseDto[];
|
||||
@ApiProperty() total: number;
|
||||
@ApiProperty() limit: number;
|
||||
@ApiProperty() offset: number;
|
||||
}
|
||||
@ -11,192 +11,384 @@ import {
|
||||
import { Type } from 'class-transformer';
|
||||
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
||||
|
||||
/**
|
||||
* CSV Rate Search Request DTO
|
||||
*
|
||||
* Request body for searching rates in CSV-based system
|
||||
* Includes basic search parameters + optional advanced filters
|
||||
*/
|
||||
export class CsvRateSearchDto {
|
||||
@ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' })
|
||||
@ApiProperty({
|
||||
description: 'Origin port code (UN/LOCODE format)',
|
||||
example: 'NLRTM',
|
||||
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
origin: string;
|
||||
|
||||
@ApiProperty({ description: 'Destination UN/LOCODE', example: 'CNSHA' })
|
||||
@ApiProperty({
|
||||
description: 'Destination port code (UN/LOCODE format)',
|
||||
example: 'USNYC',
|
||||
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
destination: string;
|
||||
|
||||
@ApiProperty({ description: 'Volume in cubic meters (CBM)', minimum: 0.01, example: 10.5 })
|
||||
@ApiProperty({
|
||||
description: 'Volume in cubic meters (CBM)',
|
||||
minimum: 0.01,
|
||||
example: 25.5,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
volumeCBM: number;
|
||||
|
||||
@ApiProperty({ description: 'Weight in kilograms', minimum: 1, example: 2500 })
|
||||
@ApiProperty({
|
||||
description: 'Weight in kilograms',
|
||||
minimum: 1,
|
||||
example: 3500,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
weightKG: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Container type filter', example: 'LCL' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Number of pallets (0 if no pallets)',
|
||||
minimum: 0,
|
||||
example: 10,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
palletCount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Container type filter (e.g., LCL, 20DRY, 40HC)',
|
||||
example: 'LCL',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Cargo contains dangerous goods', example: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasDangerousGoods?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Advanced filters', type: RateSearchFiltersDto })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Advanced filters for narrowing results',
|
||||
type: RateSearchFiltersDto,
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => RateSearchFiltersDto)
|
||||
filters?: RateSearchFiltersDto;
|
||||
|
||||
// Service requirements for detailed price calculation
|
||||
@ApiPropertyOptional({
|
||||
description: 'Cargo contains dangerous goods (DG)',
|
||||
example: true,
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasDangerousGoods?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Requires special handling',
|
||||
example: true,
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresSpecialHandling?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Requires tailgate lift',
|
||||
example: false,
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresTailgate?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Requires securing straps',
|
||||
example: true,
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresStraps?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Requires thermal protection cover',
|
||||
example: false,
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresThermalCover?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Contains regulated products requiring special documentation',
|
||||
example: false,
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasRegulatedProducts?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Requires delivery appointment',
|
||||
example: true,
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresAppointment?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV Rate Search Response DTO
|
||||
*
|
||||
* Response containing matching rates with calculated prices
|
||||
*/
|
||||
export class CsvRateSearchResponseDto {
|
||||
@ApiProperty({ description: 'Array of matching rate results', type: [Object] })
|
||||
@ApiProperty({
|
||||
description: 'Array of matching rate results',
|
||||
type: [Object], // Will be replaced with RateResultDto
|
||||
})
|
||||
results: CsvRateResultDto[];
|
||||
|
||||
@ApiProperty({ description: 'Total number of results', example: 12 })
|
||||
@ApiProperty({
|
||||
description: 'Total number of results found',
|
||||
example: 15,
|
||||
})
|
||||
totalResults: number;
|
||||
|
||||
@ApiProperty({ description: 'CSV files searched', type: [String] })
|
||||
@ApiProperty({
|
||||
description: 'CSV files that were searched',
|
||||
type: [String],
|
||||
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
|
||||
})
|
||||
searchedFiles: string[];
|
||||
|
||||
@ApiProperty({ description: 'Timestamp of search', example: '2026-05-11T10:30:00Z' })
|
||||
@ApiProperty({
|
||||
description: 'Timestamp when search was executed',
|
||||
example: '2025-10-23T10:30:00Z',
|
||||
})
|
||||
searchedAt: Date;
|
||||
|
||||
@ApiProperty({ description: 'Applied filters' })
|
||||
@ApiProperty({
|
||||
description: 'Filters that were applied to the search',
|
||||
type: RateSearchFiltersDto,
|
||||
})
|
||||
appliedFilters: RateSearchFiltersDto;
|
||||
}
|
||||
|
||||
export class FobBreakdownDto {
|
||||
documentation: number;
|
||||
isps: number;
|
||||
handling: number;
|
||||
solas: number;
|
||||
customs: number;
|
||||
ams_aci: number;
|
||||
isf5: number;
|
||||
dgAdmin: number;
|
||||
}
|
||||
|
||||
export class PriceBreakdownDto {
|
||||
@ApiProperty({ description: 'Freight charge', example: 420.0 })
|
||||
freightCharge: number;
|
||||
|
||||
@ApiProperty({ description: 'Freight currency', example: 'USD' })
|
||||
freightCurrency: string;
|
||||
|
||||
@ApiProperty({ description: 'Fixed FOB charges (doc+ISPS+solas+customs+AMS+ISF5)', example: 185 })
|
||||
fobFixed: number;
|
||||
|
||||
@ApiProperty({ description: 'FOB handling charge', example: 60 })
|
||||
fobHandling: number;
|
||||
|
||||
@ApiProperty({ description: 'DG admin fee (FOB currency, 0 if non-DG)', example: 0 })
|
||||
fobDG: number;
|
||||
|
||||
@ApiProperty({ description: 'FOB currency', example: 'EUR' })
|
||||
fobCurrency: string;
|
||||
|
||||
@ApiProperty({ description: 'Itemized FOB breakdown', type: FobBreakdownDto })
|
||||
fobBreakdown: FobBreakdownDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'DG surcharge amount (null if on_request/not_accepted)',
|
||||
example: null,
|
||||
/**
|
||||
* Surcharge Item DTO
|
||||
*/
|
||||
export class SurchargeItemDto {
|
||||
@ApiProperty({
|
||||
description: 'Surcharge code',
|
||||
example: 'DG_FEE',
|
||||
})
|
||||
dgSurchargeAmount: number | null;
|
||||
|
||||
@ApiProperty({ description: 'DG surcharge currency', example: 'EUR' })
|
||||
dgSurchargeCurrency: string;
|
||||
code: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'DG surcharge status',
|
||||
enum: ['computed', 'on_request', 'not_accepted'],
|
||||
example: 'computed',
|
||||
description: 'Surcharge description',
|
||||
example: 'Dangerous goods fee',
|
||||
})
|
||||
dgSurchargeStatus: string;
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ description: 'Total freight in freightCurrency', example: 420.0 })
|
||||
totalFreight: number;
|
||||
@ApiProperty({
|
||||
description: 'Surcharge amount in currency',
|
||||
example: 65.0,
|
||||
})
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({ description: 'Total FOB in fobCurrency', example: 245 })
|
||||
totalFob: number;
|
||||
|
||||
@ApiProperty({ description: 'Sum for sorting (currency-naive)', example: 665.0 })
|
||||
totalPriceForSorting: number;
|
||||
|
||||
@ApiProperty({ description: 'Primary currency', example: 'USD' })
|
||||
primaryCurrency: string;
|
||||
@ApiProperty({
|
||||
description: 'Type of surcharge calculation',
|
||||
enum: ['FIXED', 'PER_UNIT', 'PERCENTAGE'],
|
||||
example: 'FIXED',
|
||||
})
|
||||
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Price Breakdown DTO
|
||||
*/
|
||||
export class PriceBreakdownDto {
|
||||
@ApiProperty({
|
||||
description: 'Base price before any charges',
|
||||
example: 0,
|
||||
})
|
||||
basePrice: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Charge based on volume (CBM)',
|
||||
example: 150.0,
|
||||
})
|
||||
volumeCharge: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Charge based on weight (KG)',
|
||||
example: 25.0,
|
||||
})
|
||||
weightCharge: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Charge for pallets',
|
||||
example: 125.0,
|
||||
})
|
||||
palletCharge: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of all surcharges',
|
||||
type: [SurchargeItemDto],
|
||||
})
|
||||
surcharges: SurchargeItemDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Total of all surcharges',
|
||||
example: 242.0,
|
||||
})
|
||||
totalSurcharges: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Total price including all charges',
|
||||
example: 542.0,
|
||||
})
|
||||
totalPrice: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Currency of the pricing',
|
||||
enum: ['USD', 'EUR'],
|
||||
example: 'USD',
|
||||
})
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single CSV Rate Result DTO
|
||||
*/
|
||||
export class CsvRateResultDto {
|
||||
@ApiProperty({ example: 'SSC Consolidation' })
|
||||
@ApiProperty({
|
||||
description: 'Company name',
|
||||
example: 'SSC Consolidation',
|
||||
})
|
||||
companyName: string;
|
||||
|
||||
@ApiProperty({ example: 'bookings@ssc.com' })
|
||||
@ApiProperty({
|
||||
description: 'Company email for booking requests',
|
||||
example: 'bookings@sscconsolidation.com',
|
||||
})
|
||||
companyEmail: string;
|
||||
|
||||
@ApiProperty({ description: 'Origin CFS name', example: 'Fos Sur Mer' })
|
||||
originCFS: string;
|
||||
|
||||
@ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' })
|
||||
@ApiProperty({
|
||||
description: 'Origin port code',
|
||||
example: 'NLRTM',
|
||||
})
|
||||
origin: string;
|
||||
|
||||
@ApiProperty({ description: 'Port of loading', example: 'FOS SUR MER' })
|
||||
portOfLoading: string;
|
||||
|
||||
@ApiProperty({ description: 'Routing type', example: 'Direct' })
|
||||
routing: string;
|
||||
|
||||
@ApiProperty({ description: 'Destination CFS name', example: 'Shanghai' })
|
||||
destinationCFS: string;
|
||||
|
||||
@ApiProperty({ description: 'Destination UN/LOCODE', example: 'CNSHA' })
|
||||
@ApiProperty({
|
||||
description: 'Destination port code',
|
||||
example: 'USNYC',
|
||||
})
|
||||
destination: string;
|
||||
|
||||
@ApiProperty({ description: 'Destination country', example: 'China' })
|
||||
destinationCountry: string;
|
||||
|
||||
@ApiProperty({ example: 'LCL' })
|
||||
@ApiProperty({
|
||||
description: 'Container type',
|
||||
example: 'LCL',
|
||||
})
|
||||
containerType: string;
|
||||
|
||||
@ApiProperty({ description: 'Detailed price breakdown', type: PriceBreakdownDto })
|
||||
@ApiProperty({
|
||||
description: 'Calculated price in USD',
|
||||
example: 1850.5,
|
||||
})
|
||||
priceUSD: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Calculated price in EUR',
|
||||
example: 1665.45,
|
||||
})
|
||||
priceEUR: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Primary currency of the rate',
|
||||
enum: ['USD', 'EUR'],
|
||||
example: 'USD',
|
||||
})
|
||||
primaryCurrency: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Detailed price breakdown with all charges',
|
||||
type: PriceBreakdownDto,
|
||||
})
|
||||
priceBreakdown: PriceBreakdownDto;
|
||||
|
||||
@ApiProperty({ description: 'Departure frequency', example: 'Weekly' })
|
||||
frequency: string;
|
||||
@ApiProperty({
|
||||
description: 'Whether this rate has separate surcharges',
|
||||
example: true,
|
||||
})
|
||||
hasSurcharges: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Transit time (adjusted if service level)', example: 28 })
|
||||
@ApiProperty({
|
||||
description: 'Details of surcharges if any',
|
||||
example: 'BAF+CAF included',
|
||||
nullable: true,
|
||||
})
|
||||
surchargeDetails: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Transit time in days',
|
||||
example: 28,
|
||||
})
|
||||
transitDays: number;
|
||||
|
||||
@ApiProperty({ description: 'Rate validity end date', example: '2026-12-31' })
|
||||
@ApiProperty({
|
||||
description: 'Rate validity end date',
|
||||
example: '2025-12-31',
|
||||
})
|
||||
validUntil: string;
|
||||
|
||||
@ApiProperty({ description: 'Whether DG cargo is accepted', example: true })
|
||||
dgAccepted: boolean;
|
||||
@ApiProperty({
|
||||
description: 'Source of the rate',
|
||||
enum: ['CSV', 'API'],
|
||||
example: 'CSV',
|
||||
})
|
||||
source: 'CSV' | 'API';
|
||||
|
||||
@ApiProperty({ description: 'DG surcharge status', example: 'computed' })
|
||||
dgSurchargeStatus: string;
|
||||
|
||||
@ApiProperty({ description: 'Internal remarks', example: 'GR1/GR2' })
|
||||
remarks: string;
|
||||
|
||||
@ApiProperty({ example: 'CSV' })
|
||||
source: 'CSV';
|
||||
|
||||
@ApiProperty({ description: 'Match score 0-100', example: 95 })
|
||||
@ApiProperty({
|
||||
description: 'Match score (0-100) indicating how well this rate matches the search',
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
example: 95,
|
||||
})
|
||||
matchScore: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['RAPID', 'STANDARD', 'ECONOMIC'] })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Service level (only present when using search-csv-offers endpoint)',
|
||||
enum: ['RAPID', 'STANDARD', 'ECONOMIC'],
|
||||
example: 'RAPID',
|
||||
})
|
||||
serviceLevel?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Price multiplier for service level', example: 1.0 })
|
||||
priceMultiplier?: number;
|
||||
@ApiPropertyOptional({
|
||||
description: 'Original price before service level adjustment',
|
||||
example: { usd: 1500.0, eur: 1350.0 },
|
||||
})
|
||||
originalPrice?: {
|
||||
usd: number;
|
||||
eur: number;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Original transit days before service level adjustment',
|
||||
example: 28,
|
||||
example: 20,
|
||||
})
|
||||
originalTransitDays?: number;
|
||||
}
|
||||
|
||||
@ -10,9 +10,15 @@ import {
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Rate Search Filters DTO
|
||||
*
|
||||
* Advanced filters for narrowing down rate search results
|
||||
* All filters are optional
|
||||
*/
|
||||
export class RateSearchFiltersDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'List of company names to include',
|
||||
description: 'List of company names to include in search',
|
||||
type: [String],
|
||||
example: ['SSC Consolidation', 'ECU Worldwide'],
|
||||
})
|
||||
@ -22,25 +28,59 @@ export class RateSearchFiltersDto {
|
||||
companies?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Only show "Direct" routing (exclude transhipment)',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
onlyDirect?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Exclude routes where DG is not accepted',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
excludeNonDgRoutes?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Minimum price (totalPriceForSorting)',
|
||||
description: 'Minimum volume in CBM (cubic meters)',
|
||||
minimum: 0,
|
||||
example: 500,
|
||||
example: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minVolumeCBM?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Maximum volume in CBM (cubic meters)',
|
||||
minimum: 0,
|
||||
example: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolumeCBM?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Minimum weight in kilograms',
|
||||
minimum: 0,
|
||||
example: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minWeightKG?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Maximum weight in kilograms',
|
||||
minimum: 0,
|
||||
example: 15000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxWeightKG?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Exact number of pallets (0 means any)',
|
||||
minimum: 0,
|
||||
example: 10,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
palletCount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Minimum price in selected currency',
|
||||
minimum: 0,
|
||||
example: 1000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@ -48,9 +88,9 @@ export class RateSearchFiltersDto {
|
||||
minPrice?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Maximum price (totalPriceForSorting)',
|
||||
description: 'Maximum price in selected currency',
|
||||
minimum: 0,
|
||||
example: 3000,
|
||||
example: 5000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@ -70,7 +110,7 @@ export class RateSearchFiltersDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Maximum transit time in days',
|
||||
minimum: 0,
|
||||
example: 45,
|
||||
example: 40,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@ -80,7 +120,7 @@ export class RateSearchFiltersDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Container types to filter by',
|
||||
type: [String],
|
||||
example: ['LCL'],
|
||||
example: ['LCL', '20DRY', '40HC'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ -88,7 +128,7 @@ export class RateSearchFiltersDto {
|
||||
containerTypes?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Preferred currency for price display',
|
||||
description: 'Preferred currency for price filtering',
|
||||
enum: ['USD', 'EUR'],
|
||||
example: 'USD',
|
||||
})
|
||||
@ -96,9 +136,17 @@ export class RateSearchFiltersDto {
|
||||
@IsEnum(['USD', 'EUR'])
|
||||
currency?: 'USD' | 'EUR';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Only show all-in prices (without separate surcharges)',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
onlyAllInPrices?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Departure date to check rate validity (ISO 8601)',
|
||||
example: '2026-06-15',
|
||||
example: '2025-06-15',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
/**
|
||||
* DomainExceptionFilter
|
||||
*
|
||||
* Catches any DomainException bubbling up to the HTTP boundary, translates its
|
||||
* i18nKey/i18nArgs into the caller's locale (resolved by nestjs-i18n) and
|
||||
* returns a structured JSON error response.
|
||||
*
|
||||
* Non-domain errors fall through to NestJS's default handler.
|
||||
*/
|
||||
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
|
||||
import { I18nService, I18nContext } from 'nestjs-i18n';
|
||||
import { Response, Request } from 'express';
|
||||
import { DomainException } from '@domain/exceptions/domain.exception';
|
||||
import { DEFAULT_LOCALE, Locale, toLocale } from '@domain/value-objects/locale.vo';
|
||||
|
||||
@Catch(DomainException)
|
||||
export class DomainExceptionFilter implements ExceptionFilter {
|
||||
constructor(private readonly i18n: I18nService<Record<string, unknown>>) {}
|
||||
|
||||
catch(exception: DomainException, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const lang: Locale = toLocale(I18nContext.current()?.lang, DEFAULT_LOCALE) ?? DEFAULT_LOCALE;
|
||||
|
||||
const translated = this.i18n.translate(exception.i18nKey, {
|
||||
lang,
|
||||
args: exception.i18nArgs,
|
||||
defaultValue: exception.message,
|
||||
});
|
||||
|
||||
const status = exception.status || HttpStatus.BAD_REQUEST;
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
error: exception.name,
|
||||
message: typeof translated === 'string' ? translated : exception.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -9,24 +9,20 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GDPRController } from '../controllers/gdpr.controller';
|
||||
import { GDPRService } from '../services/gdpr.service';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
||||
import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
|
||||
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
|
||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
UserOrmEntity,
|
||||
OrganizationOrmEntity,
|
||||
BookingOrmEntity,
|
||||
AuditLogOrmEntity,
|
||||
NotificationOrmEntity,
|
||||
CookieConsentOrmEntity,
|
||||
]),
|
||||
SubscriptionsModule,
|
||||
],
|
||||
controllers: [GDPRController],
|
||||
providers: [GDPRService],
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
import { Controller, Get, Query, Res, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
@ -14,7 +22,7 @@ export class LogsController {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.logExporterUrl = this.configService.get<string>(
|
||||
'LOG_EXPORTER_URL',
|
||||
'http://xpeditis-log-exporter:3200'
|
||||
'http://xpeditis-log-exporter:3200',
|
||||
);
|
||||
}
|
||||
|
||||
@ -31,7 +39,10 @@ export class LogsController {
|
||||
if (!res.ok) throw new Error(`log-exporter error: ${res.status}`);
|
||||
return res.json();
|
||||
} catch (err: any) {
|
||||
throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY);
|
||||
throw new HttpException(
|
||||
{ error: err.message },
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,7 +59,7 @@ export class LogsController {
|
||||
@Query('end') end: string,
|
||||
@Query('limit') limit: string,
|
||||
@Query('format') format: string = 'json',
|
||||
@Res() res: Response
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@ -60,9 +71,10 @@ export class LogsController {
|
||||
if (limit) params.set('limit', limit);
|
||||
params.set('format', format);
|
||||
|
||||
const upstream = await fetch(`${this.logExporterUrl}/api/logs/export?${params}`, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const upstream = await fetch(
|
||||
`${this.logExporterUrl}/api/logs/export?${params}`,
|
||||
{ signal: AbortSignal.timeout(30000) },
|
||||
);
|
||||
|
||||
if (!upstream.ok) {
|
||||
const body = await upstream.json().catch(() => ({}));
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
CsvRateResultDto,
|
||||
CsvRateSearchResponseDto,
|
||||
PriceBreakdownDto,
|
||||
FobBreakdownDto,
|
||||
} from '../dto/csv-rate-search.dto';
|
||||
import { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
||||
import {
|
||||
CsvRateSearchOutput,
|
||||
CsvRateSearchResult,
|
||||
@ -14,92 +9,100 @@ import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto';
|
||||
import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto';
|
||||
import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity';
|
||||
|
||||
/**
|
||||
* CSV Rate Mapper
|
||||
*
|
||||
* Maps between domain entities and DTOs
|
||||
* Follows hexagonal architecture principles
|
||||
*/
|
||||
@Injectable()
|
||||
export class CsvRateMapper {
|
||||
/**
|
||||
* Map DTO filters to domain filters
|
||||
*/
|
||||
mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined {
|
||||
if (!dto) return undefined;
|
||||
if (!dto) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
companies: dto.companies,
|
||||
onlyDirect: dto.onlyDirect,
|
||||
excludeNonDgRoutes: dto.excludeNonDgRoutes,
|
||||
minVolumeCBM: dto.minVolumeCBM,
|
||||
maxVolumeCBM: dto.maxVolumeCBM,
|
||||
minWeightKG: dto.minWeightKG,
|
||||
maxWeightKG: dto.maxWeightKG,
|
||||
palletCount: dto.palletCount,
|
||||
minPrice: dto.minPrice,
|
||||
maxPrice: dto.maxPrice,
|
||||
currency: dto.currency,
|
||||
minTransitDays: dto.minTransitDays,
|
||||
maxTransitDays: dto.maxTransitDays,
|
||||
containerTypes: dto.containerTypes,
|
||||
onlyAllInPrices: dto.onlyAllInPrices,
|
||||
departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map domain search result to DTO
|
||||
*/
|
||||
mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto {
|
||||
const rate = result.rate;
|
||||
const bd = result.priceBreakdown;
|
||||
|
||||
const fobBreakdown: FobBreakdownDto = {
|
||||
documentation: bd.fobBreakdown.documentation,
|
||||
isps: bd.fobBreakdown.isps,
|
||||
handling: bd.fobBreakdown.handling,
|
||||
solas: bd.fobBreakdown.solas,
|
||||
customs: bd.fobBreakdown.customs,
|
||||
ams_aci: bd.fobBreakdown.ams_aci,
|
||||
isf5: bd.fobBreakdown.isf5,
|
||||
dgAdmin: bd.fobBreakdown.dgAdmin,
|
||||
};
|
||||
|
||||
const priceBreakdown: PriceBreakdownDto = {
|
||||
freightCharge: bd.freightCharge,
|
||||
freightCurrency: bd.freightCurrency,
|
||||
fobFixed: bd.fobFixed,
|
||||
fobHandling: bd.fobHandling,
|
||||
fobDG: bd.fobDG,
|
||||
fobCurrency: bd.fobCurrency,
|
||||
fobBreakdown,
|
||||
dgSurchargeAmount: bd.dgSurchargeAmount,
|
||||
dgSurchargeCurrency: bd.dgSurchargeCurrency,
|
||||
dgSurchargeStatus: bd.dgSurchargeStatus,
|
||||
totalFreight: bd.totalFreight,
|
||||
totalFob: bd.totalFob,
|
||||
totalPriceForSorting: bd.totalPriceForSorting,
|
||||
primaryCurrency: bd.primaryCurrency,
|
||||
};
|
||||
|
||||
return {
|
||||
companyName: rate.companyName,
|
||||
companyEmail: rate.companyEmail,
|
||||
originCFS: rate.originCFS,
|
||||
origin: rate.originCode.getValue(),
|
||||
portOfLoading: rate.portOfLoading,
|
||||
routing: rate.routing,
|
||||
destinationCFS: rate.destinationCFS,
|
||||
destination: rate.destinationCode.getValue(),
|
||||
destinationCountry: rate.destinationCountry,
|
||||
origin: rate.origin.getValue(),
|
||||
destination: rate.destination.getValue(),
|
||||
containerType: rate.containerType.getValue(),
|
||||
priceBreakdown,
|
||||
frequency: rate.frequency,
|
||||
priceUSD: result.calculatedPrice.usd,
|
||||
priceEUR: result.calculatedPrice.eur,
|
||||
primaryCurrency: result.calculatedPrice.primaryCurrency,
|
||||
priceBreakdown: {
|
||||
basePrice: result.priceBreakdown.basePrice,
|
||||
volumeCharge: result.priceBreakdown.volumeCharge,
|
||||
weightCharge: result.priceBreakdown.weightCharge,
|
||||
palletCharge: result.priceBreakdown.palletCharge,
|
||||
surcharges: result.priceBreakdown.surcharges.map(s => ({
|
||||
code: s.code,
|
||||
description: s.description,
|
||||
amount: s.amount,
|
||||
type: s.type,
|
||||
})),
|
||||
totalSurcharges: result.priceBreakdown.totalSurcharges,
|
||||
totalPrice: result.priceBreakdown.totalPrice,
|
||||
currency: result.priceBreakdown.currency,
|
||||
},
|
||||
hasSurcharges: rate.hasSurcharges(),
|
||||
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
|
||||
// Use adjusted transit days if available (service level offers), otherwise use original
|
||||
transitDays: result.adjustedTransitDays ?? rate.transitDays,
|
||||
validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
|
||||
dgAccepted: rate.isDgAccepted(),
|
||||
dgSurchargeStatus: bd.dgSurchargeStatus,
|
||||
remarks: rate.remarks,
|
||||
source: result.source,
|
||||
matchScore: result.matchScore,
|
||||
// Include service level fields if present
|
||||
serviceLevel: result.serviceLevel,
|
||||
priceMultiplier: result.priceMultiplier,
|
||||
originalPrice: result.originalPrice,
|
||||
originalTransitDays: result.originalTransitDays,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map domain search output to response DTO
|
||||
*/
|
||||
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
|
||||
return {
|
||||
results: output.results.map(r => this.mapSearchResultToDto(r)),
|
||||
results: output.results.map(result => this.mapSearchResultToDto(result)),
|
||||
totalResults: output.totalResults,
|
||||
searchedFiles: output.searchedFiles,
|
||||
searchedAt: output.searchedAt,
|
||||
appliedFilters: output.appliedFilters as any,
|
||||
appliedFilters: output.appliedFilters as any, // Already matches DTO structure
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map ORM entity to DTO
|
||||
*/
|
||||
mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
@ -115,7 +118,10 @@ export class CsvRateMapper {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map multiple config entities to DTOs
|
||||
*/
|
||||
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
|
||||
return entities.map(e => this.mapConfigEntityToDto(e));
|
||||
return entities.map(entity => this.mapConfigEntityToDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,139 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { BlogPost, BlogPostStatus } from '@domain/entities/blog-post.entity';
|
||||
import {
|
||||
BlogPostRepository,
|
||||
BlogPostFilters,
|
||||
BLOG_POST_REPOSITORY,
|
||||
} from '@domain/ports/out/blog-post.repository';
|
||||
import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto';
|
||||
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
||||
|
||||
const BLOG_IMAGES_BUCKET = 'xpeditis-blog';
|
||||
|
||||
@Injectable()
|
||||
export class BlogService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(BlogService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(BLOG_POST_REPOSITORY)
|
||||
private readonly blogPostRepository: BlogPostRepository,
|
||||
@Inject(STORAGE_PORT)
|
||||
private readonly storage: StoragePort
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
await this.storage.ensureBucket(BLOG_IMAGES_BUCKET);
|
||||
this.logger.log(`Blog images bucket "${BLOG_IMAGES_BUCKET}" is ready`);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Could not ensure blog images bucket: ${err?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createPost(dto: CreateBlogPostDto): Promise<BlogPost> {
|
||||
const slugTaken = await this.blogPostRepository.slugExists(dto.slug);
|
||||
if (slugTaken) {
|
||||
throw new ConflictException(`Slug "${dto.slug}" is already in use`);
|
||||
}
|
||||
|
||||
const post = BlogPost.create({
|
||||
id: uuidv4(),
|
||||
title: dto.title,
|
||||
slug: dto.slug,
|
||||
excerpt: dto.excerpt,
|
||||
content: dto.content,
|
||||
coverImageUrl: dto.coverImageUrl,
|
||||
category: dto.category,
|
||||
tags: dto.tags ?? [],
|
||||
authorName: dto.authorName,
|
||||
});
|
||||
|
||||
return this.blogPostRepository.save(post);
|
||||
}
|
||||
|
||||
async updatePost(id: string, dto: UpdateBlogPostDto): Promise<BlogPost> {
|
||||
const post = await this.findOrFail(id);
|
||||
|
||||
if (dto.slug && dto.slug !== post.slug) {
|
||||
const slugTaken = await this.blogPostRepository.slugExists(dto.slug, id);
|
||||
if (slugTaken) {
|
||||
throw new ConflictException(`Slug "${dto.slug}" is already in use`);
|
||||
}
|
||||
}
|
||||
|
||||
let updated = post.update({
|
||||
title: dto.title,
|
||||
slug: dto.slug,
|
||||
excerpt: dto.excerpt,
|
||||
content: dto.content,
|
||||
coverImageUrl: dto.coverImageUrl,
|
||||
category: dto.category,
|
||||
tags: dto.tags,
|
||||
authorName: dto.authorName,
|
||||
isFeatured: dto.isFeatured,
|
||||
});
|
||||
|
||||
if (dto.status !== undefined && dto.status !== post.status) {
|
||||
if (dto.status === BlogPostStatus.PUBLISHED) updated = updated.publish();
|
||||
else if (dto.status === BlogPostStatus.ARCHIVED) updated = updated.archive();
|
||||
else if (dto.status === BlogPostStatus.DRAFT) updated = updated.unpublish();
|
||||
}
|
||||
|
||||
return this.blogPostRepository.save(updated);
|
||||
}
|
||||
|
||||
async deletePost(id: string): Promise<void> {
|
||||
await this.findOrFail(id);
|
||||
await this.blogPostRepository.delete(id);
|
||||
}
|
||||
|
||||
async getPostById(id: string): Promise<BlogPost> {
|
||||
return this.findOrFail(id);
|
||||
}
|
||||
|
||||
async getPublishedPostBySlug(slug: string): Promise<BlogPost> {
|
||||
const post = await this.blogPostRepository.findBySlug(slug);
|
||||
if (!post || !post.isPublished()) {
|
||||
throw new NotFoundException('Article not found');
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
async listPublishedPosts(
|
||||
filters: BlogPostFilters
|
||||
): Promise<{ posts: BlogPost[]; total: number }> {
|
||||
const publishedFilters: BlogPostFilters = {
|
||||
...filters,
|
||||
status: BlogPostStatus.PUBLISHED,
|
||||
};
|
||||
const [posts, total] = await Promise.all([
|
||||
this.blogPostRepository.findByFilters(publishedFilters),
|
||||
this.blogPostRepository.count(publishedFilters),
|
||||
]);
|
||||
return { posts, total };
|
||||
}
|
||||
|
||||
async listAllPosts(filters: BlogPostFilters): Promise<{ posts: BlogPost[]; total: number }> {
|
||||
const [posts, total] = await Promise.all([
|
||||
this.blogPostRepository.findByFilters(filters),
|
||||
this.blogPostRepository.count(filters),
|
||||
]);
|
||||
return { posts, total };
|
||||
}
|
||||
|
||||
private async findOrFail(id: string): Promise<BlogPost> {
|
||||
const post = await this.blogPostRepository.findById(id);
|
||||
if (!post) {
|
||||
throw new NotFoundException(`Blog post with id "${id}" not found`);
|
||||
}
|
||||
return post;
|
||||
}
|
||||
}
|
||||
@ -374,20 +374,18 @@ export class CsvBookingService {
|
||||
|
||||
booking.markBankTransferDeclared();
|
||||
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||
this.logger.log(
|
||||
`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`
|
||||
);
|
||||
this.logger.log(`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`);
|
||||
|
||||
// Send email to all ADMIN users
|
||||
try {
|
||||
const allUsers = await this.userRepository.findAll();
|
||||
const adminEmails = allUsers.filter(u => u.role === 'ADMIN' && u.isActive).map(u => u.email);
|
||||
const adminEmails = allUsers
|
||||
.filter(u => u.role === 'ADMIN' && u.isActive)
|
||||
.map(u => u.email);
|
||||
|
||||
if (adminEmails.length > 0) {
|
||||
const commissionAmount = booking.commissionAmountEur
|
||||
? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
|
||||
booking.commissionAmountEur
|
||||
)
|
||||
? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(booking.commissionAmountEur)
|
||||
: 'N/A';
|
||||
|
||||
await this.emailAdapter.send({
|
||||
@ -490,9 +488,7 @@ export class CsvBookingService {
|
||||
notes: booking.notes,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`
|
||||
);
|
||||
this.logger.log(`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -548,9 +544,7 @@ export class CsvBookingService {
|
||||
confirmationToken: booking.confirmationToken,
|
||||
notes: booking.notes,
|
||||
});
|
||||
this.logger.log(
|
||||
`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`
|
||||
);
|
||||
this.logger.log(`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
||||
}
|
||||
|
||||
@ -10,10 +10,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||
import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto';
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
|
||||
export interface GDPRDataExport {
|
||||
exportDate: string;
|
||||
@ -30,11 +28,8 @@ export class GDPRService {
|
||||
constructor(
|
||||
@InjectRepository(UserOrmEntity)
|
||||
private readonly userRepository: Repository<UserOrmEntity>,
|
||||
@InjectRepository(OrganizationOrmEntity)
|
||||
private readonly organizationRepository: Repository<OrganizationOrmEntity>,
|
||||
@InjectRepository(CookieConsentOrmEntity)
|
||||
private readonly consentRepository: Repository<CookieConsentOrmEntity>,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
private readonly consentRepository: Repository<CookieConsentOrmEntity>
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -87,70 +82,41 @@ export class GDPRService {
|
||||
return exportData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a manager can delete their own account (i.e. another manager exists in the org)
|
||||
*/
|
||||
async canManagerSelfDelete(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<{ canSelfDelete: boolean; otherManagersCount: number }> {
|
||||
const orgUsers = await this.userRepository.find({ where: { organizationId } });
|
||||
const otherManagers = orgUsers.filter(u => u.role === 'MANAGER' && u.id !== userId);
|
||||
return { canSelfDelete: otherManagers.length > 0, otherManagersCount: otherManagers.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user data (GDPR Article 17 - Right to Erasure)
|
||||
* Note: This is a simplified version. In production, implement full anonymization logic.
|
||||
*/
|
||||
async deleteUserData(userId: string, reason?: string): Promise<void> {
|
||||
this.logger.warn(
|
||||
`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`
|
||||
);
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete consent data first (will cascade with user deletion)
|
||||
await this.consentRepository.delete({ userId });
|
||||
await this.subscriptionService.revokeLicense(userId);
|
||||
await this.userRepository.delete({ id: userId });
|
||||
this.logger.warn(`User ${userId} (${user.email}) account deleted`);
|
||||
|
||||
// IMPORTANT: In production, implement full data anonymization
|
||||
// For now, we just mark the account for deletion
|
||||
// Real implementation should:
|
||||
// 1. Anonymize bookings (keep for legal retention)
|
||||
// 2. Delete notifications
|
||||
// 3. Anonymize audit logs
|
||||
// 4. Anonymize user record
|
||||
|
||||
this.logger.warn(`User ${userId} marked for deletion. Full implementation pending.`);
|
||||
this.logger.log(`Data deletion initiated for user ${userId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Data deletion failed for user ${userId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete entire organization and all its data (MANAGER action)
|
||||
* Cascade deletes all users and bookings via DB constraints
|
||||
*/
|
||||
async deleteOrganizationData(organizationId: string, reason?: string): Promise<void> {
|
||||
this.logger.warn(
|
||||
`Initiating organization deletion for org ${organizationId}. Reason: ${reason || 'Manager request'}`
|
||||
);
|
||||
|
||||
const org = await this.organizationRepository.findOne({ where: { id: organizationId } });
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
const orgUsers = await this.userRepository.find({ where: { organizationId } });
|
||||
|
||||
for (const orgUser of orgUsers) {
|
||||
try {
|
||||
await this.subscriptionService.revokeLicense(orgUser.id);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Failed to revoke license for user ${orgUser.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.organizationRepository.delete({ id: organizationId });
|
||||
this.logger.warn(`Organization ${organizationId} (${org.name}) deleted with all data`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record or update consent (GDPR Article 7 - Conditions for consent)
|
||||
*/
|
||||
|
||||
@ -70,10 +70,7 @@ export class InvitationService {
|
||||
}
|
||||
|
||||
// Check if licenses are available for this organization
|
||||
const canInviteResult = await this.subscriptionService.canInviteUser(
|
||||
organizationId,
|
||||
inviterRole
|
||||
);
|
||||
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole);
|
||||
if (!canInviteResult.canInvite) {
|
||||
this.logger.warn(
|
||||
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
export enum BlogPostStatus {
|
||||
DRAFT = 'draft',
|
||||
PUBLISHED = 'published',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
|
||||
|
||||
interface BlogPostProps {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
coverImageUrl?: string;
|
||||
category: BlogPostCategory;
|
||||
tags: string[];
|
||||
authorName: string;
|
||||
status: BlogPostStatus;
|
||||
isFeatured: boolean;
|
||||
publishedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class BlogPost {
|
||||
private constructor(private readonly props: BlogPostProps) {}
|
||||
|
||||
static create(
|
||||
props: Omit<BlogPostProps, 'status' | 'isFeatured' | 'publishedAt' | 'createdAt' | 'updatedAt'>
|
||||
): BlogPost {
|
||||
const now = new Date();
|
||||
return new BlogPost({
|
||||
...props,
|
||||
status: BlogPostStatus.DRAFT,
|
||||
isFeatured: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
static fromPersistence(props: BlogPostProps): BlogPost {
|
||||
return new BlogPost(props);
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.props.id;
|
||||
}
|
||||
get title(): string {
|
||||
return this.props.title;
|
||||
}
|
||||
get slug(): string {
|
||||
return this.props.slug;
|
||||
}
|
||||
get excerpt(): string {
|
||||
return this.props.excerpt;
|
||||
}
|
||||
get content(): string {
|
||||
return this.props.content;
|
||||
}
|
||||
get coverImageUrl(): string | undefined {
|
||||
return this.props.coverImageUrl;
|
||||
}
|
||||
get category(): BlogPostCategory {
|
||||
return this.props.category;
|
||||
}
|
||||
get tags(): string[] {
|
||||
return this.props.tags;
|
||||
}
|
||||
get authorName(): string {
|
||||
return this.props.authorName;
|
||||
}
|
||||
get status(): BlogPostStatus {
|
||||
return this.props.status;
|
||||
}
|
||||
get isFeatured(): boolean {
|
||||
return this.props.isFeatured;
|
||||
}
|
||||
get publishedAt(): Date | undefined {
|
||||
return this.props.publishedAt;
|
||||
}
|
||||
get createdAt(): Date {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
get updatedAt(): Date {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
update(
|
||||
data: Partial<
|
||||
Pick<
|
||||
BlogPostProps,
|
||||
| 'title'
|
||||
| 'slug'
|
||||
| 'excerpt'
|
||||
| 'content'
|
||||
| 'coverImageUrl'
|
||||
| 'category'
|
||||
| 'tags'
|
||||
| 'authorName'
|
||||
| 'isFeatured'
|
||||
>
|
||||
>
|
||||
): BlogPost {
|
||||
return new BlogPost({ ...this.props, ...data, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
publish(): BlogPost {
|
||||
return new BlogPost({
|
||||
...this.props,
|
||||
status: BlogPostStatus.PUBLISHED,
|
||||
publishedAt: this.props.publishedAt ?? new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
archive(): BlogPost {
|
||||
return new BlogPost({ ...this.props, status: BlogPostStatus.ARCHIVED, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
unpublish(): BlogPost {
|
||||
return new BlogPost({ ...this.props, status: BlogPostStatus.DRAFT, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
isPublished(): boolean {
|
||||
return this.props.status === BlogPostStatus.PUBLISHED;
|
||||
}
|
||||
|
||||
toObject(): BlogPostProps {
|
||||
return { ...this.props };
|
||||
}
|
||||
}
|
||||
@ -1,69 +1,60 @@
|
||||
import { PortCode } from '../value-objects/port-code.vo';
|
||||
import { ContainerType } from '../value-objects/container-type.vo';
|
||||
import { Money } from '../value-objects/money.vo';
|
||||
import { Volume } from '../value-objects/volume.vo';
|
||||
import { SurchargeCollection } from '../value-objects/surcharge.vo';
|
||||
import { DateRange } from '../value-objects/date-range.vo';
|
||||
|
||||
export type DgSurchargeValue = number | 'ON REQUEST' | 'NOT ACCEPTED';
|
||||
export type HandlingUnit = 'W' | 'UP'; // W = tonne revenue (max CBM/T), UP = per CBM
|
||||
export type FrequencyType = 'Weekly' | 'Bi-Weekly' | 'Bi-Monthly' | 'Monthly';
|
||||
|
||||
export interface FreightPricing {
|
||||
freightCurrency: string;
|
||||
freightRatePerCBM: number; // 0.0 = included/to negotiate
|
||||
freightMinimum: number;
|
||||
}
|
||||
|
||||
export interface FobCharges {
|
||||
fobCurrency: string;
|
||||
fobDocumentation: number;
|
||||
fobISPS: number;
|
||||
fobHandling: number;
|
||||
fobHandlingUnit: HandlingUnit;
|
||||
fobHandlingMinimum: number;
|
||||
fobSolas: number;
|
||||
fobCustoms: number;
|
||||
fobAMS_ACI: number;
|
||||
fobISF5: number;
|
||||
fobDGAdmin: number; // Only if DG shipment
|
||||
}
|
||||
|
||||
export interface DgSurchargeInfo {
|
||||
dgSurchargeCurrency: string;
|
||||
dgSurchargeRate: DgSurchargeValue;
|
||||
dgSurchargeUnit: 'UP' | 'LS' | '%'; // per CBM, lump sum, or percentage
|
||||
dgSurchargeMin: DgSurchargeValue;
|
||||
/**
|
||||
* Volume Range - Valid range for CBM
|
||||
*/
|
||||
export interface VolumeRange {
|
||||
minCBM: number;
|
||||
maxCBM: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CsvRate — Shipping rate from a consolidator CSV file.
|
||||
* Weight Range - Valid range for KG
|
||||
*/
|
||||
export interface WeightRange {
|
||||
minKG: number;
|
||||
maxKG: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate Pricing - Pricing structure for CSV rates
|
||||
*/
|
||||
export interface RatePricing {
|
||||
pricePerCBM: number;
|
||||
pricePerKG: number;
|
||||
basePriceUSD: Money;
|
||||
basePriceEUR: Money;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV Rate Entity
|
||||
*
|
||||
* Represents a shipping rate loaded from CSV file.
|
||||
* Contains all information needed to calculate freight costs.
|
||||
*
|
||||
* Business Rules:
|
||||
* - Route matching uses originCode + destinationCode (UN/LOCODE)
|
||||
* - Price = max(freightRatePerCBM×V, freightMinimum) + FOB fixed + handling
|
||||
* - FOB and freight may be in different currencies
|
||||
* - DG surcharge applies only when hasDangerousGoods = true
|
||||
* - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges
|
||||
* - Rate must be valid (within validity period) to be used
|
||||
* - Volume and weight must be within specified ranges
|
||||
*/
|
||||
export class CsvRate {
|
||||
constructor(
|
||||
// Supplier identity
|
||||
public readonly companyName: string,
|
||||
public readonly companyEmail: string,
|
||||
// Route geography
|
||||
public readonly originCFS: string,
|
||||
public readonly originCode: PortCode,
|
||||
public readonly portOfLoading: string,
|
||||
public readonly routing: string,
|
||||
public readonly destinationCFS: string,
|
||||
public readonly destinationCode: PortCode,
|
||||
public readonly destinationCountry: string,
|
||||
// Container
|
||||
public readonly origin: PortCode,
|
||||
public readonly destination: PortCode,
|
||||
public readonly containerType: ContainerType,
|
||||
// Pricing
|
||||
public readonly freight: FreightPricing,
|
||||
public readonly fob: FobCharges,
|
||||
public readonly dgSurcharge: DgSurchargeInfo,
|
||||
// Metadata
|
||||
public readonly remarks: string,
|
||||
public readonly frequency: FrequencyType,
|
||||
public readonly volumeRange: VolumeRange,
|
||||
public readonly weightRange: WeightRange,
|
||||
public readonly palletCount: number,
|
||||
public readonly pricing: RatePricing,
|
||||
public readonly currency: string, // Primary currency (USD or EUR)
|
||||
public readonly surcharges: SurchargeCollection,
|
||||
public readonly transitDays: number,
|
||||
public readonly validity: DateRange
|
||||
) {
|
||||
@ -71,56 +62,178 @@ export class CsvRate {
|
||||
}
|
||||
|
||||
private validate(): void {
|
||||
if (!this.companyName?.trim()) throw new Error('Company name is required');
|
||||
if (!this.companyEmail?.trim()) throw new Error('Company email is required');
|
||||
if (this.transitDays <= 0) throw new Error('Transit days must be positive');
|
||||
if (this.freight.freightMinimum < 0) throw new Error('Freight minimum cannot be negative');
|
||||
if (this.fob.fobHandling < 0) throw new Error('FOB handling cannot be negative');
|
||||
if (!this.companyName || this.companyName.trim().length === 0) {
|
||||
throw new Error('Company name is required');
|
||||
}
|
||||
|
||||
if (!this.companyEmail || this.companyEmail.trim().length === 0) {
|
||||
throw new Error('Company email is required');
|
||||
}
|
||||
|
||||
if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) {
|
||||
throw new Error('Volume range cannot be negative');
|
||||
}
|
||||
|
||||
if (this.volumeRange.minCBM > this.volumeRange.maxCBM) {
|
||||
throw new Error('Min volume cannot be greater than max volume');
|
||||
}
|
||||
|
||||
if (this.weightRange.minKG < 0 || this.weightRange.maxKG < 0) {
|
||||
throw new Error('Weight range cannot be negative');
|
||||
}
|
||||
|
||||
if (this.weightRange.minKG > this.weightRange.maxKG) {
|
||||
throw new Error('Min weight cannot be greater than max weight');
|
||||
}
|
||||
|
||||
if (this.palletCount < 0) {
|
||||
throw new Error('Pallet count cannot be negative');
|
||||
}
|
||||
|
||||
if (this.pricing.pricePerCBM < 0 || this.pricing.pricePerKG < 0) {
|
||||
throw new Error('Prices cannot be negative');
|
||||
}
|
||||
|
||||
if (this.transitDays <= 0) {
|
||||
throw new Error('Transit days must be positive');
|
||||
}
|
||||
|
||||
if (this.currency !== 'USD' && this.currency !== 'EUR') {
|
||||
throw new Error('Currency must be USD or EUR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total price for given volume and weight
|
||||
*
|
||||
* Business Logic:
|
||||
* 1. Calculate volume-based price: volumeCBM * pricePerCBM
|
||||
* 2. Calculate weight-based price: weightKG * pricePerKG
|
||||
* 3. Take the maximum (freight class rule)
|
||||
* 4. Add surcharges
|
||||
*/
|
||||
calculatePrice(volume: Volume): Money {
|
||||
// Freight class rule: max(volume price, weight price)
|
||||
const freightPrice = volume.calculateFreightPrice(
|
||||
this.pricing.pricePerCBM,
|
||||
this.pricing.pricePerKG
|
||||
);
|
||||
|
||||
// Create Money object in the rate's currency
|
||||
let totalPrice = Money.create(freightPrice, this.currency);
|
||||
|
||||
// Add surcharges in the same currency
|
||||
const surchargeTotal = this.surcharges.getTotalAmount(this.currency);
|
||||
totalPrice = totalPrice.add(surchargeTotal);
|
||||
|
||||
return totalPrice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get price in specific currency (USD or EUR)
|
||||
*/
|
||||
getPriceInCurrency(volume: Volume, targetCurrency: 'USD' | 'EUR'): Money {
|
||||
const price = this.calculatePrice(volume);
|
||||
|
||||
// If already in target currency, return as-is
|
||||
if (price.getCurrency() === targetCurrency) {
|
||||
return price;
|
||||
}
|
||||
|
||||
// Otherwise, use the pre-calculated base price in target currency
|
||||
// and recalculate proportionally
|
||||
const basePriceInPrimaryCurrency =
|
||||
this.currency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
|
||||
|
||||
const basePriceInTargetCurrency =
|
||||
targetCurrency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
|
||||
|
||||
// Calculate conversion ratio
|
||||
const ratio = basePriceInTargetCurrency.getAmount() / basePriceInPrimaryCurrency.getAmount();
|
||||
|
||||
// Apply ratio to calculated price
|
||||
const convertedAmount = price.getAmount() * ratio;
|
||||
return Money.create(convertedAmount, targetCurrency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rate is valid for a specific date
|
||||
*/
|
||||
isValidForDate(date: Date): boolean {
|
||||
return this.validity.contains(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rate is currently valid (today is within validity period)
|
||||
*/
|
||||
isCurrentlyValid(): boolean {
|
||||
return this.validity.isCurrentRange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if volume and weight match this rate's range
|
||||
*/
|
||||
matchesVolume(volume: Volume): boolean {
|
||||
return volume.isWithinRange(
|
||||
this.volumeRange.minCBM,
|
||||
this.volumeRange.maxCBM,
|
||||
this.weightRange.minKG,
|
||||
this.weightRange.maxKG
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pallet count matches
|
||||
* 0 means "any pallet count" (flexible)
|
||||
* Otherwise must match exactly or be within range
|
||||
*/
|
||||
matchesPalletCount(palletCount: number): boolean {
|
||||
// If rate has 0 pallets, it's flexible
|
||||
if (this.palletCount === 0) {
|
||||
return true;
|
||||
}
|
||||
// Otherwise must match exactly
|
||||
return this.palletCount === palletCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rate matches a specific route
|
||||
*/
|
||||
matchesRoute(origin: PortCode, destination: PortCode): boolean {
|
||||
return this.originCode.equals(origin) && this.destinationCode.equals(destination);
|
||||
return this.origin.equals(origin) && this.destination.equals(destination);
|
||||
}
|
||||
|
||||
isDgAccepted(): boolean {
|
||||
return this.dgSurcharge.dgSurchargeRate !== 'NOT ACCEPTED';
|
||||
/**
|
||||
* Check if rate has separate surcharges
|
||||
*/
|
||||
hasSurcharges(): boolean {
|
||||
return !this.surcharges.isEmpty();
|
||||
}
|
||||
|
||||
isDgOnRequest(): boolean {
|
||||
return this.dgSurcharge.dgSurchargeRate === 'ON REQUEST';
|
||||
/**
|
||||
* Get surcharge details as formatted string
|
||||
*/
|
||||
getSurchargeDetails(): string {
|
||||
return this.surcharges.getDetails();
|
||||
}
|
||||
|
||||
isDirectRoute(): boolean {
|
||||
return this.routing.trim().toLowerCase() === 'direct';
|
||||
}
|
||||
|
||||
getFrequencyScore(): number {
|
||||
switch (this.frequency) {
|
||||
case 'Weekly':
|
||||
return 4;
|
||||
case 'Bi-Weekly':
|
||||
return 3;
|
||||
case 'Bi-Monthly':
|
||||
return 2;
|
||||
case 'Monthly':
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
/**
|
||||
* Check if this is an "all-in" rate (no separate surcharges)
|
||||
*/
|
||||
isAllInPrice(): boolean {
|
||||
return this.surcharges.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get route description
|
||||
*/
|
||||
getRouteDescription(): string {
|
||||
return `${this.originCode.getValue()} → ${this.destinationCode.getValue()}`;
|
||||
return `${this.origin.getValue()} → ${this.destination.getValue()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get company and route summary
|
||||
*/
|
||||
getSummary(): string {
|
||||
return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`;
|
||||
}
|
||||
|
||||
@ -10,8 +10,6 @@
|
||||
* - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER)
|
||||
*/
|
||||
|
||||
import { DEFAULT_LOCALE, Locale } from '../value-objects/locale.vo';
|
||||
|
||||
export enum OrganizationType {
|
||||
FREIGHT_FORWARDER = 'FREIGHT_FORWARDER',
|
||||
CARRIER = 'CARRIER',
|
||||
@ -49,7 +47,6 @@ export interface OrganizationProps {
|
||||
siret?: string;
|
||||
siretVerified: boolean;
|
||||
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
|
||||
defaultLanguage: Locale;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
isActive: boolean;
|
||||
@ -66,13 +63,9 @@ export class Organization {
|
||||
* Factory method to create a new Organization
|
||||
*/
|
||||
static create(
|
||||
props: Omit<
|
||||
OrganizationProps,
|
||||
'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge' | 'defaultLanguage'
|
||||
> & {
|
||||
props: Omit<OrganizationProps, 'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge'> & {
|
||||
siretVerified?: boolean;
|
||||
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
|
||||
defaultLanguage?: Locale;
|
||||
}
|
||||
): Organization {
|
||||
const now = new Date();
|
||||
@ -101,7 +94,6 @@ export class Organization {
|
||||
...props,
|
||||
siretVerified: props.siretVerified ?? false,
|
||||
statusBadge: props.statusBadge ?? 'none',
|
||||
defaultLanguage: props.defaultLanguage ?? DEFAULT_LOCALE,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
@ -196,15 +188,6 @@ export class Organization {
|
||||
return this.props.isActive;
|
||||
}
|
||||
|
||||
get defaultLanguage(): Locale {
|
||||
return this.props.defaultLanguage;
|
||||
}
|
||||
|
||||
updateDefaultLanguage(locale: Locale): void {
|
||||
this.props.defaultLanguage = locale;
|
||||
this.props.updatedAt = new Date();
|
||||
}
|
||||
|
||||
// Business methods
|
||||
isCarrier(): boolean {
|
||||
return this.props.type === OrganizationType.CARRIER;
|
||||
|
||||
@ -10,8 +10,6 @@
|
||||
* - Role-based access control (Admin, Manager, User, Viewer)
|
||||
*/
|
||||
|
||||
import { DEFAULT_LOCALE, Locale } from '../value-objects/locale.vo';
|
||||
|
||||
export enum UserRole {
|
||||
ADMIN = 'ADMIN', // Full system access
|
||||
MANAGER = 'MANAGER', // Manage bookings and users within organization
|
||||
@ -32,7 +30,6 @@ export interface UserProps {
|
||||
isEmailVerified: boolean;
|
||||
isActive: boolean;
|
||||
lastLoginAt?: Date;
|
||||
preferredLanguage: Locale;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -50,13 +47,8 @@ export class User {
|
||||
static create(
|
||||
props: Omit<
|
||||
UserProps,
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
| 'isEmailVerified'
|
||||
| 'isActive'
|
||||
| 'lastLoginAt'
|
||||
| 'preferredLanguage'
|
||||
> & { preferredLanguage?: Locale }
|
||||
'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'
|
||||
>
|
||||
): User {
|
||||
const now = new Date();
|
||||
|
||||
@ -67,7 +59,6 @@ export class User {
|
||||
|
||||
return new User({
|
||||
...props,
|
||||
preferredLanguage: props.preferredLanguage ?? DEFAULT_LOCALE,
|
||||
isEmailVerified: false,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
@ -151,15 +142,6 @@ export class User {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
get preferredLanguage(): Locale {
|
||||
return this.props.preferredLanguage;
|
||||
}
|
||||
|
||||
updatePreferredLanguage(locale: Locale): void {
|
||||
this.props.preferredLanguage = locale;
|
||||
this.props.updatedAt = new Date();
|
||||
}
|
||||
|
||||
// Business methods
|
||||
has2FAEnabled(): boolean {
|
||||
return !!this.props.totpSecret;
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
/**
|
||||
* DomainException (Base)
|
||||
*
|
||||
* Base class for all translatable domain exceptions.
|
||||
* Exceptions carry an i18n key + optional args so the application-layer
|
||||
* exception filter can translate them into the caller's locale at the HTTP
|
||||
* response boundary.
|
||||
*
|
||||
* Subclasses should:
|
||||
* - Pass an i18nKey (e.g. 'error.PORT_NOT_FOUND')
|
||||
* - Pass i18nArgs for interpolation (e.g. { portCode })
|
||||
* - Optionally override `status` (HTTP status, default 400)
|
||||
*/
|
||||
|
||||
export type I18nArgs = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
export abstract class DomainException extends Error {
|
||||
public readonly i18nKey: string;
|
||||
public readonly i18nArgs: I18nArgs;
|
||||
public readonly status: number;
|
||||
|
||||
constructor(i18nKey: string, i18nArgs: I18nArgs = {}, fallbackMessage?: string, status = 400) {
|
||||
super(fallbackMessage ?? i18nKey);
|
||||
this.i18nKey = i18nKey;
|
||||
this.i18nArgs = i18nArgs;
|
||||
this.status = status;
|
||||
this.name = this.constructor.name;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@
|
||||
* All domain exceptions for the Xpeditis platform
|
||||
*/
|
||||
|
||||
export * from './domain.exception';
|
||||
export * from './invalid-port-code.exception';
|
||||
export * from './invalid-rate-quote.exception';
|
||||
export * from './carrier-timeout.exception';
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
/**
|
||||
* PortNotFoundException
|
||||
*
|
||||
* Thrown when a port is not found in the database.
|
||||
* Thrown when a port is not found in the database
|
||||
*/
|
||||
|
||||
import { DomainException } from './domain.exception';
|
||||
|
||||
export class PortNotFoundException extends DomainException {
|
||||
export class PortNotFoundException extends Error {
|
||||
constructor(public readonly portCode: string) {
|
||||
super('error.PORT_NOT_FOUND', { portCode }, `Port not found: ${portCode}`, 404);
|
||||
super(`Port not found: ${portCode}`);
|
||||
this.name = 'PortNotFoundException';
|
||||
Object.setPrototypeOf(this, PortNotFoundException.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,73 +1,160 @@
|
||||
import { CsvRate } from '../../entities/csv-rate.entity';
|
||||
import { ServiceLevel } from '../../services/rate-offer-generator.service';
|
||||
import { PriceBreakdown } from '../../services/csv-rate-price-calculator.service';
|
||||
|
||||
export { PriceBreakdown };
|
||||
|
||||
/**
|
||||
* Filters for narrowing CSV rate search results.
|
||||
* Volume/weight range filters removed — new schema has no per-rate volume limits.
|
||||
* Advanced Rate Search Filters
|
||||
*
|
||||
* Filters for narrowing down rate search results
|
||||
*/
|
||||
export interface RateSearchFilters {
|
||||
companies?: string[];
|
||||
// Company filters
|
||||
companies?: string[]; // List of company names to include
|
||||
|
||||
// Price filter (applied to totalPriceForSorting)
|
||||
// Volume/Weight filters
|
||||
minVolumeCBM?: number;
|
||||
maxVolumeCBM?: number;
|
||||
minWeightKG?: number;
|
||||
maxWeightKG?: number;
|
||||
palletCount?: number; // Exact pallet count (0 = any)
|
||||
|
||||
// Price filters
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
currency?: 'USD' | 'EUR';
|
||||
currency?: 'USD' | 'EUR'; // Preferred currency for filtering
|
||||
|
||||
// Transit filter
|
||||
// Transit filters
|
||||
minTransitDays?: number;
|
||||
maxTransitDays?: number;
|
||||
|
||||
// Route filter
|
||||
onlyDirect?: boolean; // Only show "Direct" routing
|
||||
// Container type filters
|
||||
containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC']
|
||||
|
||||
// Container type filter
|
||||
containerTypes?: string[];
|
||||
// Surcharge filters
|
||||
onlyAllInPrices?: boolean; // Only show rates without separate surcharges
|
||||
|
||||
// Date filter
|
||||
departureDate?: Date;
|
||||
// Date filters
|
||||
departureDate?: Date; // Filter by validity for specific date
|
||||
|
||||
// Service level filter (for offers endpoint)
|
||||
serviceLevels?: ServiceLevel[];
|
||||
|
||||
// DG filter
|
||||
excludeNonDgRoutes?: boolean; // Only show DG-accepted routes
|
||||
// Service level filter
|
||||
serviceLevels?: ServiceLevel[]; // Filter by service level (RAPID, STANDARD, ECONOMIC)
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV Rate Search Input
|
||||
*
|
||||
* Parameters for searching rates in CSV system
|
||||
*/
|
||||
export interface CsvRateSearchInput {
|
||||
origin: string; // UN/LOCODE
|
||||
destination: string; // UN/LOCODE
|
||||
volumeCBM: number;
|
||||
weightKG: number;
|
||||
containerType?: string;
|
||||
origin: string; // Port code (UN/LOCODE)
|
||||
destination: string; // Port code (UN/LOCODE)
|
||||
volumeCBM: number; // Volume in cubic meters
|
||||
weightKG: number; // Weight in kilograms
|
||||
palletCount?: number; // Number of pallets (0 if none)
|
||||
containerType?: string; // Optional container type filter
|
||||
filters?: RateSearchFilters; // Advanced filters
|
||||
|
||||
// Service requirements for price calculation
|
||||
hasDangerousGoods?: boolean;
|
||||
filters?: RateSearchFilters;
|
||||
requiresSpecialHandling?: boolean;
|
||||
requiresTailgate?: boolean;
|
||||
requiresStraps?: boolean;
|
||||
requiresThermalCover?: boolean;
|
||||
hasRegulatedProducts?: boolean;
|
||||
requiresAppointment?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surcharge Item - Individual fee or charge
|
||||
*/
|
||||
export interface SurchargeItem {
|
||||
code: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Price Breakdown - Detailed pricing calculation
|
||||
*/
|
||||
export interface PriceBreakdown {
|
||||
basePrice: number;
|
||||
volumeCharge: number;
|
||||
weightCharge: number;
|
||||
palletCharge: number;
|
||||
surcharges: SurchargeItem[];
|
||||
totalSurcharges: number;
|
||||
totalPrice: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV Rate Search Result
|
||||
*
|
||||
* Single rate result with calculated price
|
||||
*/
|
||||
export interface CsvRateSearchResult {
|
||||
rate: CsvRate;
|
||||
priceBreakdown: PriceBreakdown;
|
||||
calculatedPrice: {
|
||||
usd: number;
|
||||
eur: number;
|
||||
primaryCurrency: string;
|
||||
};
|
||||
priceBreakdown: PriceBreakdown; // Detailed price calculation
|
||||
source: 'CSV';
|
||||
matchScore: number;
|
||||
serviceLevel?: ServiceLevel;
|
||||
priceMultiplier?: number;
|
||||
originalTransitDays?: number;
|
||||
adjustedTransitDays?: number;
|
||||
matchScore: number; // 0-100, how well it matches filters
|
||||
serviceLevel?: ServiceLevel; // Service level (RAPID, STANDARD, ECONOMIC) if offers are generated
|
||||
originalPrice?: {
|
||||
usd: number;
|
||||
eur: number;
|
||||
}; // Original price before service level adjustment
|
||||
originalTransitDays?: number; // Original transit days before service level adjustment
|
||||
adjustedTransitDays?: number; // Adjusted transit days (for service level offers)
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV Rate Search Output
|
||||
*
|
||||
* Results from CSV rate search
|
||||
*/
|
||||
export interface CsvRateSearchOutput {
|
||||
results: CsvRateSearchResult[];
|
||||
totalResults: number;
|
||||
searchedFiles: string[];
|
||||
searchedFiles: string[]; // CSV files searched
|
||||
searchedAt: Date;
|
||||
appliedFilters: RateSearchFilters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search CSV Rates Port (Input Port)
|
||||
*
|
||||
* Use case for searching rates in CSV-based system
|
||||
* Supports advanced filters for precise rate matching
|
||||
*/
|
||||
export interface SearchCsvRatesPort {
|
||||
/**
|
||||
* Execute CSV rate search with filters
|
||||
* @param input - Search parameters and filters
|
||||
* @returns Matching rates with calculated prices
|
||||
*/
|
||||
execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
|
||||
|
||||
/**
|
||||
* Execute CSV rate search with service level offers generation
|
||||
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
|
||||
* @param input - Search parameters and filters
|
||||
* @returns Matching rates with 3 service level variants each
|
||||
*/
|
||||
executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
|
||||
|
||||
/**
|
||||
* Get available companies in CSV system
|
||||
* @returns List of company names that have CSV rates
|
||||
*/
|
||||
getAvailableCompanies(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Get available container types in CSV system
|
||||
* @returns List of container types available
|
||||
*/
|
||||
getAvailableContainerTypes(): Promise<string[]>;
|
||||
}
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { BlogPost, BlogPostCategory, BlogPostStatus } from '@domain/entities/blog-post.entity';
|
||||
|
||||
export const BLOG_POST_REPOSITORY = 'BlogPostRepository';
|
||||
|
||||
export interface BlogPostFilters {
|
||||
status?: BlogPostStatus;
|
||||
category?: BlogPostCategory;
|
||||
search?: string;
|
||||
isFeatured?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface BlogPostRepository {
|
||||
save(post: BlogPost): Promise<BlogPost>;
|
||||
findById(id: string): Promise<BlogPost | null>;
|
||||
findBySlug(slug: string): Promise<BlogPost | null>;
|
||||
findByFilters(filters: BlogPostFilters): Promise<BlogPost[]>;
|
||||
count(filters: BlogPostFilters): Promise<number>;
|
||||
delete(id: string): Promise<void>;
|
||||
slugExists(slug: string, excludeId?: string): Promise<boolean>;
|
||||
}
|
||||
@ -66,9 +66,4 @@ export interface StoragePort {
|
||||
* List objects in a bucket
|
||||
*/
|
||||
list(bucket: string, prefix?: string): Promise<StorageObject[]>;
|
||||
|
||||
/**
|
||||
* Ensure a bucket exists, creating it if it does not
|
||||
*/
|
||||
ensureBucket(bucket: string): Promise<void>;
|
||||
}
|
||||
|
||||
@ -3,152 +3,217 @@ import { CsvRate } from '../entities/csv-rate.entity';
|
||||
export interface PriceCalculationParams {
|
||||
volumeCBM: number;
|
||||
weightKG: number;
|
||||
hasDangerousGoods?: boolean;
|
||||
palletCount: number;
|
||||
hasDangerousGoods: boolean;
|
||||
requiresSpecialHandling: boolean;
|
||||
requiresTailgate: boolean;
|
||||
requiresStraps: boolean;
|
||||
requiresThermalCover: boolean;
|
||||
hasRegulatedProducts: boolean;
|
||||
requiresAppointment: boolean;
|
||||
}
|
||||
|
||||
export interface FobBreakdown {
|
||||
documentation: number;
|
||||
isps: number;
|
||||
handling: number;
|
||||
solas: number;
|
||||
customs: number;
|
||||
ams_aci: number;
|
||||
isf5: number;
|
||||
dgAdmin: number;
|
||||
}
|
||||
|
||||
export type DgSurchargeStatus = 'computed' | 'on_request' | 'not_accepted';
|
||||
|
||||
export interface PriceBreakdown {
|
||||
// Freight (in freightCurrency)
|
||||
freightCharge: number;
|
||||
freightCurrency: string;
|
||||
basePrice: number;
|
||||
volumeCharge: number;
|
||||
weightCharge: number;
|
||||
palletCharge: number;
|
||||
surcharges: SurchargeItem[];
|
||||
totalSurcharges: number;
|
||||
totalPrice: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
// FOB charges (in fobCurrency)
|
||||
fobFixed: number; // doc + ISPS + solas + customs + AMS_ACI + ISF5
|
||||
fobHandling: number;
|
||||
fobDG: number; // fobDGAdmin only if DG
|
||||
fobCurrency: string;
|
||||
fobBreakdown: FobBreakdown;
|
||||
|
||||
// DG surcharge (fobCurrency or dgSurchargeCurrency)
|
||||
dgSurchargeAmount: number | null; // null when on_request or not_accepted
|
||||
dgSurchargeCurrency: string;
|
||||
dgSurchargeStatus: DgSurchargeStatus;
|
||||
|
||||
// Totals (each in their own currency)
|
||||
totalFreight: number; // = freightCharge in freightCurrency
|
||||
totalFob: number; // = fobFixed + fobHandling + fobDG + dgSurcharge in fobCurrency
|
||||
|
||||
// Used for sorting/comparison only — naive sum treating both currencies as equal
|
||||
// Callers should be aware of potential currency mismatch
|
||||
totalPriceForSorting: number;
|
||||
primaryCurrency: string;
|
||||
export interface SurchargeItem {
|
||||
code: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates price for a CSV rate given volume and weight.
|
||||
*
|
||||
* Formula:
|
||||
* Fret = max(freightRatePerCBM × V, freightMinimum)
|
||||
* Handling = max(fobHandling × max(V, W_tonnes), fobHandlingMinimum) [if unit=W]
|
||||
* = max(fobHandling × V, fobHandlingMinimum) [if unit=UP]
|
||||
* FOB fixed = doc + ISPS + solas + customs + AMS_ACI + ISF5
|
||||
* Total = Fret (freightCurrency) + FOB_fixed + Handling (fobCurrency)
|
||||
* Service de calcul de prix pour les tarifs CSV
|
||||
* Calcule le prix total basé sur le volume, poids, palettes et services additionnels
|
||||
*/
|
||||
export class CsvRatePriceCalculatorService {
|
||||
/**
|
||||
* Calcule le prix total pour un tarif CSV donné
|
||||
*/
|
||||
calculatePrice(rate: CsvRate, params: PriceCalculationParams): PriceBreakdown {
|
||||
const V = params.volumeCBM;
|
||||
const W = params.weightKG / 1000; // convert KG → tonnes for W unit
|
||||
const isDG = params.hasDangerousGoods ?? false;
|
||||
// 1. Prix de base
|
||||
const basePrice = rate.pricing.basePriceUSD.getAmount();
|
||||
|
||||
// 1. Freight charge
|
||||
const freightCharge =
|
||||
rate.freight.freightRatePerCBM > 0
|
||||
? Math.max(rate.freight.freightRatePerCBM * V, rate.freight.freightMinimum)
|
||||
: rate.freight.freightMinimum;
|
||||
// 2. Frais au volume (USD par CBM)
|
||||
const volumeCharge = rate.pricing.pricePerCBM * params.volumeCBM;
|
||||
|
||||
// 2. Handling — "W" = tonne revenue (max of CBM and tonnes), "UP" = per CBM
|
||||
const handlingBase = rate.fob.fobHandlingUnit === 'W' ? Math.max(V, W) : V;
|
||||
const fobHandling = Math.max(rate.fob.fobHandling * handlingBase, rate.fob.fobHandlingMinimum);
|
||||
// 3. Frais au poids (USD par KG)
|
||||
const weightCharge = rate.pricing.pricePerKG * params.weightKG;
|
||||
|
||||
// 3. FOB fixed charges
|
||||
const fobFixed =
|
||||
rate.fob.fobDocumentation +
|
||||
rate.fob.fobISPS +
|
||||
rate.fob.fobSolas +
|
||||
rate.fob.fobCustoms +
|
||||
rate.fob.fobAMS_ACI +
|
||||
rate.fob.fobISF5;
|
||||
// 4. Frais de palettes (25 USD par palette)
|
||||
const palletCharge = params.palletCount * 25;
|
||||
|
||||
// 4. DG admin (FOB currency, only if DG)
|
||||
const fobDG = isDG ? rate.fob.fobDGAdmin : 0;
|
||||
// 5. Surcharges standard du CSV
|
||||
const standardSurcharges = this.parseStandardSurcharges(rate.getSurchargeDetails(), params);
|
||||
|
||||
// 5. DG surcharge (own currency, only if DG)
|
||||
let dgSurchargeAmount: number | null = null;
|
||||
let dgSurchargeStatus: DgSurchargeStatus = 'computed';
|
||||
// 6. Surcharges additionnelles basées sur les services
|
||||
const additionalSurcharges = this.calculateAdditionalSurcharges(params);
|
||||
|
||||
if (isDG) {
|
||||
const dgRate = rate.dgSurcharge.dgSurchargeRate;
|
||||
if (dgRate === 'NOT ACCEPTED') {
|
||||
dgSurchargeStatus = 'not_accepted';
|
||||
} else if (dgRate === 'ON REQUEST') {
|
||||
dgSurchargeStatus = 'on_request';
|
||||
} else {
|
||||
dgSurchargeStatus = 'computed';
|
||||
const dgNum = typeof dgRate === 'number' ? dgRate : parseFloat(String(dgRate));
|
||||
let rawDG = 0;
|
||||
switch (rate.dgSurcharge.dgSurchargeUnit) {
|
||||
case 'UP':
|
||||
rawDG = dgNum * V;
|
||||
break;
|
||||
case 'LS':
|
||||
rawDG = dgNum;
|
||||
break;
|
||||
case '%':
|
||||
rawDG = freightCharge * (dgNum / 100);
|
||||
break;
|
||||
}
|
||||
const dgMin =
|
||||
typeof rate.dgSurcharge.dgSurchargeMin === 'number' ? rate.dgSurcharge.dgSurchargeMin : 0;
|
||||
dgSurchargeAmount = Math.max(rawDG, dgMin);
|
||||
}
|
||||
}
|
||||
// 7. Total des surcharges
|
||||
const allSurcharges = [...standardSurcharges, ...additionalSurcharges];
|
||||
const totalSurcharges = allSurcharges.reduce((sum, s) => sum + s.amount, 0);
|
||||
|
||||
// 6. Total FOB (in fobCurrency)
|
||||
const totalFob = fobFixed + fobHandling + fobDG + (dgSurchargeAmount ?? 0);
|
||||
|
||||
// 7. Naive sum for sorting (ignores currency differences)
|
||||
const totalPriceForSorting = freightCharge + totalFob;
|
||||
// 8. Prix total
|
||||
const totalPrice = basePrice + volumeCharge + weightCharge + palletCharge + totalSurcharges;
|
||||
|
||||
return {
|
||||
freightCharge: round2(freightCharge),
|
||||
freightCurrency: rate.freight.freightCurrency,
|
||||
fobFixed: round2(fobFixed),
|
||||
fobHandling: round2(fobHandling),
|
||||
fobDG: round2(fobDG),
|
||||
fobCurrency: rate.fob.fobCurrency,
|
||||
fobBreakdown: {
|
||||
documentation: rate.fob.fobDocumentation,
|
||||
isps: rate.fob.fobISPS,
|
||||
handling: round2(fobHandling),
|
||||
solas: rate.fob.fobSolas,
|
||||
customs: rate.fob.fobCustoms,
|
||||
ams_aci: rate.fob.fobAMS_ACI,
|
||||
isf5: rate.fob.fobISF5,
|
||||
dgAdmin: isDG ? rate.fob.fobDGAdmin : 0,
|
||||
},
|
||||
dgSurchargeAmount: dgSurchargeAmount !== null ? round2(dgSurchargeAmount) : null,
|
||||
dgSurchargeCurrency: rate.dgSurcharge.dgSurchargeCurrency,
|
||||
dgSurchargeStatus,
|
||||
totalFreight: round2(freightCharge),
|
||||
totalFob: round2(totalFob),
|
||||
totalPriceForSorting: round2(totalPriceForSorting),
|
||||
primaryCurrency: rate.freight.freightCurrency,
|
||||
basePrice,
|
||||
volumeCharge,
|
||||
weightCharge,
|
||||
palletCharge,
|
||||
surcharges: allSurcharges,
|
||||
totalSurcharges,
|
||||
totalPrice: Math.round(totalPrice * 100) / 100, // Arrondi à 2 décimales
|
||||
currency: rate.currency || 'USD',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
/**
|
||||
* Parse les surcharges standard du format CSV
|
||||
* Format: "DOC:10 | ISPS:7 | HANDLING:20 W | DG_FEE:65"
|
||||
*/
|
||||
private parseStandardSurcharges(
|
||||
surchargeDetails: string | null,
|
||||
params: PriceCalculationParams
|
||||
): SurchargeItem[] {
|
||||
if (!surchargeDetails) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const surcharges: SurchargeItem[] = [];
|
||||
const items = surchargeDetails.split('|').map(s => s.trim());
|
||||
|
||||
for (const item of items) {
|
||||
const match = item.match(/^([A-Z_]+):(\d+(?:\.\d+)?)\s*([WP%]?)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, code, amountStr, type] = match;
|
||||
let amount = parseFloat(amountStr);
|
||||
let surchargeType: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE' = 'FIXED';
|
||||
|
||||
// Calcul selon le type
|
||||
if (type === 'W') {
|
||||
// Par poids (W = Weight)
|
||||
amount = amount * params.weightKG;
|
||||
surchargeType = 'PER_UNIT';
|
||||
} else if (type === 'P') {
|
||||
// Par palette
|
||||
amount = amount * params.palletCount;
|
||||
surchargeType = 'PER_UNIT';
|
||||
} else if (type === '%') {
|
||||
// Pourcentage (sera appliqué sur le total)
|
||||
surchargeType = 'PERCENTAGE';
|
||||
}
|
||||
|
||||
// Certaines surcharges ne s'appliquent que si certaines conditions sont remplies
|
||||
if (code === 'DG_FEE' && !params.hasDangerousGoods) {
|
||||
continue; // Skip DG fee si pas de marchandises dangereuses
|
||||
}
|
||||
|
||||
surcharges.push({
|
||||
code,
|
||||
description: this.getSurchargeDescription(code),
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
type: surchargeType,
|
||||
});
|
||||
}
|
||||
|
||||
return surcharges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les surcharges additionnelles basées sur les services demandés
|
||||
*/
|
||||
private calculateAdditionalSurcharges(params: PriceCalculationParams): SurchargeItem[] {
|
||||
const surcharges: SurchargeItem[] = [];
|
||||
|
||||
if (params.requiresSpecialHandling) {
|
||||
surcharges.push({
|
||||
code: 'SPECIAL_HANDLING',
|
||||
description: 'Manutention particulière',
|
||||
amount: 75,
|
||||
type: 'FIXED',
|
||||
});
|
||||
}
|
||||
|
||||
if (params.requiresTailgate) {
|
||||
surcharges.push({
|
||||
code: 'TAILGATE',
|
||||
description: 'Hayon élévateur',
|
||||
amount: 50,
|
||||
type: 'FIXED',
|
||||
});
|
||||
}
|
||||
|
||||
if (params.requiresStraps) {
|
||||
surcharges.push({
|
||||
code: 'STRAPS',
|
||||
description: 'Sangles de sécurité',
|
||||
amount: 30,
|
||||
type: 'FIXED',
|
||||
});
|
||||
}
|
||||
|
||||
if (params.requiresThermalCover) {
|
||||
surcharges.push({
|
||||
code: 'THERMAL_COVER',
|
||||
description: 'Couverture thermique',
|
||||
amount: 100,
|
||||
type: 'FIXED',
|
||||
});
|
||||
}
|
||||
|
||||
if (params.hasRegulatedProducts) {
|
||||
surcharges.push({
|
||||
code: 'REGULATED_PRODUCTS',
|
||||
description: 'Produits réglementés',
|
||||
amount: 80,
|
||||
type: 'FIXED',
|
||||
});
|
||||
}
|
||||
|
||||
if (params.requiresAppointment) {
|
||||
surcharges.push({
|
||||
code: 'APPOINTMENT',
|
||||
description: 'Livraison sur rendez-vous',
|
||||
amount: 40,
|
||||
type: 'FIXED',
|
||||
});
|
||||
}
|
||||
|
||||
return surcharges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la description d'un code de surcharge standard
|
||||
*/
|
||||
private getSurchargeDescription(code: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
DOC: 'Documentation fee',
|
||||
ISPS: 'ISPS Security',
|
||||
HANDLING: 'Handling charges',
|
||||
SOLAS: 'SOLAS VGM',
|
||||
CUSTOMS: 'Customs clearance',
|
||||
AMS_ACI: 'AMS/ACI filing',
|
||||
DG_FEE: 'Dangerous goods fee',
|
||||
BAF: 'Bunker Adjustment Factor',
|
||||
CAF: 'Currency Adjustment Factor',
|
||||
THC: 'Terminal Handling Charges',
|
||||
BL_FEE: 'Bill of Lading fee',
|
||||
TELEX_RELEASE: 'Telex release',
|
||||
ORIGIN_CHARGES: 'Origin charges',
|
||||
DEST_CHARGES: 'Destination charges',
|
||||
};
|
||||
|
||||
return descriptions[code] || code.replace(/_/g, ' ');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { CsvRate } from '../entities/csv-rate.entity';
|
||||
import { PortCode } from '../value-objects/port-code.vo';
|
||||
import { ContainerType } from '../value-objects/container-type.vo';
|
||||
import { Volume } from '../value-objects/volume.vo';
|
||||
import {
|
||||
SearchCsvRatesPort,
|
||||
CsvRateSearchInput,
|
||||
@ -10,8 +11,11 @@ import {
|
||||
} from '@domain/ports/in/search-csv-rates.port';
|
||||
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
|
||||
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
|
||||
import { RateOfferGeneratorService } from './rate-offer-generator.service';
|
||||
import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service';
|
||||
|
||||
/**
|
||||
* Config Metadata Interface (to avoid circular dependency)
|
||||
*/
|
||||
interface CsvRateConfig {
|
||||
companyName: string;
|
||||
csvFilePath: string;
|
||||
@ -21,10 +25,21 @@ interface CsvRateConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Config Repository Port (simplified interface)
|
||||
*/
|
||||
export interface CsvRateConfigRepositoryPort {
|
||||
findActiveConfigs(): Promise<CsvRateConfig[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV Rate Search Service
|
||||
*
|
||||
* Domain service implementing CSV rate search use case.
|
||||
* Applies business rules for matching rates and filtering.
|
||||
*
|
||||
* Pure domain logic - no framework dependencies.
|
||||
*/
|
||||
export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
private readonly priceCalculator: CsvRatePriceCalculatorService;
|
||||
private readonly offerGenerator: RateOfferGeneratorService;
|
||||
@ -39,39 +54,63 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
|
||||
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
||||
const searchStartTime = new Date();
|
||||
|
||||
// Parse and validate input
|
||||
const origin = PortCode.create(input.origin);
|
||||
const destination = PortCode.create(input.destination);
|
||||
const volume = new Volume(input.volumeCBM, input.weightKG);
|
||||
const palletCount = input.palletCount ?? 0;
|
||||
|
||||
// Load all CSV rates
|
||||
const allRates = await this.loadAllRates();
|
||||
let matchingRates = this.filterByRoute(allRates, origin, destination);
|
||||
|
||||
// Apply route and volume matching
|
||||
let matchingRates = this.filterByRoute(allRates, origin, destination);
|
||||
matchingRates = this.filterByVolume(matchingRates, volume);
|
||||
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
|
||||
|
||||
// Apply container type filter if specified
|
||||
if (input.containerType) {
|
||||
const containerType = ContainerType.create(input.containerType);
|
||||
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
|
||||
}
|
||||
|
||||
// Apply advanced filters
|
||||
if (input.filters) {
|
||||
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, input);
|
||||
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
|
||||
}
|
||||
|
||||
// Calculate prices and create results
|
||||
const results: CsvRateSearchResult[] = matchingRates.map(rate => {
|
||||
// Calculate detailed price breakdown
|
||||
const priceBreakdown = this.priceCalculator.calculatePrice(rate, {
|
||||
volumeCBM: input.volumeCBM,
|
||||
weightKG: input.weightKG,
|
||||
palletCount: input.palletCount ?? 0,
|
||||
hasDangerousGoods: input.hasDangerousGoods ?? false,
|
||||
requiresSpecialHandling: input.requiresSpecialHandling ?? false,
|
||||
requiresTailgate: input.requiresTailgate ?? false,
|
||||
requiresStraps: input.requiresStraps ?? false,
|
||||
requiresThermalCover: input.requiresThermalCover ?? false,
|
||||
hasRegulatedProducts: input.hasRegulatedProducts ?? false,
|
||||
requiresAppointment: input.requiresAppointment ?? false,
|
||||
});
|
||||
|
||||
return {
|
||||
rate,
|
||||
calculatedPrice: {
|
||||
usd: priceBreakdown.totalPrice,
|
||||
eur: priceBreakdown.totalPrice, // TODO: Add currency conversion
|
||||
primaryCurrency: priceBreakdown.currency,
|
||||
},
|
||||
priceBreakdown,
|
||||
source: 'CSV' as const,
|
||||
matchScore: this.calculateMatchScore(rate),
|
||||
matchScore: this.calculateMatchScore(rate, input),
|
||||
};
|
||||
});
|
||||
|
||||
results.sort(
|
||||
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
|
||||
);
|
||||
// Sort by total price (ascending)
|
||||
results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
|
||||
|
||||
return {
|
||||
results,
|
||||
@ -83,67 +122,101 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search with service level offers — returns 3 variants per rate (ECONOMIC / STANDARD / RAPID).
|
||||
* Price multipliers (0.85 / 1.0 / 1.2) are applied to totalPriceForSorting.
|
||||
* Execute CSV rate search with service level offers generation
|
||||
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
|
||||
*/
|
||||
async executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
||||
const searchStartTime = new Date();
|
||||
|
||||
// Parse and validate input
|
||||
const origin = PortCode.create(input.origin);
|
||||
const destination = PortCode.create(input.destination);
|
||||
const volume = new Volume(input.volumeCBM, input.weightKG);
|
||||
const palletCount = input.palletCount ?? 0;
|
||||
|
||||
// Load all CSV rates
|
||||
const allRates = await this.loadAllRates();
|
||||
let matchingRates = this.filterByRoute(allRates, origin, destination);
|
||||
|
||||
// Apply route and volume matching
|
||||
let matchingRates = this.filterByRoute(allRates, origin, destination);
|
||||
matchingRates = this.filterByVolume(matchingRates, volume);
|
||||
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
|
||||
|
||||
// Apply container type filter if specified
|
||||
if (input.containerType) {
|
||||
const containerType = ContainerType.create(input.containerType);
|
||||
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
|
||||
}
|
||||
|
||||
// Apply advanced filters (before generating offers)
|
||||
if (input.filters) {
|
||||
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, input);
|
||||
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
|
||||
}
|
||||
|
||||
// Filter eligible rates for offer generation
|
||||
const eligibleRates = this.offerGenerator.filterEligibleRates(matchingRates);
|
||||
|
||||
// Generate 3 offers (RAPID, STANDARD, ECONOMIC) for each eligible rate
|
||||
const allOffers = this.offerGenerator.generateOffersForRates(eligibleRates);
|
||||
|
||||
// Convert offers to search results
|
||||
const results: CsvRateSearchResult[] = allOffers.map(offer => {
|
||||
// Calculate detailed price breakdown with adjusted prices
|
||||
const priceBreakdown = this.priceCalculator.calculatePrice(offer.rate, {
|
||||
volumeCBM: input.volumeCBM,
|
||||
weightKG: input.weightKG,
|
||||
palletCount: input.palletCount ?? 0,
|
||||
hasDangerousGoods: input.hasDangerousGoods ?? false,
|
||||
requiresSpecialHandling: input.requiresSpecialHandling ?? false,
|
||||
requiresTailgate: input.requiresTailgate ?? false,
|
||||
requiresStraps: input.requiresStraps ?? false,
|
||||
requiresThermalCover: input.requiresThermalCover ?? false,
|
||||
hasRegulatedProducts: input.hasRegulatedProducts ?? false,
|
||||
requiresAppointment: input.requiresAppointment ?? false,
|
||||
});
|
||||
|
||||
const multiplier = offer.priceMultiplier;
|
||||
const adjustedBreakdown = {
|
||||
...priceBreakdown,
|
||||
freightCharge: round2(priceBreakdown.freightCharge * multiplier),
|
||||
totalFreight: round2(priceBreakdown.totalFreight * multiplier),
|
||||
totalFob: round2(priceBreakdown.totalFob * multiplier),
|
||||
totalPriceForSorting: round2(priceBreakdown.totalPriceForSorting * multiplier),
|
||||
};
|
||||
// Apply service level price adjustment to the total price
|
||||
const adjustedTotalPrice =
|
||||
priceBreakdown.totalPrice *
|
||||
(offer.serviceLevel === ServiceLevel.RAPID
|
||||
? 1.2
|
||||
: offer.serviceLevel === ServiceLevel.ECONOMIC
|
||||
? 0.85
|
||||
: 1.0);
|
||||
|
||||
return {
|
||||
rate: offer.rate,
|
||||
priceBreakdown: adjustedBreakdown,
|
||||
calculatedPrice: {
|
||||
usd: adjustedTotalPrice,
|
||||
eur: adjustedTotalPrice, // TODO: Add currency conversion
|
||||
primaryCurrency: priceBreakdown.currency,
|
||||
},
|
||||
priceBreakdown: {
|
||||
...priceBreakdown,
|
||||
totalPrice: adjustedTotalPrice,
|
||||
},
|
||||
source: 'CSV' as const,
|
||||
matchScore: this.calculateMatchScore(offer.rate),
|
||||
matchScore: this.calculateMatchScore(offer.rate, input),
|
||||
serviceLevel: offer.serviceLevel,
|
||||
priceMultiplier: offer.priceMultiplier,
|
||||
originalPrice: {
|
||||
usd: offer.originalPriceUSD,
|
||||
eur: offer.originalPriceEUR,
|
||||
},
|
||||
originalTransitDays: offer.originalTransitDays,
|
||||
adjustedTransitDays: offer.adjustedTransitDays,
|
||||
};
|
||||
});
|
||||
|
||||
// Apply service level filter if specified
|
||||
let filteredResults = results;
|
||||
if (input.filters?.serviceLevels?.length) {
|
||||
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
|
||||
filteredResults = results.filter(
|
||||
r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
|
||||
);
|
||||
}
|
||||
|
||||
filteredResults.sort(
|
||||
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
|
||||
);
|
||||
// Sort by total price (ascending) - ECONOMIC first, then STANDARD, then RAPID
|
||||
filteredResults.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
|
||||
|
||||
return {
|
||||
results: filteredResults,
|
||||
@ -156,110 +229,197 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
|
||||
async getAvailableCompanies(): Promise<string[]> {
|
||||
const allRates = await this.loadAllRates();
|
||||
return [...new Set(allRates.map(r => r.companyName))].sort();
|
||||
const companies = new Set(allRates.map(rate => rate.companyName));
|
||||
return Array.from(companies).sort();
|
||||
}
|
||||
|
||||
async getAvailableContainerTypes(): Promise<string[]> {
|
||||
const allRates = await this.loadAllRates();
|
||||
return [...new Set(allRates.map(r => r.containerType.getValue()))].sort();
|
||||
const types = new Set(allRates.map(rate => rate.containerType.getValue()));
|
||||
return Array.from(types).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique origin port codes from CSV rates
|
||||
* Used to limit port selection to only those with available routes
|
||||
*/
|
||||
async getAvailableOrigins(): Promise<string[]> {
|
||||
const allRates = await this.loadAllRates();
|
||||
return [...new Set(allRates.map(r => r.originCode.getValue()))].sort();
|
||||
const origins = new Set(allRates.map(rate => rate.origin.getValue()));
|
||||
return Array.from(origins).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all destination port codes available for a given origin
|
||||
* Used to limit destination selection based on selected origin
|
||||
*/
|
||||
async getAvailableDestinations(origin: string): Promise<string[]> {
|
||||
const allRates = await this.loadAllRates();
|
||||
const originCode = PortCode.create(origin);
|
||||
return [
|
||||
...new Set(
|
||||
allRates.filter(r => r.originCode.equals(originCode)).map(r => r.destinationCode.getValue())
|
||||
),
|
||||
].sort();
|
||||
|
||||
const destinations = new Set(
|
||||
allRates
|
||||
.filter(rate => rate.origin.equals(originCode))
|
||||
.map(rate => rate.destination.getValue())
|
||||
);
|
||||
|
||||
return Array.from(destinations).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available routes (origin-destination pairs) from CSV rates
|
||||
* Returns a map of origin codes to their available destination codes
|
||||
*/
|
||||
async getAvailableRoutes(): Promise<Map<string, string[]>> {
|
||||
const allRates = await this.loadAllRates();
|
||||
const routeMap = new Map<string, Set<string>>();
|
||||
|
||||
allRates.forEach(rate => {
|
||||
const origin = rate.originCode.getValue();
|
||||
const destination = rate.destinationCode.getValue();
|
||||
if (!routeMap.has(origin)) routeMap.set(origin, new Set());
|
||||
const origin = rate.origin.getValue();
|
||||
const destination = rate.destination.getValue();
|
||||
|
||||
if (!routeMap.has(origin)) {
|
||||
routeMap.set(origin, new Set());
|
||||
}
|
||||
routeMap.get(origin)!.add(destination);
|
||||
});
|
||||
|
||||
// Convert Sets to sorted arrays
|
||||
const result = new Map<string, string[]>();
|
||||
routeMap.forEach((destinations, origin) => {
|
||||
result.set(origin, [...destinations].sort());
|
||||
result.set(origin, Array.from(destinations).sort());
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all rates from all CSV files
|
||||
*/
|
||||
private async loadAllRates(): Promise<CsvRate[]> {
|
||||
// If config repository is available, load rates with emails and company names from configs
|
||||
if (this.configRepository) {
|
||||
const configs = await this.configRepository.findActiveConfigs();
|
||||
|
||||
if (configs.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
configs.map(config => {
|
||||
const ratePromises = configs.map(config => {
|
||||
const email = config.metadata?.companyEmail || 'bookings@example.com';
|
||||
return this.csvRateLoader.loadRatesFromCsv(
|
||||
config.csvFilePath,
|
||||
email,
|
||||
config.companyName
|
||||
);
|
||||
})
|
||||
);
|
||||
// Pass company name from config to override CSV column value
|
||||
return this.csvRateLoader.loadRatesFromCsv(config.csvFilePath, email, config.companyName);
|
||||
});
|
||||
|
||||
const failures = results.filter(r => r.status === 'rejected');
|
||||
// Use allSettled to handle missing files gracefully
|
||||
const results = await Promise.allSettled(ratePromises);
|
||||
const rateArrays = results
|
||||
.filter(
|
||||
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
|
||||
)
|
||||
.map(result => result.value);
|
||||
|
||||
// Log any failed file loads
|
||||
const failures = results.filter(result => result.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
console.warn(`Failed to load ${failures.length} CSV files from database configs`);
|
||||
console.warn(
|
||||
`Failed to load ${failures.length} CSV files:`,
|
||||
failures.map(
|
||||
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return results
|
||||
.filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled')
|
||||
.flatMap(r => r.value);
|
||||
}
|
||||
|
||||
// DB has no active configs — fall through to local CSV files
|
||||
console.warn('No active CSV rate configs in database, loading from local CSV files');
|
||||
return rateArrays.flat();
|
||||
}
|
||||
|
||||
// Fallback: load files without email (use default)
|
||||
const files = await this.csvRateLoader.getAvailableCsvFiles();
|
||||
const results = await Promise.allSettled(
|
||||
files.map(file => this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com'))
|
||||
const ratePromises = files.map(file =>
|
||||
this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com')
|
||||
);
|
||||
|
||||
return results
|
||||
.filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled')
|
||||
.flatMap(r => r.value);
|
||||
// Use allSettled here too for consistency
|
||||
const results = await Promise.allSettled(ratePromises);
|
||||
const rateArrays = results
|
||||
.filter(
|
||||
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
|
||||
)
|
||||
.map(result => result.value);
|
||||
|
||||
return rateArrays.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter rates by route (origin/destination)
|
||||
*/
|
||||
private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] {
|
||||
return rates.filter(rate => rate.matchesRoute(origin, destination));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter rates by volume/weight range
|
||||
*/
|
||||
private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] {
|
||||
return rates.filter(rate => rate.matchesVolume(volume));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter rates by pallet count
|
||||
*/
|
||||
private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] {
|
||||
return rates.filter(rate => rate.matchesPalletCount(palletCount));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply advanced filters to rate list
|
||||
*/
|
||||
private applyAdvancedFilters(
|
||||
rates: CsvRate[],
|
||||
filters: RateSearchFilters,
|
||||
input: CsvRateSearchInput
|
||||
volume: Volume
|
||||
): CsvRate[] {
|
||||
let filtered = rates;
|
||||
|
||||
if (filters.companies?.length) {
|
||||
// Company filter
|
||||
if (filters.companies && filters.companies.length > 0) {
|
||||
filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName));
|
||||
}
|
||||
|
||||
if (filters.onlyDirect) {
|
||||
filtered = filtered.filter(rate => rate.isDirectRoute());
|
||||
// Volume CBM filter
|
||||
if (filters.minVolumeCBM !== undefined) {
|
||||
filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!);
|
||||
}
|
||||
if (filters.maxVolumeCBM !== undefined) {
|
||||
filtered = filtered.filter(rate => rate.volumeRange.minCBM <= filters.maxVolumeCBM!);
|
||||
}
|
||||
|
||||
if (filters.excludeNonDgRoutes) {
|
||||
filtered = filtered.filter(rate => rate.isDgAccepted());
|
||||
// Weight KG filter
|
||||
if (filters.minWeightKG !== undefined) {
|
||||
filtered = filtered.filter(rate => rate.weightRange.maxKG >= filters.minWeightKG!);
|
||||
}
|
||||
if (filters.maxWeightKG !== undefined) {
|
||||
filtered = filtered.filter(rate => rate.weightRange.minKG <= filters.maxWeightKG!);
|
||||
}
|
||||
|
||||
// Pallet count filter
|
||||
if (filters.palletCount !== undefined) {
|
||||
filtered = filtered.filter(rate => rate.matchesPalletCount(filters.palletCount!));
|
||||
}
|
||||
|
||||
// Price filter (calculate price first)
|
||||
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
|
||||
const currency = filters.currency || 'USD';
|
||||
filtered = filtered.filter(rate => {
|
||||
const price = rate.getPriceInCurrency(volume, currency);
|
||||
const amount = price.getAmount();
|
||||
|
||||
if (filters.minPrice !== undefined && amount < filters.minPrice) {
|
||||
return false;
|
||||
}
|
||||
if (filters.maxPrice !== undefined && amount > filters.maxPrice) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Transit days filter
|
||||
if (filters.minTransitDays !== undefined) {
|
||||
filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!);
|
||||
}
|
||||
@ -267,55 +427,52 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!);
|
||||
}
|
||||
|
||||
if (filters.containerTypes?.length) {
|
||||
// Container type filter
|
||||
if (filters.containerTypes && filters.containerTypes.length > 0) {
|
||||
filtered = filtered.filter(rate =>
|
||||
filters.containerTypes!.includes(rate.containerType.getValue())
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.departureDate) {
|
||||
filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
|
||||
// All-in prices only filter
|
||||
if (filters.onlyAllInPrices) {
|
||||
filtered = filtered.filter(rate => rate.isAllInPrice());
|
||||
}
|
||||
|
||||
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
|
||||
filtered = filtered.filter(rate => {
|
||||
const bd = this.priceCalculator.calculatePrice(rate, {
|
||||
volumeCBM: input.volumeCBM,
|
||||
weightKG: input.weightKG,
|
||||
hasDangerousGoods: input.hasDangerousGoods ?? false,
|
||||
});
|
||||
if (filters.minPrice !== undefined && bd.totalPriceForSorting < filters.minPrice) {
|
||||
return false;
|
||||
}
|
||||
if (filters.maxPrice !== undefined && bd.totalPriceForSorting > filters.maxPrice) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Departure date / validity filter
|
||||
if (filters.departureDate) {
|
||||
filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score (0–100) based on routing type, departure frequency, and rate validity.
|
||||
* Higher = better match.
|
||||
* Calculate match score (0-100) based on how well rate matches input
|
||||
* Higher score = better match
|
||||
*/
|
||||
private calculateMatchScore(rate: CsvRate): number {
|
||||
private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number {
|
||||
let score = 100;
|
||||
|
||||
// Direct route bonus
|
||||
if (rate.isDirectRoute()) {
|
||||
score += 10;
|
||||
} else {
|
||||
// Reduce score if volume/weight is near boundaries
|
||||
const volumeUtilization =
|
||||
(input.volumeCBM - rate.volumeRange.minCBM) /
|
||||
(rate.volumeRange.maxCBM - rate.volumeRange.minCBM);
|
||||
if (volumeUtilization < 0.2 || volumeUtilization > 0.8) {
|
||||
score -= 10; // Near boundaries
|
||||
}
|
||||
|
||||
// Reduce score if pallet count doesn't match exactly
|
||||
if (rate.palletCount !== 0 && input.palletCount !== rate.palletCount) {
|
||||
score -= 5;
|
||||
}
|
||||
|
||||
// Frequency bonus (Weekly = best)
|
||||
const freqScore = rate.getFrequencyScore(); // 1–4
|
||||
score += (freqScore - 2) * 5; // Weekly: +10, Bi-Weekly: +5, Bi-Monthly: 0, Monthly: -5
|
||||
// Increase score for all-in prices (simpler for customers)
|
||||
if (rate.isAllInPrice()) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Validity penalty
|
||||
// Reduce score for rates expiring soon
|
||||
const daysUntilExpiry = Math.floor(
|
||||
(rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
@ -328,7 +485,3 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
return Math.max(0, Math.min(100, score));
|
||||
}
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
@ -2,8 +2,16 @@ import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.
|
||||
import { CsvRate } from '../entities/csv-rate.entity';
|
||||
import { PortCode } from '../value-objects/port-code.vo';
|
||||
import { ContainerType } from '../value-objects/container-type.vo';
|
||||
import { DateRange } from '../value-objects/date-range.vo';
|
||||
import { Money } from '../value-objects/money.vo';
|
||||
|
||||
/**
|
||||
* Test Suite for Rate Offer Generator Service
|
||||
*
|
||||
* Vérifie que:
|
||||
* - RAPID est le plus cher ET le plus rapide
|
||||
* - ECONOMIC est le moins cher ET le plus lent
|
||||
* - STANDARD est au milieu en prix et transit time
|
||||
*/
|
||||
describe('RateOfferGeneratorService', () => {
|
||||
let service: RateOfferGeneratorService;
|
||||
let mockRate: CsvRate;
|
||||
@ -11,226 +19,415 @@ describe('RateOfferGeneratorService', () => {
|
||||
beforeEach(() => {
|
||||
service = new RateOfferGeneratorService();
|
||||
|
||||
// Mock minimal CsvRate compatible with new schema
|
||||
// Créer un tarif de base pour les tests
|
||||
// Prix: 1000 USD / 900 EUR, Transit: 20 jours
|
||||
mockRate = {
|
||||
companyName: 'Test Carrier',
|
||||
companyEmail: 'test@carrier.com',
|
||||
originCFS: 'Fos Sur Mer',
|
||||
originCode: PortCode.create('FRFOS'),
|
||||
portOfLoading: 'FOS SUR MER',
|
||||
routing: 'Direct',
|
||||
destinationCFS: 'New York',
|
||||
destinationCode: PortCode.create('USNYC'),
|
||||
destinationCountry: 'USA',
|
||||
origin: PortCode.create('FRPAR'),
|
||||
destination: PortCode.create('USNYC'),
|
||||
containerType: ContainerType.create('LCL'),
|
||||
freight: {
|
||||
freightCurrency: 'USD',
|
||||
freightRatePerCBM: 50,
|
||||
freightMinimum: 500,
|
||||
volumeRange: { minCBM: 1, maxCBM: 10 },
|
||||
weightRange: { minKG: 100, maxKG: 5000 },
|
||||
palletCount: 0,
|
||||
pricing: {
|
||||
pricePerCBM: 100,
|
||||
pricePerKG: 0.5,
|
||||
basePriceUSD: Money.create(1000, 'USD'),
|
||||
basePriceEUR: Money.create(900, 'EUR'),
|
||||
},
|
||||
fob: {
|
||||
fobCurrency: 'EUR',
|
||||
fobDocumentation: 55,
|
||||
fobISPS: 18,
|
||||
fobHandling: 22,
|
||||
fobHandlingUnit: 'W',
|
||||
fobHandlingMinimum: 110,
|
||||
fobSolas: 15,
|
||||
fobCustoms: 85,
|
||||
fobAMS_ACI: 35,
|
||||
fobISF5: 0,
|
||||
fobDGAdmin: 50,
|
||||
},
|
||||
dgSurcharge: {
|
||||
dgSurchargeCurrency: 'EUR',
|
||||
dgSurchargeRate: 20,
|
||||
dgSurchargeUnit: 'UP',
|
||||
dgSurchargeMin: 50,
|
||||
},
|
||||
remarks: '',
|
||||
frequency: 'Weekly',
|
||||
currency: 'USD',
|
||||
hasSurcharges: false,
|
||||
surchargeBAF: null,
|
||||
surchargeCAF: null,
|
||||
surchargeDetails: null,
|
||||
transitDays: 20,
|
||||
validity: DateRange.create(new Date('2026-01-01'), new Date('2026-12-31'), true),
|
||||
validity: {
|
||||
getStartDate: () => new Date('2024-01-01'),
|
||||
getEndDate: () => new Date('2024-12-31'),
|
||||
},
|
||||
isValidForDate: () => true,
|
||||
isCurrentlyValid: () => true,
|
||||
matchesRoute: () => true,
|
||||
isDgAccepted: () => true,
|
||||
isDgOnRequest: () => false,
|
||||
isDirectRoute: () => true,
|
||||
getFrequencyScore: () => 4,
|
||||
getRouteDescription: () => 'FRFOS → USNYC',
|
||||
getSummary: () => 'Test Carrier: FRFOS → USNYC',
|
||||
toString: () => 'Test Carrier: FRFOS → USNYC',
|
||||
matchesVolume: () => true,
|
||||
matchesPalletCount: () => true,
|
||||
getPriceInCurrency: () => Money.create(1000, 'USD'),
|
||||
isAllInPrice: () => true,
|
||||
getSurchargeDetails: () => null,
|
||||
} as any;
|
||||
});
|
||||
|
||||
describe('generateOffers', () => {
|
||||
it('generates exactly 3 offers (RAPID, STANDARD, ECONOMIC)', () => {
|
||||
it('devrait générer exactement 3 offres (RAPID, STANDARD, ECONOMIC)', () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
|
||||
expect(offers).toHaveLength(3);
|
||||
expect(offers.map(o => o.serviceLevel)).toEqual(
|
||||
expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC])
|
||||
);
|
||||
});
|
||||
|
||||
it('ECONOMIC has the lowest price multiplier (0.85)', () => {
|
||||
it('ECONOMIC doit être le moins cher', () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||
expect(economic.priceMultiplier).toBe(0.85);
|
||||
expect(economic.priceAdjustmentPercent).toBe(-15);
|
||||
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||
|
||||
// ECONOMIC doit avoir le prix le plus bas
|
||||
expect(economic!.adjustedPriceUSD).toBeLessThan(standard!.adjustedPriceUSD);
|
||||
expect(economic!.adjustedPriceUSD).toBeLessThan(rapid!.adjustedPriceUSD);
|
||||
|
||||
// Vérifier le prix attendu: 1000 * 0.85 = 850 USD
|
||||
expect(economic!.adjustedPriceUSD).toBe(850);
|
||||
expect(economic!.priceAdjustmentPercent).toBe(-15);
|
||||
});
|
||||
|
||||
it('RAPID has the highest price multiplier (1.2)', () => {
|
||||
it('RAPID doit être le plus cher', () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||
expect(rapid.priceMultiplier).toBe(1.2);
|
||||
expect(rapid.priceAdjustmentPercent).toBe(20);
|
||||
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||
|
||||
// RAPID doit avoir le prix le plus élevé
|
||||
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(standard!.adjustedPriceUSD);
|
||||
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(economic!.adjustedPriceUSD);
|
||||
|
||||
// Vérifier le prix attendu: 1000 * 1.20 = 1200 USD
|
||||
expect(rapid!.adjustedPriceUSD).toBe(1200);
|
||||
expect(rapid!.priceAdjustmentPercent).toBe(20);
|
||||
});
|
||||
|
||||
it('STANDARD has no price adjustment (multiplier = 1.0)', () => {
|
||||
it("STANDARD doit avoir le prix de base (pas d'ajustement)", () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
||||
expect(standard.priceMultiplier).toBe(1.0);
|
||||
expect(standard.priceAdjustmentPercent).toBe(0);
|
||||
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||
|
||||
// STANDARD doit avoir le prix de base (pas de changement)
|
||||
expect(standard!.adjustedPriceUSD).toBe(1000);
|
||||
expect(standard!.adjustedPriceEUR).toBe(900);
|
||||
expect(standard!.priceAdjustmentPercent).toBe(0);
|
||||
});
|
||||
|
||||
it('RAPID has the shortest transit time', () => {
|
||||
it('RAPID doit être le plus rapide (moins de jours de transit)', () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||
|
||||
expect(rapid.adjustedTransitDays).toBeLessThan(standard.adjustedTransitDays);
|
||||
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
|
||||
// 20 * 0.70 = 14
|
||||
expect(rapid.adjustedTransitDays).toBe(14);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||
|
||||
// RAPID doit avoir le transit time le plus court
|
||||
expect(rapid!.adjustedTransitDays).toBeLessThan(standard!.adjustedTransitDays);
|
||||
expect(rapid!.adjustedTransitDays).toBeLessThan(economic!.adjustedTransitDays);
|
||||
|
||||
// Vérifier le transit attendu: 20 * 0.70 = 14 jours
|
||||
expect(rapid!.adjustedTransitDays).toBe(14);
|
||||
expect(rapid!.transitAdjustmentPercent).toBe(-30);
|
||||
});
|
||||
|
||||
it('ECONOMIC has the longest transit time', () => {
|
||||
it('ECONOMIC doit être le plus lent (plus de jours de transit)', () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
||||
|
||||
expect(economic.adjustedTransitDays).toBeGreaterThan(standard.adjustedTransitDays);
|
||||
// 20 * 1.50 = 30
|
||||
expect(economic.adjustedTransitDays).toBe(30);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||
|
||||
// ECONOMIC doit avoir le transit time le plus long
|
||||
expect(economic!.adjustedTransitDays).toBeGreaterThan(standard!.adjustedTransitDays);
|
||||
expect(economic!.adjustedTransitDays).toBeGreaterThan(rapid!.adjustedTransitDays);
|
||||
|
||||
// Vérifier le transit attendu: 20 * 1.50 = 30 jours
|
||||
expect(economic!.adjustedTransitDays).toBe(30);
|
||||
expect(economic!.transitAdjustmentPercent).toBe(50);
|
||||
});
|
||||
|
||||
it('STANDARD has no transit adjustment', () => {
|
||||
it("STANDARD doit avoir le transit time de base (pas d'ajustement)", () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
||||
expect(standard.adjustedTransitDays).toBe(20);
|
||||
expect(standard.transitAdjustmentPercent).toBe(0);
|
||||
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||
|
||||
// STANDARD doit avoir le transit time de base
|
||||
expect(standard!.adjustedTransitDays).toBe(20);
|
||||
expect(standard!.transitAdjustmentPercent).toBe(0);
|
||||
});
|
||||
|
||||
it('offers are sorted by priceMultiplier (ECONOMIC → STANDARD → RAPID)', () => {
|
||||
it('les offres doivent être triées par prix croissant (ECONOMIC -> STANDARD -> RAPID)', () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
|
||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
||||
expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD);
|
||||
expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID);
|
||||
|
||||
// Vérifier que les prix sont dans l'ordre croissant
|
||||
expect(offers[0].adjustedPriceUSD).toBeLessThan(offers[1].adjustedPriceUSD);
|
||||
expect(offers[1].adjustedPriceUSD).toBeLessThan(offers[2].adjustedPriceUSD);
|
||||
});
|
||||
|
||||
it('clamps transit time to minimum (5 days)', () => {
|
||||
const shortTransitRate = { ...mockRate, transitDays: 3 } as any;
|
||||
const offers = service.generateOffers(shortTransitRate);
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||
expect(rapid.adjustedTransitDays).toBe(5);
|
||||
});
|
||||
|
||||
it('clamps transit time to maximum (90 days)', () => {
|
||||
const longTransitRate = { ...mockRate, transitDays: 80 } as any;
|
||||
const offers = service.generateOffers(longTransitRate);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||
expect(economic.adjustedTransitDays).toBe(90);
|
||||
});
|
||||
|
||||
it('preserves the original rate reference', () => {
|
||||
it('doit conserver les informations originales du tarif', () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
|
||||
for (const offer of offers) {
|
||||
expect(offer.rate).toBe(mockRate);
|
||||
expect(offer.originalPriceUSD).toBe(1000);
|
||||
expect(offer.originalPriceEUR).toBe(900);
|
||||
expect(offer.originalTransitDays).toBe(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('doit appliquer la contrainte de transit time minimum (5 jours)', () => {
|
||||
// Tarif avec transit time très court (3 jours)
|
||||
const shortTransitRate = {
|
||||
...mockRate,
|
||||
transitDays: 3,
|
||||
} as any;
|
||||
|
||||
const offers = service.generateOffers(shortTransitRate);
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||
|
||||
// RAPID avec 3 * 0.70 = 2.1 jours, mais minimum est 5 jours
|
||||
expect(rapid!.adjustedTransitDays).toBe(5);
|
||||
});
|
||||
|
||||
it('doit appliquer la contrainte de transit time maximum (90 jours)', () => {
|
||||
// Tarif avec transit time très long (80 jours)
|
||||
const longTransitRate = {
|
||||
...mockRate,
|
||||
transitDays: 80,
|
||||
} as any;
|
||||
|
||||
const offers = service.generateOffers(longTransitRate);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||
|
||||
// ECONOMIC avec 80 * 1.50 = 120 jours, mais maximum est 90 jours
|
||||
expect(economic!.adjustedTransitDays).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateOffersForRates', () => {
|
||||
it('generates 3 offers per rate', () => {
|
||||
const rate2 = { ...mockRate, companyName: 'Another Carrier' } as any;
|
||||
const offers = service.generateOffersForRates([mockRate, rate2]);
|
||||
expect(offers).toHaveLength(6);
|
||||
it('doit générer 3 offres par tarif', () => {
|
||||
const rate1 = mockRate;
|
||||
const rate2 = {
|
||||
...mockRate,
|
||||
companyName: 'Another Carrier',
|
||||
} as any;
|
||||
|
||||
const offers = service.generateOffersForRates([rate1, rate2]);
|
||||
|
||||
expect(offers).toHaveLength(6); // 2 tarifs * 3 offres
|
||||
});
|
||||
|
||||
it('doit trier toutes les offres par prix croissant', () => {
|
||||
const rate1 = mockRate; // Prix base: 1000 USD
|
||||
const rate2 = {
|
||||
...mockRate,
|
||||
companyName: 'Cheaper Carrier',
|
||||
pricing: {
|
||||
...mockRate.pricing,
|
||||
basePriceUSD: Money.create(500, 'USD'), // Prix base plus bas
|
||||
},
|
||||
} as any;
|
||||
|
||||
const offers = service.generateOffersForRates([rate1, rate2]);
|
||||
|
||||
// Vérifier que les prix sont triés
|
||||
for (let i = 0; i < offers.length - 1; i++) {
|
||||
expect(offers[i].adjustedPriceUSD).toBeLessThanOrEqual(offers[i + 1].adjustedPriceUSD);
|
||||
}
|
||||
|
||||
// L'offre la moins chère devrait être ECONOMIC du rate2
|
||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
||||
expect(offers[0].rate.companyName).toBe('Cheaper Carrier');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateOffersForServiceLevel', () => {
|
||||
it('generates only RAPID offers', () => {
|
||||
it('doit générer uniquement les offres RAPID', () => {
|
||||
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID);
|
||||
|
||||
expect(offers).toHaveLength(1);
|
||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID);
|
||||
});
|
||||
|
||||
it('generates only ECONOMIC offers', () => {
|
||||
it('doit générer uniquement les offres ECONOMIC', () => {
|
||||
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC);
|
||||
|
||||
expect(offers).toHaveLength(1);
|
||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCheapestOffer', () => {
|
||||
it("doit retourner l'offre ECONOMIC la moins chère", () => {
|
||||
const rate1 = mockRate; // 1000 USD base
|
||||
const rate2 = {
|
||||
...mockRate,
|
||||
pricing: {
|
||||
...mockRate.pricing,
|
||||
basePriceUSD: Money.create(500, 'USD'),
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cheapest = service.getCheapestOffer([rate1, rate2]);
|
||||
|
||||
expect(cheapest).not.toBeNull();
|
||||
expect(cheapest!.serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
||||
// 500 * 0.85 = 425 USD
|
||||
expect(cheapest!.adjustedPriceUSD).toBe(425);
|
||||
});
|
||||
|
||||
it('doit retourner null si aucun tarif', () => {
|
||||
const cheapest = service.getCheapestOffer([]);
|
||||
expect(cheapest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFastestOffer', () => {
|
||||
it("doit retourner l'offre RAPID la plus rapide", () => {
|
||||
const rate1 = { ...mockRate, transitDays: 20 } as any;
|
||||
const rate2 = { ...mockRate, transitDays: 10 } as any;
|
||||
|
||||
const fastest = service.getFastestOffer([rate1, rate2]);
|
||||
|
||||
expect(fastest).not.toBeNull();
|
||||
expect(fastest!.serviceLevel).toBe(ServiceLevel.RAPID);
|
||||
// 10 * 0.70 = 7 jours
|
||||
expect(fastest!.adjustedTransitDays).toBe(7);
|
||||
});
|
||||
|
||||
it('doit retourner null si aucun tarif', () => {
|
||||
const fastest = service.getFastestOffer([]);
|
||||
expect(fastest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBestOffersPerServiceLevel', () => {
|
||||
it('returns one offer per service level', () => {
|
||||
const best = service.getBestOffersPerServiceLevel([mockRate]);
|
||||
it('doit retourner la meilleure offre de chaque niveau de service', () => {
|
||||
const rate1 = mockRate;
|
||||
const rate2 = {
|
||||
...mockRate,
|
||||
pricing: {
|
||||
...mockRate.pricing,
|
||||
basePriceUSD: Money.create(800, 'USD'),
|
||||
},
|
||||
} as any;
|
||||
|
||||
const best = service.getBestOffersPerServiceLevel([rate1, rate2]);
|
||||
|
||||
expect(best.rapid).not.toBeNull();
|
||||
expect(best.standard).not.toBeNull();
|
||||
expect(best.economic).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for all levels when no rates', () => {
|
||||
const best = service.getBestOffersPerServiceLevel([]);
|
||||
expect(best.rapid).toBeNull();
|
||||
expect(best.standard).toBeNull();
|
||||
expect(best.economic).toBeNull();
|
||||
// Toutes doivent provenir du rate2 (moins cher)
|
||||
expect(best.rapid!.originalPriceUSD).toBe(800);
|
||||
expect(best.standard!.originalPriceUSD).toBe(800);
|
||||
expect(best.economic!.originalPriceUSD).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRateEligible', () => {
|
||||
it('accepts a valid rate', () => {
|
||||
it('doit accepter un tarif valide', () => {
|
||||
expect(service.isRateEligible(mockRate)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a rate with transitDays = 0', () => {
|
||||
const invalid = { ...mockRate, transitDays: 0 } as any;
|
||||
expect(service.isRateEligible(invalid)).toBe(false);
|
||||
it('doit rejeter un tarif avec transit time = 0', () => {
|
||||
const invalidRate = { ...mockRate, transitDays: 0 } as any;
|
||||
expect(service.isRateEligible(invalidRate)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a rate with freightRatePerCBM = 0 and freightMinimum = 0', () => {
|
||||
const invalid = {
|
||||
it('doit rejeter un tarif avec prix = 0', () => {
|
||||
const invalidRate = {
|
||||
...mockRate,
|
||||
freight: { ...mockRate.freight, freightRatePerCBM: 0, freightMinimum: 0 },
|
||||
pricing: {
|
||||
...mockRate.pricing,
|
||||
basePriceUSD: Money.create(0, 'USD'),
|
||||
},
|
||||
} as any;
|
||||
expect(service.isRateEligible(invalid)).toBe(false);
|
||||
expect(service.isRateEligible(invalidRate)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an expired rate', () => {
|
||||
const expired = { ...mockRate, isValidForDate: () => false } as any;
|
||||
expect(service.isRateEligible(expired)).toBe(false);
|
||||
it('doit rejeter un tarif expiré', () => {
|
||||
const expiredRate = {
|
||||
...mockRate,
|
||||
isValidForDate: () => false,
|
||||
} as any;
|
||||
expect(service.isRateEligible(expiredRate)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Business logic invariants', () => {
|
||||
it('RAPID priceMultiplier always > ECONOMIC priceMultiplier', () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||
expect(rapid.priceMultiplier).toBeGreaterThan(economic.priceMultiplier);
|
||||
describe('filterEligibleRates', () => {
|
||||
it('doit filtrer les tarifs invalides', () => {
|
||||
const validRate = mockRate;
|
||||
const invalidRate1 = { ...mockRate, transitDays: 0 } as any;
|
||||
const invalidRate2 = {
|
||||
...mockRate,
|
||||
pricing: {
|
||||
...mockRate.pricing,
|
||||
basePriceUSD: Money.create(0, 'USD'),
|
||||
},
|
||||
} as any;
|
||||
|
||||
const eligibleRates = service.filterEligibleRates([validRate, invalidRate1, invalidRate2]);
|
||||
|
||||
expect(eligibleRates).toHaveLength(1);
|
||||
expect(eligibleRates[0]).toBe(validRate);
|
||||
});
|
||||
});
|
||||
|
||||
it('RAPID transit always < ECONOMIC transit for different base days', () => {
|
||||
for (const days of [5, 10, 20, 30, 60]) {
|
||||
const rate = { ...mockRate, transitDays: days } as any;
|
||||
describe('Validation de la logique métier', () => {
|
||||
it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => {
|
||||
// Test avec différents prix de base
|
||||
const prices = [100, 500, 1000, 5000, 10000];
|
||||
|
||||
for (const price of prices) {
|
||||
const rate = {
|
||||
...mockRate,
|
||||
pricing: {
|
||||
...mockRate.pricing,
|
||||
basePriceUSD: Money.create(price, 'USD'),
|
||||
},
|
||||
} as any;
|
||||
|
||||
const offers = service.generateOffers(rate);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||
|
||||
expect(rapid.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
|
||||
}
|
||||
});
|
||||
|
||||
it('RAPID doit TOUJOURS être plus rapide que ECONOMIC', () => {
|
||||
// Test avec différents transit times de base
|
||||
const transitDays = [5, 10, 20, 30, 60];
|
||||
|
||||
for (const days of transitDays) {
|
||||
const rate = { ...mockRate, transitDays: days } as any;
|
||||
|
||||
const offers = service.generateOffers(rate);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||
|
||||
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
|
||||
}
|
||||
});
|
||||
|
||||
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le prix', () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||
|
||||
expect(standard.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
|
||||
expect(standard.adjustedPriceUSD).toBeLessThan(rapid.adjustedPriceUSD);
|
||||
});
|
||||
|
||||
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le transit time', () => {
|
||||
const offers = service.generateOffers(mockRate);
|
||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||
|
||||
expect(standard.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
|
||||
expect(standard.adjustedTransitDays).toBeGreaterThan(rapid.adjustedTransitDays);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,9 +3,9 @@ import { CsvRate } from '../entities/csv-rate.entity';
|
||||
/**
|
||||
* Service Level Types
|
||||
*
|
||||
* - RAPID : +20% price, -30% transit (express, priority)
|
||||
* - STANDARD : base price and transit
|
||||
* - ECONOMIC : -15% price, +50% transit (cheapest, slowest)
|
||||
* - RAPID: Offre la plus chère + la plus rapide (transit time réduit)
|
||||
* - STANDARD: Offre standard (prix et transit time de base)
|
||||
* - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté)
|
||||
*/
|
||||
export enum ServiceLevel {
|
||||
RAPID = 'RAPID',
|
||||
@ -13,110 +13,243 @@ export enum ServiceLevel {
|
||||
ECONOMIC = 'ECONOMIC',
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate Offer - Variante d'un tarif avec un niveau de service
|
||||
*/
|
||||
export interface RateOffer {
|
||||
rate: CsvRate;
|
||||
serviceLevel: ServiceLevel;
|
||||
priceMultiplier: number;
|
||||
adjustedPriceUSD: number;
|
||||
adjustedPriceEUR: number;
|
||||
adjustedTransitDays: number;
|
||||
originalPriceUSD: number;
|
||||
originalPriceEUR: number;
|
||||
originalTransitDays: number;
|
||||
priceAdjustmentPercent: number;
|
||||
transitAdjustmentPercent: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration pour les ajustements de prix et transit par niveau de service
|
||||
*/
|
||||
interface ServiceLevelConfig {
|
||||
priceMultiplier: number;
|
||||
transitMultiplier: number;
|
||||
priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement)
|
||||
transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement)
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates RAPID / STANDARD / ECONOMIC variants for a given CSV rate.
|
||||
* Rate Offer Generator Service
|
||||
*
|
||||
* Price adjustment is applied to the total calculated price in the search service —
|
||||
* this service only stores the multiplier and the adjusted transit time.
|
||||
* Service du domaine qui génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV.
|
||||
*
|
||||
* Règles métier:
|
||||
* - RAPID : Prix +20%, Transit -30% (plus cher, plus rapide)
|
||||
* - STANDARD : Prix +0%, Transit +0% (tarif de base)
|
||||
* - ECONOMIC : Prix -15%, Transit +50% (moins cher, plus lent)
|
||||
*
|
||||
* Pure domain logic - Pas de dépendances framework
|
||||
*/
|
||||
export class RateOfferGeneratorService {
|
||||
/**
|
||||
* Configuration par défaut des niveaux de service
|
||||
* Ces valeurs peuvent être ajustées selon les besoins métier
|
||||
*/
|
||||
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
|
||||
[ServiceLevel.RAPID]: {
|
||||
priceMultiplier: 1.2,
|
||||
transitMultiplier: 0.7,
|
||||
description: 'Express — Livraison rapide avec service prioritaire',
|
||||
priceMultiplier: 1.2, // +20% du prix de base
|
||||
transitMultiplier: 0.7, // -30% du temps de transit (plus rapide)
|
||||
description: 'Express - Livraison rapide avec service prioritaire',
|
||||
},
|
||||
[ServiceLevel.STANDARD]: {
|
||||
priceMultiplier: 1.0,
|
||||
transitMultiplier: 1.0,
|
||||
description: 'Standard — Service régulier au meilleur rapport qualité/prix',
|
||||
priceMultiplier: 1.0, // Prix de base (pas de changement)
|
||||
transitMultiplier: 1.0, // Transit time de base (pas de changement)
|
||||
description: 'Standard - Service régulier au meilleur rapport qualité/prix',
|
||||
},
|
||||
[ServiceLevel.ECONOMIC]: {
|
||||
priceMultiplier: 0.85,
|
||||
transitMultiplier: 1.5,
|
||||
description: 'Économique — Tarif réduit avec délai étendu',
|
||||
priceMultiplier: 0.85, // -15% du prix de base
|
||||
transitMultiplier: 1.5, // +50% du temps de transit (plus lent)
|
||||
description: 'Économique - Tarif réduit avec délai étendu',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Transit time minimum (en jours) pour garantir la cohérence
|
||||
* Même avec réduction, on ne peut pas descendre en dessous de ce minimum
|
||||
*/
|
||||
private readonly MIN_TRANSIT_DAYS = 5;
|
||||
|
||||
/**
|
||||
* Transit time maximum (en jours) pour garantir la cohérence
|
||||
* Même avec augmentation, on ne peut pas dépasser ce maximum
|
||||
*/
|
||||
private readonly MAX_TRANSIT_DAYS = 90;
|
||||
|
||||
/**
|
||||
* Génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV
|
||||
*
|
||||
* @param rate - Le tarif CSV de base
|
||||
* @returns Tableau de 3 offres triées par prix croissant (ECONOMIC, STANDARD, RAPID)
|
||||
*/
|
||||
generateOffers(rate: CsvRate): RateOffer[] {
|
||||
const offers: RateOffer[] = [];
|
||||
|
||||
// Extraire les prix de base
|
||||
const basePriceUSD = rate.pricing.basePriceUSD.getAmount();
|
||||
const basePriceEUR = rate.pricing.basePriceEUR.getAmount();
|
||||
const baseTransitDays = rate.transitDays;
|
||||
|
||||
// Générer les 3 offres
|
||||
for (const serviceLevel of Object.values(ServiceLevel)) {
|
||||
const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel];
|
||||
const rawTransit = rate.transitDays * config.transitMultiplier;
|
||||
const adjustedTransitDays = this.clampTransit(Math.round(rawTransit));
|
||||
|
||||
// Calculer les prix ajustés
|
||||
const adjustedPriceUSD = this.roundPrice(basePriceUSD * config.priceMultiplier);
|
||||
const adjustedPriceEUR = this.roundPrice(basePriceEUR * config.priceMultiplier);
|
||||
|
||||
// Calculer le transit time ajusté (avec contraintes min/max)
|
||||
const rawTransitDays = baseTransitDays * config.transitMultiplier;
|
||||
const adjustedTransitDays = this.constrainTransitDays(Math.round(rawTransitDays));
|
||||
|
||||
// Calculer les pourcentages d'ajustement
|
||||
const priceAdjustmentPercent = Math.round((config.priceMultiplier - 1) * 100);
|
||||
const transitAdjustmentPercent = Math.round((config.transitMultiplier - 1) * 100);
|
||||
|
||||
offers.push({
|
||||
rate,
|
||||
serviceLevel,
|
||||
priceMultiplier: config.priceMultiplier,
|
||||
adjustedPriceUSD,
|
||||
adjustedPriceEUR,
|
||||
adjustedTransitDays,
|
||||
originalTransitDays: rate.transitDays,
|
||||
priceAdjustmentPercent: Math.round((config.priceMultiplier - 1) * 100),
|
||||
transitAdjustmentPercent: Math.round((config.transitMultiplier - 1) * 100),
|
||||
originalPriceUSD: basePriceUSD,
|
||||
originalPriceEUR: basePriceEUR,
|
||||
originalTransitDays: baseTransitDays,
|
||||
priceAdjustmentPercent,
|
||||
transitAdjustmentPercent,
|
||||
description: config.description,
|
||||
});
|
||||
}
|
||||
|
||||
// ECONOMIC → STANDARD → RAPID (cheapest first)
|
||||
return offers.sort((a, b) => a.priceMultiplier - b.priceMultiplier);
|
||||
// Trier par prix croissant: ECONOMIC (moins cher) -> STANDARD -> RAPID (plus cher)
|
||||
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère plusieurs offres pour une liste de tarifs
|
||||
*
|
||||
* @param rates - Liste de tarifs CSV
|
||||
* @returns Liste de toutes les offres générées (3 par tarif), triées par prix
|
||||
*/
|
||||
generateOffersForRates(rates: CsvRate[]): RateOffer[] {
|
||||
return rates.flatMap(rate => this.generateOffers(rate));
|
||||
const allOffers: RateOffer[] = [];
|
||||
|
||||
for (const rate of rates) {
|
||||
const offers = this.generateOffers(rate);
|
||||
allOffers.push(...offers);
|
||||
}
|
||||
|
||||
// Trier toutes les offres par prix croissant
|
||||
return allOffers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère uniquement les offres d'un niveau de service spécifique
|
||||
*
|
||||
* @param rates - Liste de tarifs CSV
|
||||
* @param serviceLevel - Niveau de service souhaité (RAPID, STANDARD, ECONOMIC)
|
||||
* @returns Liste des offres du niveau de service demandé, triées par prix
|
||||
*/
|
||||
generateOffersForServiceLevel(rates: CsvRate[], serviceLevel: ServiceLevel): RateOffer[] {
|
||||
return rates
|
||||
.map(rate => this.generateOffers(rate).find(o => o.serviceLevel === serviceLevel)!)
|
||||
.filter(Boolean);
|
||||
const offers: RateOffer[] = [];
|
||||
|
||||
for (const rate of rates) {
|
||||
const allOffers = this.generateOffers(rate);
|
||||
const matchingOffer = allOffers.find(o => o.serviceLevel === serviceLevel);
|
||||
|
||||
if (matchingOffer) {
|
||||
offers.push(matchingOffer);
|
||||
}
|
||||
}
|
||||
|
||||
// Trier par prix croissant
|
||||
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient l'offre la moins chère (ECONOMIC) parmi une liste de tarifs
|
||||
*/
|
||||
getCheapestOffer(rates: CsvRate[]): RateOffer | null {
|
||||
if (rates.length === 0) return null;
|
||||
|
||||
const economicOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC);
|
||||
return economicOffers[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient l'offre la plus rapide (RAPID) parmi une liste de tarifs
|
||||
*/
|
||||
getFastestOffer(rates: CsvRate[]): RateOffer | null {
|
||||
if (rates.length === 0) return null;
|
||||
|
||||
const rapidOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID);
|
||||
|
||||
// Trier par transit time croissant (plus rapide en premier)
|
||||
rapidOffers.sort((a, b) => a.adjustedTransitDays - b.adjustedTransitDays);
|
||||
|
||||
return rapidOffers[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient les meilleures offres (meilleur rapport qualité/prix)
|
||||
* Retourne une offre de chaque niveau de service avec le meilleur prix
|
||||
*/
|
||||
getBestOffersPerServiceLevel(rates: CsvRate[]): {
|
||||
rapid: RateOffer | null;
|
||||
standard: RateOffer | null;
|
||||
economic: RateOffer | null;
|
||||
} {
|
||||
return {
|
||||
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] ?? null,
|
||||
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] ?? null,
|
||||
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] ?? null,
|
||||
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] || null,
|
||||
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] || null,
|
||||
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrondit le prix à 2 décimales
|
||||
*/
|
||||
private roundPrice(price: number): number {
|
||||
return Math.round(price * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contraint le transit time entre les limites min et max
|
||||
*/
|
||||
private constrainTransitDays(days: number): number {
|
||||
return Math.max(this.MIN_TRANSIT_DAYS, Math.min(this.MAX_TRANSIT_DAYS, days));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un tarif est éligible pour la génération d'offres
|
||||
*
|
||||
* Critères:
|
||||
* - Transit time doit être > 0
|
||||
* - Prix doit être > 0
|
||||
* - Tarif doit être valide (non expiré)
|
||||
*/
|
||||
isRateEligible(rate: CsvRate): boolean {
|
||||
if (rate.transitDays <= 0) return false;
|
||||
// A rate is usable if it has a freight rate or at least a freight minimum
|
||||
if (rate.freight.freightRatePerCBM <= 0 && rate.freight.freightMinimum <= 0) return false;
|
||||
if (rate.pricing.basePriceUSD.getAmount() <= 0) return false;
|
||||
if (!rate.isValidForDate(new Date())) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre les tarifs éligibles pour la génération d'offres
|
||||
*/
|
||||
filterEligibleRates(rates: CsvRate[]): CsvRate[] {
|
||||
return rates.filter(rate => this.isRateEligible(rate));
|
||||
}
|
||||
|
||||
private clampTransit(days: number): number {
|
||||
return Math.max(this.MIN_TRANSIT_DAYS, Math.min(this.MAX_TRANSIT_DAYS, days));
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,4 +14,3 @@ export * from './booking-status.vo';
|
||||
export * from './subscription-plan.vo';
|
||||
export * from './subscription-status.vo';
|
||||
export * from './license-status.vo';
|
||||
export * from './locale.vo';
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Locale Value Object
|
||||
*
|
||||
* Represents the supported UI / response languages of the platform.
|
||||
*/
|
||||
|
||||
export const SUPPORTED_LOCALES = ['fr', 'en'] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'fr';
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function toLocale(value: unknown, fallback: Locale = DEFAULT_LOCALE): Locale {
|
||||
return isLocale(value) ? value : fallback;
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
{
|
||||
"LOGIN_SUCCESS": "Login successful",
|
||||
"LOGOUT_SUCCESS": "Logout successful",
|
||||
"REGISTER_SUCCESS": "Registration successful — please verify your email",
|
||||
"PASSWORD_RESET_SENT": "If the email exists, a reset link has been sent",
|
||||
"PASSWORD_RESET_SUCCESS": "Password has been reset successfully",
|
||||
"EMAIL_VERIFIED": "Email verified successfully",
|
||||
"VERIFICATION_EMAIL_SENT": "Verification email has been sent"
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"status": {
|
||||
"DRAFT": "Draft",
|
||||
"CONFIRMED": "Confirmed",
|
||||
"SHIPPED": "Shipped",
|
||||
"DELIVERED": "Delivered",
|
||||
"CANCELLED": "Cancelled",
|
||||
"REJECTED": "Rejected"
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"SUCCESS": "Success",
|
||||
"YES": "Yes",
|
||||
"NO": "No"
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"greeting": "Hello {firstName}",
|
||||
"footer": "The Xpeditis team",
|
||||
"ignoreIfNotYou": "If you did not request this email, you can safely ignore it."
|
||||
},
|
||||
"verification": {
|
||||
"subject": "Verify your email",
|
||||
"title": "Welcome to Xpeditis!",
|
||||
"body": "Please confirm your email address by clicking the button below.",
|
||||
"cta": "Verify my email"
|
||||
},
|
||||
"passwordReset": {
|
||||
"subject": "Reset your password",
|
||||
"title": "Reset your password",
|
||||
"body": "Click the button below to set a new password. This link is valid for 1 hour.",
|
||||
"cta": "Reset my password"
|
||||
},
|
||||
"welcome": {
|
||||
"subject": "Welcome to Xpeditis, {firstName}!",
|
||||
"title": "Welcome aboard!",
|
||||
"body": "Your account is ready. Start searching maritime rates and bookings right away.",
|
||||
"cta": "Go to dashboard"
|
||||
},
|
||||
"bookingConfirmation": {
|
||||
"subject": "Booking {bookingNumber} confirmed",
|
||||
"title": "Booking Confirmation",
|
||||
"body": "Your booking {bookingNumber} has been confirmed successfully.",
|
||||
"details": "Details",
|
||||
"cta": "View booking"
|
||||
},
|
||||
"userInvitation": {
|
||||
"subject": "You have been invited to join Xpeditis",
|
||||
"title": "You have been invited",
|
||||
"body": "{inviterName} has invited you to join {organizationName} on Xpeditis.",
|
||||
"cta": "Accept invitation"
|
||||
},
|
||||
"csvBookingRequest": {
|
||||
"subject": "New booking request {bookingReference}",
|
||||
"title": "New booking request",
|
||||
"body": "A new booking request has been submitted. Please review the details.",
|
||||
"cta": "Review booking"
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
{
|
||||
"INTERNAL_ERROR": "Internal server error",
|
||||
"UNAUTHORIZED": "Authentication required",
|
||||
"FORBIDDEN": "You do not have permission to perform this action",
|
||||
"NOT_FOUND": "Resource not found",
|
||||
"CONFLICT": "Conflict",
|
||||
"RATE_LIMITED": "Too many requests — please try again later",
|
||||
"PORT_NOT_FOUND": "Port not found: {portCode}",
|
||||
"PORT_INVALID_CODE": "Invalid port code: {portCode}",
|
||||
"USER_NOT_FOUND": "User not found",
|
||||
"USER_EMAIL_TAKEN": "This email is already in use",
|
||||
"USER_INACTIVE": "User account is inactive",
|
||||
"USER_EMAIL_NOT_VERIFIED": "Email address not verified",
|
||||
"ORGANIZATION_NOT_FOUND": "Organization not found",
|
||||
"INVALID_CREDENTIALS": "Invalid email or password",
|
||||
"INVALID_TOKEN": "Invalid or expired token",
|
||||
"BOOKING_NOT_FOUND": "Booking {bookingNumber} not found",
|
||||
"BOOKING_INVALID_STATUS": "Invalid booking status transition",
|
||||
"RATE_QUOTE_NOT_FOUND": "Rate quote not found",
|
||||
"RATE_QUOTE_EXPIRED": "Rate quote has expired",
|
||||
"CARRIER_NOT_FOUND": "Carrier not found",
|
||||
"NO_LICENSES_AVAILABLE": "No licenses available for this organization"
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
{
|
||||
"booking": {
|
||||
"created": {
|
||||
"title": "Booking Created",
|
||||
"message": "Your booking {bookingNumber} has been created successfully."
|
||||
},
|
||||
"updated": {
|
||||
"title": "Booking Updated",
|
||||
"message": "Booking {bookingNumber} status changed to {status}."
|
||||
},
|
||||
"confirmed": {
|
||||
"title": "Booking Confirmed",
|
||||
"message": "Your booking {bookingNumber} has been confirmed by the carrier."
|
||||
},
|
||||
"rejected": {
|
||||
"title": "Booking Rejected",
|
||||
"message": "Your booking {bookingNumber} has been rejected by the carrier."
|
||||
},
|
||||
"documentUploaded": {
|
||||
"title": "Document Uploaded",
|
||||
"message": "Document \"{documentName}\" has been uploaded for your booking."
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"welcome": {
|
||||
"title": "Welcome to Xpeditis",
|
||||
"message": "Hi {firstName}, welcome aboard! Start by searching for rates."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
{
|
||||
"booking": {
|
||||
"title": "BOOKING CONFIRMATION",
|
||||
"bookingNumber": "Booking Number",
|
||||
"routeInformation": "Route Information",
|
||||
"origin": "Origin",
|
||||
"destination": "Destination",
|
||||
"shipperInformation": "Shipper Information",
|
||||
"consigneeInformation": "Consignee Information",
|
||||
"containerDetails": "Container Details",
|
||||
"cargoDescription": "Cargo Description",
|
||||
"totalPrice": "Total Price",
|
||||
"estimatedDeparture": "Estimated Departure",
|
||||
"estimatedArrival": "Estimated Arrival",
|
||||
"carrier": "Carrier",
|
||||
"status": "Status"
|
||||
},
|
||||
"rateQuote": {
|
||||
"title": "RATE QUOTE COMPARISON",
|
||||
"quoteNumber": "Quote Number",
|
||||
"issuedAt": "Issued At",
|
||||
"validUntil": "Valid Until",
|
||||
"origin": "Origin",
|
||||
"destination": "Destination",
|
||||
"carrier": "Carrier",
|
||||
"transitTime": "Transit Time",
|
||||
"containerType": "Container Type",
|
||||
"baseRate": "Base Rate",
|
||||
"surcharges": "Surcharges",
|
||||
"totalPrice": "Total Price"
|
||||
},
|
||||
"common": {
|
||||
"generatedOn": "Generated on {date}",
|
||||
"page": "Page {current} of {total}"
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
{
|
||||
"EMAIL_REQUIRED": "Email is required",
|
||||
"EMAIL_INVALID": "Invalid email format",
|
||||
"PASSWORD_REQUIRED": "Password is required",
|
||||
"PASSWORD_MIN_LENGTH": "Password must be at least {constraint1} characters",
|
||||
"PASSWORD_MAX_LENGTH": "Password must be at most {constraint1} characters",
|
||||
"PASSWORD_PATTERN": "Password must contain uppercase, lowercase, number and special character",
|
||||
"FIRST_NAME_REQUIRED": "First name is required",
|
||||
"FIRST_NAME_MIN_LENGTH": "First name must be at least {constraint1} characters",
|
||||
"LAST_NAME_REQUIRED": "Last name is required",
|
||||
"LAST_NAME_MIN_LENGTH": "Last name must be at least {constraint1} characters",
|
||||
"PHONE_INVALID": "Invalid phone number",
|
||||
"SIREN_PATTERN": "SIREN must be exactly 9 digits",
|
||||
"SIRET_PATTERN": "SIRET must be exactly 14 digits",
|
||||
"STREET_MIN_LENGTH": "Street must be at least {constraint1} characters",
|
||||
"CITY_REQUIRED": "City is required",
|
||||
"POSTAL_CODE_REQUIRED": "Postal code is required",
|
||||
"COUNTRY_PATTERN": "Country must be a 2-letter ISO code (e.g., FR, US, CN)",
|
||||
"FIELD_REQUIRED": "This field is required",
|
||||
"FIELD_TOO_SHORT": "Must be at least {constraint1} characters",
|
||||
"FIELD_TOO_LONG": "Must be at most {constraint1} characters",
|
||||
"NUMBER_MIN": "Must be at least {constraint1}",
|
||||
"NUMBER_MAX": "Must be at most {constraint1}",
|
||||
"INVALID_UUID": "Invalid identifier format",
|
||||
"INVALID_DATE": "Invalid date",
|
||||
"INVALID_ENUM": "Invalid value — allowed values: {constraint1}",
|
||||
"INVALID_BOOLEAN": "Must be true or false",
|
||||
"INVALID_URL": "Invalid URL",
|
||||
"LOCALE_INVALID": "Language must be 'fr' or 'en'"
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
{
|
||||
"LOGIN_SUCCESS": "Connexion réussie",
|
||||
"LOGOUT_SUCCESS": "Déconnexion réussie",
|
||||
"REGISTER_SUCCESS": "Inscription réussie — veuillez vérifier votre email",
|
||||
"PASSWORD_RESET_SENT": "Si l'email existe, un lien de réinitialisation a été envoyé",
|
||||
"PASSWORD_RESET_SUCCESS": "Mot de passe réinitialisé avec succès",
|
||||
"EMAIL_VERIFIED": "Email vérifié avec succès",
|
||||
"VERIFICATION_EMAIL_SENT": "Email de vérification envoyé"
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"status": {
|
||||
"DRAFT": "Brouillon",
|
||||
"CONFIRMED": "Confirmée",
|
||||
"SHIPPED": "Expédiée",
|
||||
"DELIVERED": "Livrée",
|
||||
"CANCELLED": "Annulée",
|
||||
"REJECTED": "Refusée"
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"SUCCESS": "Succès",
|
||||
"YES": "Oui",
|
||||
"NO": "Non"
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"greeting": "Bonjour {firstName}",
|
||||
"footer": "L'équipe Xpeditis",
|
||||
"ignoreIfNotYou": "Si vous n'êtes pas à l'origine de cet email, vous pouvez l'ignorer."
|
||||
},
|
||||
"verification": {
|
||||
"subject": "Vérifiez votre email",
|
||||
"title": "Bienvenue sur Xpeditis !",
|
||||
"body": "Veuillez confirmer votre adresse email en cliquant sur le bouton ci-dessous.",
|
||||
"cta": "Vérifier mon email"
|
||||
},
|
||||
"passwordReset": {
|
||||
"subject": "Réinitialisez votre mot de passe",
|
||||
"title": "Réinitialisez votre mot de passe",
|
||||
"body": "Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe. Ce lien est valide 1 heure.",
|
||||
"cta": "Réinitialiser mon mot de passe"
|
||||
},
|
||||
"welcome": {
|
||||
"subject": "Bienvenue sur Xpeditis, {firstName} !",
|
||||
"title": "Bienvenue à bord !",
|
||||
"body": "Votre compte est prêt. Commencez dès maintenant à rechercher des tarifs maritimes et à réserver.",
|
||||
"cta": "Accéder au tableau de bord"
|
||||
},
|
||||
"bookingConfirmation": {
|
||||
"subject": "Réservation {bookingNumber} confirmée",
|
||||
"title": "Confirmation de réservation",
|
||||
"body": "Votre réservation {bookingNumber} a été confirmée avec succès.",
|
||||
"details": "Détails",
|
||||
"cta": "Voir la réservation"
|
||||
},
|
||||
"userInvitation": {
|
||||
"subject": "Vous avez été invité à rejoindre Xpeditis",
|
||||
"title": "Vous avez été invité",
|
||||
"body": "{inviterName} vous invite à rejoindre {organizationName} sur Xpeditis.",
|
||||
"cta": "Accepter l'invitation"
|
||||
},
|
||||
"csvBookingRequest": {
|
||||
"subject": "Nouvelle demande de réservation {bookingReference}",
|
||||
"title": "Nouvelle demande de réservation",
|
||||
"body": "Une nouvelle demande de réservation vous est soumise. Veuillez examiner les détails.",
|
||||
"cta": "Examiner la réservation"
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
{
|
||||
"INTERNAL_ERROR": "Erreur interne du serveur",
|
||||
"UNAUTHORIZED": "Authentification requise",
|
||||
"FORBIDDEN": "Vous n'avez pas la permission d'effectuer cette action",
|
||||
"NOT_FOUND": "Ressource introuvable",
|
||||
"CONFLICT": "Conflit",
|
||||
"RATE_LIMITED": "Trop de requêtes — veuillez réessayer plus tard",
|
||||
"PORT_NOT_FOUND": "Port introuvable : {portCode}",
|
||||
"PORT_INVALID_CODE": "Code de port invalide : {portCode}",
|
||||
"USER_NOT_FOUND": "Utilisateur introuvable",
|
||||
"USER_EMAIL_TAKEN": "Cet email est déjà utilisé",
|
||||
"USER_INACTIVE": "Le compte utilisateur est inactif",
|
||||
"USER_EMAIL_NOT_VERIFIED": "Adresse email non vérifiée",
|
||||
"ORGANIZATION_NOT_FOUND": "Organisation introuvable",
|
||||
"INVALID_CREDENTIALS": "Email ou mot de passe invalide",
|
||||
"INVALID_TOKEN": "Jeton invalide ou expiré",
|
||||
"BOOKING_NOT_FOUND": "Réservation {bookingNumber} introuvable",
|
||||
"BOOKING_INVALID_STATUS": "Transition de statut de réservation invalide",
|
||||
"RATE_QUOTE_NOT_FOUND": "Cotation introuvable",
|
||||
"RATE_QUOTE_EXPIRED": "La cotation a expiré",
|
||||
"CARRIER_NOT_FOUND": "Transporteur introuvable",
|
||||
"NO_LICENSES_AVAILABLE": "Aucune licence disponible pour cette organisation"
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
{
|
||||
"booking": {
|
||||
"created": {
|
||||
"title": "Réservation créée",
|
||||
"message": "Votre réservation {bookingNumber} a été créée avec succès."
|
||||
},
|
||||
"updated": {
|
||||
"title": "Réservation mise à jour",
|
||||
"message": "Le statut de la réservation {bookingNumber} est passé à {status}."
|
||||
},
|
||||
"confirmed": {
|
||||
"title": "Réservation confirmée",
|
||||
"message": "Votre réservation {bookingNumber} a été confirmée par le transporteur."
|
||||
},
|
||||
"rejected": {
|
||||
"title": "Réservation refusée",
|
||||
"message": "Votre réservation {bookingNumber} a été refusée par le transporteur."
|
||||
},
|
||||
"documentUploaded": {
|
||||
"title": "Document ajouté",
|
||||
"message": "Le document « {documentName} » a été ajouté à votre réservation."
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"welcome": {
|
||||
"title": "Bienvenue sur Xpeditis",
|
||||
"message": "Bonjour {firstName}, bienvenue à bord ! Commencez par rechercher des tarifs."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
{
|
||||
"booking": {
|
||||
"title": "CONFIRMATION DE RÉSERVATION",
|
||||
"bookingNumber": "Numéro de réservation",
|
||||
"routeInformation": "Informations de route",
|
||||
"origin": "Origine",
|
||||
"destination": "Destination",
|
||||
"shipperInformation": "Expéditeur",
|
||||
"consigneeInformation": "Destinataire",
|
||||
"containerDetails": "Détails du conteneur",
|
||||
"cargoDescription": "Description de la cargaison",
|
||||
"totalPrice": "Prix total",
|
||||
"estimatedDeparture": "Départ estimé",
|
||||
"estimatedArrival": "Arrivée estimée",
|
||||
"carrier": "Transporteur",
|
||||
"status": "Statut"
|
||||
},
|
||||
"rateQuote": {
|
||||
"title": "COMPARAISON DE COTATIONS",
|
||||
"quoteNumber": "Numéro de cotation",
|
||||
"issuedAt": "Émis le",
|
||||
"validUntil": "Valide jusqu'au",
|
||||
"origin": "Origine",
|
||||
"destination": "Destination",
|
||||
"carrier": "Transporteur",
|
||||
"transitTime": "Temps de transit",
|
||||
"containerType": "Type de conteneur",
|
||||
"baseRate": "Tarif de base",
|
||||
"surcharges": "Surtaxes",
|
||||
"totalPrice": "Prix total"
|
||||
},
|
||||
"common": {
|
||||
"generatedOn": "Généré le {date}",
|
||||
"page": "Page {current} sur {total}"
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
{
|
||||
"EMAIL_REQUIRED": "L'email est requis",
|
||||
"EMAIL_INVALID": "Format d'email invalide",
|
||||
"PASSWORD_REQUIRED": "Le mot de passe est requis",
|
||||
"PASSWORD_MIN_LENGTH": "Le mot de passe doit contenir au moins {constraint1} caractères",
|
||||
"PASSWORD_MAX_LENGTH": "Le mot de passe doit contenir au plus {constraint1} caractères",
|
||||
"PASSWORD_PATTERN": "Le mot de passe doit contenir une majuscule, une minuscule, un chiffre et un caractère spécial",
|
||||
"FIRST_NAME_REQUIRED": "Le prénom est requis",
|
||||
"FIRST_NAME_MIN_LENGTH": "Le prénom doit contenir au moins {constraint1} caractères",
|
||||
"LAST_NAME_REQUIRED": "Le nom est requis",
|
||||
"LAST_NAME_MIN_LENGTH": "Le nom doit contenir au moins {constraint1} caractères",
|
||||
"PHONE_INVALID": "Numéro de téléphone invalide",
|
||||
"SIREN_PATTERN": "Le SIREN doit contenir exactement 9 chiffres",
|
||||
"SIRET_PATTERN": "Le SIRET doit contenir exactement 14 chiffres",
|
||||
"STREET_MIN_LENGTH": "L'adresse doit contenir au moins {constraint1} caractères",
|
||||
"CITY_REQUIRED": "La ville est requise",
|
||||
"POSTAL_CODE_REQUIRED": "Le code postal est requis",
|
||||
"COUNTRY_PATTERN": "Le pays doit être un code ISO à 2 lettres (ex. FR, US, CN)",
|
||||
"FIELD_REQUIRED": "Ce champ est requis",
|
||||
"FIELD_TOO_SHORT": "Doit contenir au moins {constraint1} caractères",
|
||||
"FIELD_TOO_LONG": "Doit contenir au plus {constraint1} caractères",
|
||||
"NUMBER_MIN": "Doit être supérieur ou égal à {constraint1}",
|
||||
"NUMBER_MAX": "Doit être inférieur ou égal à {constraint1}",
|
||||
"INVALID_UUID": "Format d'identifiant invalide",
|
||||
"INVALID_DATE": "Date invalide",
|
||||
"INVALID_ENUM": "Valeur invalide — valeurs autorisées : {constraint1}",
|
||||
"INVALID_BOOLEAN": "Doit être vrai ou faux",
|
||||
"INVALID_URL": "URL invalide",
|
||||
"LOCALE_INVALID": "La langue doit être 'fr' ou 'en'"
|
||||
}
|
||||
@ -5,58 +5,41 @@ import * as path from 'path';
|
||||
/**
|
||||
* CSV Converter Service
|
||||
*
|
||||
* Detects and converts CSV files to the standard 33-column Xpeditis format.
|
||||
*
|
||||
* Standard format columns (33):
|
||||
* companyName, companyEmail, originCFS, originCode, portOfLoading, routing,
|
||||
* destinationCFS, destinationCode, destinationCountry, containerType,
|
||||
* freightCurrency, freightRatePerCBM, freightMinimum,
|
||||
* fobCurrency, fobDocumentation, fobISPS, fobHandling, fobHandlingUnit,
|
||||
* fobHandlingMinimum, fobSolas, fobCustoms, fobAMS_ACI, fobISF5, fobDGAdmin,
|
||||
* dgSurchargeCurrency, dgSurchargeRate, dgSurchargeUnit, dgSurchargeMin,
|
||||
* remarks, frequency, transitDays, validFrom, validUntil
|
||||
* Détecte automatiquement le format du CSV et convertit au format attendu
|
||||
* Supporte:
|
||||
* - Format standard Xpeditis
|
||||
* - Format "Frais FOB FRET"
|
||||
*/
|
||||
@Injectable()
|
||||
export class CsvConverterService {
|
||||
private readonly logger = new Logger(CsvConverterService.name);
|
||||
|
||||
// Headers du format standard attendu
|
||||
private readonly STANDARD_HEADERS = [
|
||||
'companyName',
|
||||
'companyEmail',
|
||||
'originCFS',
|
||||
'originCode',
|
||||
'portOfLoading',
|
||||
'routing',
|
||||
'destinationCFS',
|
||||
'destinationCode',
|
||||
'destinationCountry',
|
||||
'origin',
|
||||
'destination',
|
||||
'containerType',
|
||||
'freightCurrency',
|
||||
'freightRatePerCBM',
|
||||
'freightMinimum',
|
||||
'fobCurrency',
|
||||
'fobDocumentation',
|
||||
'fobISPS',
|
||||
'fobHandling',
|
||||
'fobHandlingUnit',
|
||||
'fobHandlingMinimum',
|
||||
'fobSolas',
|
||||
'fobCustoms',
|
||||
'fobAMS_ACI',
|
||||
'fobISF5',
|
||||
'fobDGAdmin',
|
||||
'dgSurchargeCurrency',
|
||||
'dgSurchargeRate',
|
||||
'dgSurchargeUnit',
|
||||
'dgSurchargeMin',
|
||||
'remarks',
|
||||
'frequency',
|
||||
'minVolumeCBM',
|
||||
'maxVolumeCBM',
|
||||
'minWeightKG',
|
||||
'maxWeightKG',
|
||||
'palletCount',
|
||||
'pricePerCBM',
|
||||
'pricePerKG',
|
||||
'basePriceUSD',
|
||||
'basePriceEUR',
|
||||
'currency',
|
||||
'hasSurcharges',
|
||||
'surchargeBAF',
|
||||
'surchargeCAF',
|
||||
'surchargeDetails',
|
||||
'transitDays',
|
||||
'validFrom',
|
||||
'validUntil',
|
||||
];
|
||||
|
||||
// Legacy "Frais FOB FRET" format indicators (older Excel exports)
|
||||
// Headers du format "Frais FOB FRET"
|
||||
private readonly FOB_FRET_HEADERS = [
|
||||
'Origine UN code',
|
||||
'Destination UN code',
|
||||
@ -66,32 +49,259 @@ export class CsvConverterService {
|
||||
'Transit time',
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse une ligne CSV en gérant les champs entre guillemets
|
||||
*/
|
||||
private parseCSVLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
result.push(current.trim());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte le format du CSV
|
||||
*/
|
||||
async detectFormat(filePath: string): Promise<'STANDARD' | 'FOB_FRET' | 'UNKNOWN'> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
if (lines.length === 0) return 'UNKNOWN';
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
// Vérifier les 2 premières lignes (parfois la vraie ligne d'en-tête est la ligne 2)
|
||||
for (let i = 0; i < Math.min(2, lines.length); i++) {
|
||||
const headers = this.parseCSVLine(lines[i]);
|
||||
if (this.STANDARD_HEADERS.some(h => headers.includes(h))) return 'STANDARD';
|
||||
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) return 'FOB_FRET';
|
||||
|
||||
// Vérifier format standard
|
||||
const hasStandardHeaders = this.STANDARD_HEADERS.some(h => headers.includes(h));
|
||||
if (hasStandardHeaders) {
|
||||
return 'STANDARD';
|
||||
}
|
||||
|
||||
// Vérifier format FOB FRET
|
||||
const hasFobFretHeaders = this.FOB_FRET_HEADERS.some(h => headers.includes(h));
|
||||
if (hasFobFretHeaders) {
|
||||
return 'FOB_FRET';
|
||||
}
|
||||
}
|
||||
|
||||
return 'UNKNOWN';
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`Error detecting CSV format: ${errorMessage}`);
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les surcharges à partir des colonnes FOB
|
||||
*/
|
||||
private calculateSurcharges(row: Record<string, string>): string {
|
||||
const surcharges: string[] = [];
|
||||
|
||||
const surchargeFields = [
|
||||
{ key: 'Documentation (LS et Minimum)', prefix: 'DOC' },
|
||||
{ key: 'ISPS (LS et Minimum)', prefix: 'ISPS' },
|
||||
{ key: 'Manutention', prefix: 'HANDLING' },
|
||||
{ key: 'Solas (LS et Minimum)', prefix: 'SOLAS' },
|
||||
{ key: 'Douane (LS et Minimum)', prefix: 'CUSTOMS' },
|
||||
{ key: 'AMS/ACI (LS et Minimum)', prefix: 'AMS_ACI' },
|
||||
{ key: 'ISF5 (LS et Minimum)', prefix: 'ISF5' },
|
||||
{ key: 'Frais admin de dangereux (LS et Minimum)', prefix: 'DG_FEE' },
|
||||
];
|
||||
|
||||
surchargeFields.forEach(({ key, prefix }) => {
|
||||
if (row[key]) {
|
||||
const unit = key === 'Manutention' ? row['Unité de manutention (UP;Tonne)'] || 'UP' : '';
|
||||
surcharges.push(`${prefix}:${row[key]}${unit ? ' ' + unit : ''}`);
|
||||
}
|
||||
});
|
||||
|
||||
return surcharges.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une ligne FOB FRET vers le format standard
|
||||
*/
|
||||
private convertFobFretRow(row: Record<string, string>, companyName: string): Record<string, any> {
|
||||
const currency = row['Devise FRET'] || 'USD';
|
||||
const freightRate = parseFloat(row['Taux de FRET (UP)']) || 0;
|
||||
const minFreight = parseFloat(row['Minimum FRET (LS)']) || 0;
|
||||
const transitDays = parseInt(row['Transit time']) || 0;
|
||||
|
||||
// Calcul des surcharges
|
||||
const surchargeDetails = this.calculateSurcharges(row);
|
||||
const hasSurcharges = surchargeDetails.length > 0;
|
||||
|
||||
// Dates de validité (90 jours par défaut)
|
||||
const validFrom = new Date().toISOString().split('T')[0];
|
||||
const validUntil = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
// Volumes et poids standards pour LCL
|
||||
const minVolumeCBM = 1;
|
||||
const maxVolumeCBM = 20;
|
||||
const minWeightKG = 100;
|
||||
const maxWeightKG = 20000;
|
||||
|
||||
// Prix par CBM
|
||||
const pricePerCBM = freightRate > 0 ? freightRate : minFreight;
|
||||
|
||||
// Prix par KG (estimation: prix CBM / 200 kg/m³)
|
||||
const pricePerKG = pricePerCBM > 0 ? (pricePerCBM / 200).toFixed(2) : '0';
|
||||
|
||||
return {
|
||||
companyName,
|
||||
origin: row['Origine UN code'] || '',
|
||||
destination: row['Destination UN code'] || '',
|
||||
containerType: 'LCL',
|
||||
minVolumeCBM,
|
||||
maxVolumeCBM,
|
||||
minWeightKG,
|
||||
maxWeightKG,
|
||||
palletCount: 0,
|
||||
pricePerCBM,
|
||||
pricePerKG,
|
||||
basePriceUSD: currency === 'USD' ? pricePerCBM : 0,
|
||||
basePriceEUR: currency === 'EUR' ? pricePerCBM : 0,
|
||||
currency,
|
||||
hasSurcharges,
|
||||
surchargeBAF: '',
|
||||
surchargeCAF: '',
|
||||
surchargeDetails,
|
||||
transitDays,
|
||||
validFrom,
|
||||
validUntil,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un CSV FOB FRET vers le format standard
|
||||
*/
|
||||
async convertFobFretToStandard(
|
||||
inputPath: string,
|
||||
companyName: string
|
||||
): Promise<{ outputPath: string; rowsConverted: number }> {
|
||||
this.logger.log(`Converting FOB FRET CSV: ${inputPath}`);
|
||||
|
||||
try {
|
||||
// Lire le fichier
|
||||
const content = await fs.readFile(inputPath, 'utf-8');
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error('CSV file is empty or has no data rows');
|
||||
}
|
||||
|
||||
// Trouver la ligne d'en-tête réelle (chercher celle avec "Devise FRET")
|
||||
let headerLineIndex = 0;
|
||||
for (let i = 0; i < Math.min(2, lines.length); i++) {
|
||||
const headers = this.parseCSVLine(lines[i]);
|
||||
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) {
|
||||
headerLineIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
const headers = this.parseCSVLine(lines[headerLineIndex]);
|
||||
this.logger.log(`Found FOB FRET headers at line ${headerLineIndex + 1}`);
|
||||
|
||||
// Parse data rows (commencer après la ligne d'en-tête)
|
||||
const dataRows: Record<string, string>[] = [];
|
||||
for (let i = headerLineIndex + 1; i < lines.length; i++) {
|
||||
const values = this.parseCSVLine(lines[i]);
|
||||
const row: Record<string, string> = {};
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
|
||||
// Vérifier que la ligne a des données valides
|
||||
if (row['Origine UN code'] && row['Destination UN code']) {
|
||||
dataRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${dataRows.length} valid data rows`);
|
||||
|
||||
// Convertir les lignes
|
||||
const convertedRows = dataRows.map(row => this.convertFobFretRow(row, companyName));
|
||||
|
||||
// Générer le CSV de sortie
|
||||
const outputLines: string[] = [this.STANDARD_HEADERS.join(',')];
|
||||
|
||||
convertedRows.forEach(row => {
|
||||
const values = this.STANDARD_HEADERS.map(header => {
|
||||
const value = row[header];
|
||||
|
||||
// Échapper les virgules et quotes
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
(value.includes(',') || value.includes('"') || value.includes('\n'))
|
||||
) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
outputLines.push(values.join(','));
|
||||
});
|
||||
|
||||
// Écrire le fichier converti (garder le chemin absolu)
|
||||
const outputPath = inputPath.replace('.csv', '-converted.csv');
|
||||
const absoluteOutputPath = path.isAbsolute(outputPath)
|
||||
? outputPath
|
||||
: path.resolve(process.cwd(), outputPath);
|
||||
|
||||
await fs.writeFile(absoluteOutputPath, outputLines.join('\n'), 'utf-8');
|
||||
|
||||
this.logger.log(`Conversion completed: ${absoluteOutputPath} (${convertedRows.length} rows)`);
|
||||
|
||||
return {
|
||||
outputPath: absoluteOutputPath,
|
||||
rowsConverted: convertedRows.length,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`Error converting CSV: ${errorMessage}`, errorStack);
|
||||
throw new Error(`CSV conversion failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit automatiquement un CSV si nécessaire
|
||||
*/
|
||||
async autoConvert(
|
||||
inputPath: string,
|
||||
companyName: string
|
||||
): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> {
|
||||
const format = await this.detectFormat(inputPath);
|
||||
this.logger.log(`Detected CSV format: ${format} for ${inputPath}`);
|
||||
|
||||
this.logger.log(`Detected CSV format: ${format}`);
|
||||
|
||||
if (format === 'STANDARD') {
|
||||
return { convertedPath: inputPath, wasConverted: false };
|
||||
return {
|
||||
convertedPath: inputPath,
|
||||
wasConverted: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (format === 'FOB_FRET') {
|
||||
@ -103,134 +313,6 @@ export class CsvConverterService {
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Unknown CSV format. Please provide a file matching the standard 33-column schema.'
|
||||
);
|
||||
}
|
||||
|
||||
async convertFobFretToStandard(
|
||||
inputPath: string,
|
||||
companyName: string
|
||||
): Promise<{ outputPath: string; rowsConverted: number }> {
|
||||
this.logger.log(`Converting legacy FOB FRET CSV: ${inputPath}`);
|
||||
|
||||
const content = await fs.readFile(inputPath, 'utf-8');
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
|
||||
if (lines.length < 2) throw new Error('CSV file is empty or has no data rows');
|
||||
|
||||
// Find the header line
|
||||
let headerLineIndex = 0;
|
||||
for (let i = 0; i < Math.min(2, lines.length); i++) {
|
||||
const headers = this.parseCSVLine(lines[i]);
|
||||
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) {
|
||||
headerLineIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const headers = this.parseCSVLine(lines[headerLineIndex]);
|
||||
|
||||
const dataRows: Record<string, string>[] = [];
|
||||
for (let i = headerLineIndex + 1; i < lines.length; i++) {
|
||||
const values = this.parseCSVLine(lines[i]);
|
||||
const row: Record<string, string> = {};
|
||||
headers.forEach((header, idx) => (row[header] = values[idx] || ''));
|
||||
if (row['Origine UN code'] && row['Destination UN code']) {
|
||||
dataRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
const convertedRows = dataRows.map(row => this.convertFobFretRow(row, companyName));
|
||||
|
||||
const outputLines: string[] = [this.STANDARD_HEADERS.join(',')];
|
||||
convertedRows.forEach(row => {
|
||||
const values = this.STANDARD_HEADERS.map(header => {
|
||||
const value = row[header] ?? '';
|
||||
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
outputLines.push(values.join(','));
|
||||
});
|
||||
|
||||
const outputPath = path.isAbsolute(inputPath)
|
||||
? inputPath.replace('.csv', '-converted.csv')
|
||||
: path.resolve(process.cwd(), inputPath.replace('.csv', '-converted.csv'));
|
||||
|
||||
await fs.writeFile(outputPath, outputLines.join('\n'), 'utf-8');
|
||||
this.logger.log(`Conversion complete: ${outputPath} (${convertedRows.length} rows)`);
|
||||
|
||||
return { outputPath, rowsConverted: convertedRows.length };
|
||||
}
|
||||
|
||||
private convertFobFretRow(row: Record<string, string>, companyName: string): Record<string, any> {
|
||||
const freightCurrency = row['Devise FRET'] || 'USD';
|
||||
const freightRatePerCBM = parseFloat(row['Taux de FRET (UP)']) || 0;
|
||||
const freightMinimum = parseFloat(row['Minimum FRET (LS)']) || 0;
|
||||
const transitDays = parseInt(row['Transit time'], 10) || 0;
|
||||
const fobCurrency = row['Devise FOB'] || 'EUR';
|
||||
|
||||
const validFrom = new Date().toISOString().split('T')[0];
|
||||
const validUntil = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
const originCode = row['Origine UN code'] || '';
|
||||
const destinationCode = row['Destination UN code'] || '';
|
||||
|
||||
return {
|
||||
companyName,
|
||||
companyEmail: row['Email'] || '',
|
||||
originCFS: row['Origine CFS'] || originCode,
|
||||
originCode,
|
||||
portOfLoading: row['Port of Loading'] || originCode,
|
||||
routing: row['Routing'] || 'Direct',
|
||||
destinationCFS: row['Destination CFS'] || destinationCode,
|
||||
destinationCode,
|
||||
destinationCountry: row['Destination Country'] || '',
|
||||
containerType: 'LCL',
|
||||
freightCurrency,
|
||||
freightRatePerCBM,
|
||||
freightMinimum,
|
||||
fobCurrency,
|
||||
fobDocumentation: parseInt(row['Documentation (LS et Minimum)'], 10) || 0,
|
||||
fobISPS: parseInt(row['ISPS (LS et Minimum)'], 10) || 0,
|
||||
fobHandling: parseInt(row['Manutention'], 10) || 0,
|
||||
fobHandlingUnit: row['Unité de manutention (UP;Tonne)'] || 'W',
|
||||
fobHandlingMinimum: parseInt(row['Minimum manutention'], 10) || 0,
|
||||
fobSolas: parseInt(row['Solas (LS et Minimum)'], 10) || 0,
|
||||
fobCustoms: parseInt(row['Douane (LS et Minimum)'], 10) || 0,
|
||||
fobAMS_ACI: parseFloat(row['AMS/ACI (LS et Minimum)']) || 0,
|
||||
fobISF5: parseFloat(row['ISF5 (LS et Minimum)']) || 0,
|
||||
fobDGAdmin: parseInt(row['Frais admin de dangereux (LS et Minimum)'], 10) || 0,
|
||||
dgSurchargeCurrency: row['Devise surcharge DG'] || fobCurrency,
|
||||
dgSurchargeRate: row['Taux surcharge DG'] || '0',
|
||||
dgSurchargeUnit: row['Unité surcharge DG'] || 'LS',
|
||||
dgSurchargeMin: row['Minimum surcharge DG'] || '0',
|
||||
remarks: row['Remarques'] || '',
|
||||
frequency: row['Frequence'] || 'Weekly',
|
||||
transitDays,
|
||||
validFrom,
|
||||
validUntil,
|
||||
};
|
||||
}
|
||||
|
||||
private parseCSVLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (const char of line) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
result.push(current.trim());
|
||||
return result;
|
||||
throw new Error(`Unknown CSV format. Please provide a valid CSV file.`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,109 +4,61 @@ import { parse } from 'csv-parse/sync';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
|
||||
import {
|
||||
CsvRate,
|
||||
FreightPricing,
|
||||
FobCharges,
|
||||
DgSurchargeInfo,
|
||||
DgSurchargeValue,
|
||||
HandlingUnit,
|
||||
FrequencyType,
|
||||
} from '@domain/entities/csv-rate.entity';
|
||||
import { CsvRate } from '@domain/entities/csv-rate.entity';
|
||||
import { PortCode } from '@domain/value-objects/port-code.vo';
|
||||
import { ContainerType } from '@domain/value-objects/container-type.vo';
|
||||
import { Money } from '@domain/value-objects/money.vo';
|
||||
import { Surcharge, SurchargeType, SurchargeCollection } from '@domain/value-objects/surcharge.vo';
|
||||
import { DateRange } from '@domain/value-objects/date-range.vo';
|
||||
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
|
||||
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
||||
|
||||
/**
|
||||
* Standardized 33-column CSV row.
|
||||
* All suppliers share this exact schema.
|
||||
* CSV Row Interface
|
||||
* Maps to CSV file structure
|
||||
*/
|
||||
interface CsvRow {
|
||||
// Supplier identity
|
||||
companyName: string;
|
||||
companyEmail: string;
|
||||
// Route geography
|
||||
originCFS: string;
|
||||
originCode: string;
|
||||
portOfLoading: string;
|
||||
routing: string;
|
||||
destinationCFS: string;
|
||||
destinationCode: string;
|
||||
destinationCountry: string;
|
||||
// Container
|
||||
origin: string;
|
||||
destination: string;
|
||||
containerType: string;
|
||||
// Freight
|
||||
freightCurrency: string;
|
||||
freightRatePerCBM: string;
|
||||
freightMinimum: string;
|
||||
// FOB charges
|
||||
fobCurrency: string;
|
||||
fobDocumentation: string;
|
||||
fobISPS: string;
|
||||
fobHandling: string;
|
||||
fobHandlingUnit: string;
|
||||
fobHandlingMinimum: string;
|
||||
fobSolas: string;
|
||||
fobCustoms: string;
|
||||
fobAMS_ACI: string;
|
||||
fobISF5: string;
|
||||
fobDGAdmin: string;
|
||||
// DG surcharge
|
||||
dgSurchargeCurrency: string;
|
||||
dgSurchargeRate: string;
|
||||
dgSurchargeUnit: string;
|
||||
dgSurchargeMin: string;
|
||||
// Metadata
|
||||
remarks: string;
|
||||
frequency: string;
|
||||
minVolumeCBM: string;
|
||||
maxVolumeCBM: string;
|
||||
minWeightKG: string;
|
||||
maxWeightKG: string;
|
||||
palletCount: string;
|
||||
pricePerCBM: string;
|
||||
pricePerKG: string;
|
||||
basePriceUSD: string;
|
||||
basePriceEUR: string;
|
||||
currency: string;
|
||||
hasSurcharges: string;
|
||||
surchargeBAF?: string;
|
||||
surchargeCAF?: string;
|
||||
surchargeDetails?: string;
|
||||
transitDays: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
}
|
||||
|
||||
const REQUIRED_COLUMNS = [
|
||||
'companyName',
|
||||
'companyEmail',
|
||||
'originCFS',
|
||||
'originCode',
|
||||
'portOfLoading',
|
||||
'routing',
|
||||
'destinationCFS',
|
||||
'destinationCode',
|
||||
'destinationCountry',
|
||||
'containerType',
|
||||
'freightCurrency',
|
||||
'freightRatePerCBM',
|
||||
'freightMinimum',
|
||||
'fobCurrency',
|
||||
'fobDocumentation',
|
||||
'fobISPS',
|
||||
'fobHandling',
|
||||
'fobHandlingUnit',
|
||||
'fobHandlingMinimum',
|
||||
'fobSolas',
|
||||
'fobCustoms',
|
||||
'fobAMS_ACI',
|
||||
'fobISF5',
|
||||
'fobDGAdmin',
|
||||
'dgSurchargeCurrency',
|
||||
'dgSurchargeRate',
|
||||
'dgSurchargeUnit',
|
||||
'dgSurchargeMin',
|
||||
'remarks',
|
||||
'frequency',
|
||||
'transitDays',
|
||||
'validFrom',
|
||||
'validUntil',
|
||||
];
|
||||
|
||||
/**
|
||||
* CSV Rate Loader Adapter
|
||||
*
|
||||
* Infrastructure adapter for loading shipping rates from CSV files.
|
||||
* Implements CsvRateLoaderPort interface.
|
||||
*
|
||||
* Features:
|
||||
* - CSV parsing with validation
|
||||
* - Mapping CSV rows to domain entities
|
||||
* - Error handling and logging
|
||||
* - File system operations
|
||||
*/
|
||||
@Injectable()
|
||||
export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
private readonly logger = new Logger(CsvRateLoaderAdapter.name);
|
||||
private readonly csvDirectory: string;
|
||||
|
||||
// Company name to CSV file mapping
|
||||
private readonly companyFileMapping: Map<string, string> = new Map([
|
||||
['SSC Consolidation', 'ssc-consolidation.csv'],
|
||||
['ECU Worldwide', 'ecu-worldwide.csv'],
|
||||
@ -119,6 +71,10 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
@Optional() private readonly configService?: ConfigService,
|
||||
@Optional() private readonly csvConfigRepository?: TypeOrmCsvRateConfigRepository
|
||||
) {
|
||||
// CSV files are stored in infrastructure/storage/csv-storage/rates/
|
||||
// Use absolute path based on project root (works in both dev and production)
|
||||
// In production, process.cwd() points to the backend app directory
|
||||
// In development with nest start --watch, it also points to the backend directory
|
||||
this.csvDirectory = path.join(
|
||||
process.cwd(),
|
||||
'src',
|
||||
@ -128,6 +84,10 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
'rates'
|
||||
);
|
||||
this.logger.log(`CSV directory initialized: ${this.csvDirectory}`);
|
||||
|
||||
if (this.s3Storage && this.configService) {
|
||||
this.logger.log('✅ MinIO/S3 storage support enabled for CSV files');
|
||||
}
|
||||
}
|
||||
|
||||
async loadRatesFromCsv(
|
||||
@ -135,32 +95,49 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
companyEmail: string,
|
||||
companyNameOverride?: string
|
||||
): Promise<CsvRate[]> {
|
||||
this.logger.log(`Loading rates from CSV: ${filePath}`);
|
||||
this.logger.log(
|
||||
`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`
|
||||
);
|
||||
|
||||
try {
|
||||
let fileContent: string;
|
||||
|
||||
// Try to load from MinIO first if configured
|
||||
if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) {
|
||||
try {
|
||||
const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride);
|
||||
const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined;
|
||||
|
||||
if (minioObjectKey) {
|
||||
const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
|
||||
this.logger.log(`📥 Loading CSV from MinIO: ${bucket}/${minioObjectKey}`);
|
||||
|
||||
const buffer = await this.s3Storage.download({ bucket, key: minioObjectKey });
|
||||
fileContent = buffer.toString('utf-8');
|
||||
this.logger.log(`✅ Successfully loaded CSV from MinIO`);
|
||||
} else {
|
||||
throw new Error('No MinIO object key');
|
||||
// Fallback to local file
|
||||
throw new Error('No MinIO object key found, using local file');
|
||||
}
|
||||
} catch (minioError: any) {
|
||||
this.logger.warn(`MinIO unavailable: ${minioError.message}. Using local file.`);
|
||||
const fullPath = this.resolvePath(filePath);
|
||||
this.logger.warn(
|
||||
`⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.`
|
||||
);
|
||||
// Fallback to local file system
|
||||
const fullPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.join(this.csvDirectory, filePath);
|
||||
fileContent = await fs.readFile(fullPath, 'utf-8');
|
||||
}
|
||||
} else {
|
||||
const fullPath = this.resolvePath(filePath);
|
||||
// Read from local file system
|
||||
const fullPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.join(this.csvDirectory, filePath);
|
||||
fileContent = await fs.readFile(fullPath, 'utf-8');
|
||||
}
|
||||
|
||||
// Parse CSV
|
||||
const records: CsvRow[] = parse(fileContent, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
@ -168,48 +145,62 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
});
|
||||
|
||||
this.logger.log(`Parsed ${records.length} rows from ${filePath}`);
|
||||
|
||||
// Validate structure
|
||||
this.validateCsvStructure(records);
|
||||
|
||||
// Map to domain entities
|
||||
const rates = records.map((record, index) => {
|
||||
try {
|
||||
return this.mapToCsvRate(record, companyEmail, companyNameOverride);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Row ${index + 1} in ${filePath}: ${msg}`);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`);
|
||||
throw new Error(`Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log(`Loaded ${rates.length} rates from ${filePath}`);
|
||||
this.logger.log(`Successfully loaded ${rates.length} rates from ${filePath}`);
|
||||
return rates;
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Failed to load ${filePath}: ${msg}`);
|
||||
throw new Error(`CSV loading failed for ${filePath}: ${msg}`);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Failed to load CSV file ${filePath}: ${errorMessage}`);
|
||||
throw new Error(`CSV loading failed for ${filePath}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
async loadRatesByCompany(companyName: string): Promise<CsvRate[]> {
|
||||
const fileName = this.companyFileMapping.get(companyName);
|
||||
|
||||
if (!fileName) {
|
||||
this.logger.warn(`No CSV file for company: ${companyName}`);
|
||||
this.logger.warn(`No CSV file configured for company: ${companyName}`);
|
||||
return [];
|
||||
}
|
||||
const email = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
|
||||
return this.loadRatesFromCsv(fileName, email);
|
||||
|
||||
// Use placeholder email since we don't have access to config repository here
|
||||
const placeholderEmail = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
|
||||
return this.loadRatesFromCsv(fileName, placeholderEmail);
|
||||
}
|
||||
|
||||
async validateCsvFile(
|
||||
filePath: string
|
||||
): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
const fullPath = this.resolvePath(filePath);
|
||||
const fullPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.join(this.csvDirectory, filePath);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
} catch {
|
||||
return { valid: false, errors: [`File not found: ${filePath}`] };
|
||||
errors.push(`File not found: ${filePath}`);
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// Read and parse
|
||||
const fileContent = await fs.readFile(fullPath, 'utf-8');
|
||||
const records: CsvRow[] = parse(fileContent, {
|
||||
columns: true,
|
||||
@ -218,154 +209,200 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
});
|
||||
|
||||
if (records.length === 0) {
|
||||
return { valid: false, errors: ['CSV file is empty'], rowCount: 0 };
|
||||
errors.push('CSV file is empty');
|
||||
return { valid: false, errors, rowCount: 0 };
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
try {
|
||||
this.validateCsvStructure(records);
|
||||
} catch (e) {
|
||||
errors.push(e instanceof Error ? e.message : String(e));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
errors.push(errorMessage);
|
||||
}
|
||||
|
||||
// Validate each row (use dummy email for validation)
|
||||
records.forEach((record, index) => {
|
||||
try {
|
||||
this.mapToCsvRate(record, 'validation@example.com');
|
||||
} catch (e) {
|
||||
errors.push(`Row ${index + 1}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Row ${index + 1}: ${errorMessage}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { valid: errors.length === 0, errors, rowCount: records.length };
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Validation failed: ${e instanceof Error ? e.message : String(e)}`],
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
rowCount: records.length,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Validation failed: ${errorMessage}`);
|
||||
return { valid: false, errors };
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableCsvFiles(): Promise<string[]> {
|
||||
try {
|
||||
if (this.s3Storage && this.csvConfigRepository) {
|
||||
// If MinIO/S3 is configured, list files from there
|
||||
if (this.s3Storage && this.configService && this.csvConfigRepository) {
|
||||
try {
|
||||
const configs = await this.csvConfigRepository.findAll();
|
||||
const minioFiles = configs
|
||||
.filter(c => c.metadata?.minioObjectKey)
|
||||
.map(c => c.metadata?.minioObjectKey as string);
|
||||
if (minioFiles.length > 0) return minioFiles;
|
||||
} catch {
|
||||
// fall through to local
|
||||
.filter(config => config.metadata?.minioObjectKey)
|
||||
.map(config => config.metadata?.minioObjectKey as string);
|
||||
|
||||
if (minioFiles.length > 0) {
|
||||
this.logger.log(`📂 Found ${minioFiles.length} CSV files in MinIO`);
|
||||
return minioFiles;
|
||||
} else {
|
||||
this.logger.warn('⚠️ No CSV files configured in MinIO, falling back to local files');
|
||||
}
|
||||
} catch (minioError: any) {
|
||||
this.logger.warn(
|
||||
`⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: list from local file system
|
||||
try {
|
||||
await fs.access(this.csvDirectory);
|
||||
} catch {
|
||||
this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await fs.readdir(this.csvDirectory);
|
||||
return files.filter(f => f.endsWith('.csv'));
|
||||
} catch {
|
||||
return files.filter(file => file.endsWith('.csv'));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Failed to list CSV files: ${errorMessage}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePath(filePath: string): string {
|
||||
return path.isAbsolute(filePath) ? filePath : path.join(this.csvDirectory, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that CSV has all required columns
|
||||
*/
|
||||
private validateCsvStructure(records: CsvRow[]): void {
|
||||
if (records.length === 0) throw new Error('CSV file is empty');
|
||||
const requiredColumns = [
|
||||
'companyName',
|
||||
'origin',
|
||||
'destination',
|
||||
'containerType',
|
||||
'minVolumeCBM',
|
||||
'maxVolumeCBM',
|
||||
'minWeightKG',
|
||||
'maxWeightKG',
|
||||
'palletCount',
|
||||
'pricePerCBM',
|
||||
'pricePerKG',
|
||||
'basePriceUSD',
|
||||
'basePriceEUR',
|
||||
'currency',
|
||||
'hasSurcharges',
|
||||
'transitDays',
|
||||
'validFrom',
|
||||
'validUntil',
|
||||
];
|
||||
|
||||
if (records.length === 0) {
|
||||
throw new Error('CSV file is empty');
|
||||
}
|
||||
|
||||
const firstRecord = records[0];
|
||||
const missing = REQUIRED_COLUMNS.filter(col => !(col in firstRecord));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required columns: ${missing.join(', ')}`);
|
||||
const missingColumns = requiredColumns.filter(col => !(col in firstRecord));
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
throw new Error(`Missing required columns: ${missingColumns.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
private mapToCsvRate(r: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate {
|
||||
const companyName = companyNameOverride || r.companyName.trim();
|
||||
// Admin-configured email always takes priority over the value in the CSV row
|
||||
const email = companyEmail?.trim() || r.companyEmail?.trim();
|
||||
/**
|
||||
* Map CSV row to CsvRate domain entity
|
||||
*/
|
||||
private mapToCsvRate(
|
||||
record: CsvRow,
|
||||
companyEmail: string,
|
||||
companyNameOverride?: string
|
||||
): CsvRate {
|
||||
// Parse surcharges
|
||||
const surcharges = this.parseSurcharges(record);
|
||||
|
||||
const freight: FreightPricing = {
|
||||
freightCurrency: r.freightCurrency.toUpperCase(),
|
||||
freightRatePerCBM: parseFloat(r.freightRatePerCBM) || 0,
|
||||
freightMinimum: parseFloat(r.freightMinimum) || 0,
|
||||
};
|
||||
|
||||
const fob: FobCharges = {
|
||||
fobCurrency: r.fobCurrency.toUpperCase(),
|
||||
fobDocumentation: parseInt(r.fobDocumentation, 10) || 0,
|
||||
fobISPS: parseInt(r.fobISPS, 10) || 0,
|
||||
fobHandling: parseInt(r.fobHandling, 10) || 0,
|
||||
fobHandlingUnit: (r.fobHandlingUnit?.toUpperCase() === 'W' ? 'W' : 'UP') as HandlingUnit,
|
||||
fobHandlingMinimum: parseInt(r.fobHandlingMinimum, 10) || 0,
|
||||
fobSolas: parseInt(r.fobSolas, 10) || 0,
|
||||
fobCustoms: parseInt(r.fobCustoms, 10) || 0,
|
||||
fobAMS_ACI: parseFloat(r.fobAMS_ACI) || 0,
|
||||
fobISF5: parseFloat(r.fobISF5) || 0,
|
||||
fobDGAdmin: parseInt(r.fobDGAdmin, 10) || 0,
|
||||
};
|
||||
|
||||
const dgSurcharge: DgSurchargeInfo = {
|
||||
dgSurchargeCurrency: (r.dgSurchargeCurrency || r.fobCurrency).toUpperCase(),
|
||||
dgSurchargeRate: parseDgValue(r.dgSurchargeRate),
|
||||
dgSurchargeUnit: (['UP', 'LS', '%'].includes(r.dgSurchargeUnit?.toUpperCase())
|
||||
? r.dgSurchargeUnit.toUpperCase()
|
||||
: 'LS') as 'UP' | 'LS' | '%',
|
||||
dgSurchargeMin: parseDgValue(r.dgSurchargeMin),
|
||||
};
|
||||
|
||||
const validFrom = new Date(r.validFrom);
|
||||
const validUntil = new Date(r.validUntil);
|
||||
// Create DateRange
|
||||
const validFrom = new Date(record.validFrom);
|
||||
const validUntil = new Date(record.validUntil);
|
||||
const validity = DateRange.create(validFrom, validUntil, true);
|
||||
|
||||
const frequency = parseFrequency(r.frequency);
|
||||
// Use override company name if provided, otherwise use the one from CSV
|
||||
const companyName = companyNameOverride || record.companyName.trim();
|
||||
|
||||
// Create CsvRate
|
||||
return new CsvRate(
|
||||
companyName,
|
||||
email,
|
||||
r.originCFS.trim(),
|
||||
PortCode.create(r.originCode.trim()),
|
||||
r.portOfLoading.trim(),
|
||||
r.routing.trim(),
|
||||
r.destinationCFS.trim(),
|
||||
PortCode.create(r.destinationCode.trim()),
|
||||
r.destinationCountry.trim(),
|
||||
ContainerType.create(r.containerType.trim()),
|
||||
freight,
|
||||
fob,
|
||||
dgSurcharge,
|
||||
r.remarks?.trim() || '',
|
||||
frequency,
|
||||
parseInt(r.transitDays, 10),
|
||||
companyEmail,
|
||||
PortCode.create(record.origin),
|
||||
PortCode.create(record.destination),
|
||||
ContainerType.create(record.containerType),
|
||||
{
|
||||
minCBM: parseFloat(record.minVolumeCBM),
|
||||
maxCBM: parseFloat(record.maxVolumeCBM),
|
||||
},
|
||||
{
|
||||
minKG: parseFloat(record.minWeightKG),
|
||||
maxKG: parseFloat(record.maxWeightKG),
|
||||
},
|
||||
parseInt(record.palletCount, 10),
|
||||
{
|
||||
pricePerCBM: parseFloat(record.pricePerCBM),
|
||||
pricePerKG: parseFloat(record.pricePerKG),
|
||||
basePriceUSD: Money.create(parseFloat(record.basePriceUSD), 'USD'),
|
||||
basePriceEUR: Money.create(parseFloat(record.basePriceEUR), 'EUR'),
|
||||
},
|
||||
record.currency.toUpperCase(),
|
||||
new SurchargeCollection(surcharges),
|
||||
parseInt(record.transitDays, 10),
|
||||
validity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDgValue(raw: string): DgSurchargeValue {
|
||||
if (!raw || raw.trim() === '') return 0;
|
||||
const upper = raw.trim().toUpperCase();
|
||||
if (upper === 'ON REQUEST') return 'ON REQUEST';
|
||||
if (upper === 'NOT ACCEPTED') return 'NOT ACCEPTED';
|
||||
const num = parseFloat(raw);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
/**
|
||||
* Parse surcharges from CSV row
|
||||
*/
|
||||
private parseSurcharges(record: CsvRow): Surcharge[] {
|
||||
const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true';
|
||||
|
||||
function parseFrequency(raw: string): FrequencyType {
|
||||
switch (raw?.trim()) {
|
||||
case 'Weekly':
|
||||
return 'Weekly';
|
||||
case 'Bi-Weekly':
|
||||
return 'Bi-Weekly';
|
||||
case 'Bi-Monthly':
|
||||
return 'Bi-Monthly';
|
||||
case 'Monthly':
|
||||
return 'Monthly';
|
||||
default:
|
||||
return 'Weekly';
|
||||
if (!hasSurcharges) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const surcharges: Surcharge[] = [];
|
||||
const currency = record.currency.toUpperCase();
|
||||
|
||||
// BAF (Bunker Adjustment Factor)
|
||||
if (record.surchargeBAF && parseFloat(record.surchargeBAF) > 0) {
|
||||
surcharges.push(
|
||||
new Surcharge(
|
||||
SurchargeType.BAF,
|
||||
Money.create(parseFloat(record.surchargeBAF), currency),
|
||||
'Bunker Adjustment Factor'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// CAF (Currency Adjustment Factor)
|
||||
if (record.surchargeCAF && parseFloat(record.surchargeCAF) > 0) {
|
||||
surcharges.push(
|
||||
new Surcharge(
|
||||
SurchargeType.CAF,
|
||||
Money.create(parseFloat(record.surchargeCAF), currency),
|
||||
'Currency Adjustment Factor'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return surcharges;
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,9 +73,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
|
||||
this.buildTransporter(ip, host);
|
||||
return;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(
|
||||
`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`
|
||||
);
|
||||
this.logger.warn(`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,9 +87,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
|
||||
private resolveViaDoH(hostname: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`;
|
||||
const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, res => {
|
||||
const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', chunk => (raw += chunk));
|
||||
res.on('data', (chunk) => (raw += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(raw);
|
||||
@ -138,7 +136,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
|
||||
`Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
|
||||
);
|
||||
|
||||
this.transporter.verify(error => {
|
||||
this.transporter.verify((error) => {
|
||||
if (error) {
|
||||
this.logger.error(`❌ SMTP connection FAILED: ${error.message}`);
|
||||
} else {
|
||||
@ -150,7 +148,8 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
|
||||
async send(options: EmailOptions): Promise<void> {
|
||||
try {
|
||||
const from =
|
||||
options.from ?? this.configService.get<string>('SMTP_FROM', EMAIL_SENDERS.NOREPLY);
|
||||
options.from ??
|
||||
this.configService.get<string>('SMTP_FROM', EMAIL_SENDERS.NOREPLY);
|
||||
|
||||
// Génère automatiquement la version plain text si absente (améliore le score anti-spam)
|
||||
const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined);
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
/**
|
||||
* UserPreferenceResolver
|
||||
*
|
||||
* nestjs-i18n resolver that reads the authenticated user's preferredLanguage
|
||||
* from the request (populated by JwtAuthGuard). Highest priority in the chain.
|
||||
*/
|
||||
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { I18nResolver } from 'nestjs-i18n';
|
||||
import { isLocale } from '@domain/value-objects/locale.vo';
|
||||
|
||||
@Injectable()
|
||||
export class UserPreferenceResolver implements I18nResolver {
|
||||
resolve(context: ExecutionContext): string | undefined {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const preferred = request?.user?.preferredLanguage;
|
||||
return isLocale(preferred) ? preferred : undefined;
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('blog_posts')
|
||||
@Index(['status', 'published_at'])
|
||||
@Index(['slug'], { unique: true })
|
||||
export class BlogPostOrmEntity {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column('varchar', { length: 255, unique: true })
|
||||
slug: string;
|
||||
|
||||
@Column('text')
|
||||
excerpt: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column('varchar', { length: 500, nullable: true })
|
||||
cover_image_url?: string;
|
||||
|
||||
@Column('varchar', { length: 50 })
|
||||
category: string;
|
||||
|
||||
@Column('jsonb', { default: [] })
|
||||
tags: string[];
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
author_name: string;
|
||||
|
||||
@Column('varchar', { length: 20, default: 'draft' })
|
||||
status: string;
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
is_featured: boolean;
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
published_at?: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@ -75,24 +75,11 @@ export class CsvBookingOrmEntity {
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'enum',
|
||||
enum: [
|
||||
'PENDING_PAYMENT',
|
||||
'PENDING_BANK_TRANSFER',
|
||||
'PENDING',
|
||||
'ACCEPTED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
],
|
||||
enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
|
||||
default: 'PENDING_PAYMENT',
|
||||
})
|
||||
@Index()
|
||||
status:
|
||||
| 'PENDING_PAYMENT'
|
||||
| 'PENDING_BANK_TRANSFER'
|
||||
| 'PENDING'
|
||||
| 'ACCEPTED'
|
||||
| 'REJECTED'
|
||||
| 'CANCELLED';
|
||||
status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
||||
|
||||
@Column({ name: 'documents', type: 'jsonb' })
|
||||
documents: Array<{
|
||||
|
||||
@ -74,9 +74,6 @@ export class OrganizationOrmEntity {
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ name: 'default_language', type: 'varchar', length: 2, default: 'fr' })
|
||||
defaultLanguage: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('password_reset_tokens')
|
||||
export class PasswordResetTokenOrmEntity {
|
||||
|
||||
@ -62,9 +62,6 @@ export class UserOrmEntity {
|
||||
@Column({ name: 'last_login_at', type: 'timestamp', nullable: true })
|
||||
lastLoginAt: Date | null;
|
||||
|
||||
@Column({ name: 'preferred_language', type: 'varchar', length: 2, default: 'fr' })
|
||||
preferredLanguage: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { Organization, OrganizationProps } from '@domain/entities/organization.entity';
|
||||
import { toLocale } from '@domain/value-objects/locale.vo';
|
||||
import { OrganizationOrmEntity } from '../entities/organization.orm-entity';
|
||||
|
||||
export class OrganizationOrmMapper {
|
||||
@ -35,7 +34,6 @@ export class OrganizationOrmMapper {
|
||||
orm.siretVerified = props.siretVerified;
|
||||
orm.statusBadge = props.statusBadge;
|
||||
orm.isActive = props.isActive;
|
||||
orm.defaultLanguage = props.defaultLanguage;
|
||||
orm.createdAt = props.createdAt;
|
||||
orm.updatedAt = props.updatedAt;
|
||||
|
||||
@ -68,7 +66,6 @@ export class OrganizationOrmMapper {
|
||||
siretVerified: orm.siretVerified ?? false,
|
||||
statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none',
|
||||
isActive: orm.isActive,
|
||||
defaultLanguage: toLocale(orm.defaultLanguage),
|
||||
createdAt: orm.createdAt,
|
||||
updatedAt: orm.updatedAt,
|
||||
};
|
||||
|
||||
@ -5,10 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Subscription } from '@domain/entities/subscription.entity';
|
||||
import {
|
||||
SubscriptionOrmEntity,
|
||||
SubscriptionPlanOrmType,
|
||||
} from '../entities/subscription.orm-entity';
|
||||
import { SubscriptionOrmEntity, SubscriptionPlanOrmType } from '../entities/subscription.orm-entity';
|
||||
|
||||
/** Maps canonical domain plan names back to the values stored in the DB. */
|
||||
const DOMAIN_TO_ORM_PLAN: Record<string, SubscriptionPlanOrmType> = {
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { User, UserProps } from '@domain/entities/user.entity';
|
||||
import { toLocale } from '@domain/value-objects/locale.vo';
|
||||
import { UserOrmEntity } from '../entities/user.orm-entity';
|
||||
|
||||
export class UserOrmMapper {
|
||||
@ -28,7 +27,6 @@ export class UserOrmMapper {
|
||||
orm.isEmailVerified = props.isEmailVerified;
|
||||
orm.isActive = props.isActive;
|
||||
orm.lastLoginAt = props.lastLoginAt || null;
|
||||
orm.preferredLanguage = props.preferredLanguage;
|
||||
orm.createdAt = props.createdAt;
|
||||
orm.updatedAt = props.updatedAt;
|
||||
|
||||
@ -52,7 +50,6 @@ export class UserOrmMapper {
|
||||
isEmailVerified: orm.isEmailVerified,
|
||||
isActive: orm.isActive,
|
||||
lastLoginAt: orm.lastLoginAt || undefined,
|
||||
preferredLanguage: toLocale(orm.preferredLanguage),
|
||||
createdAt: orm.createdAt,
|
||||
updatedAt: orm.updatedAt,
|
||||
};
|
||||
|
||||
@ -38,9 +38,15 @@ export class CreateApiKeysTable1741000000001 implements MigrationInterface {
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_api_keys_organization_id" ON "api_keys" ("organization_id")`
|
||||
);
|
||||
await queryRunner.query(`CREATE INDEX "idx_api_keys_user_id" ON "api_keys" ("user_id")`);
|
||||
await queryRunner.query(`CREATE INDEX "idx_api_keys_is_active" ON "api_keys" ("is_active")`);
|
||||
await queryRunner.query(`CREATE INDEX "idx_api_keys_key_hash" ON "api_keys" ("key_hash")`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_api_keys_user_id" ON "api_keys" ("user_id")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_api_keys_is_active" ON "api_keys" ("is_active")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_api_keys_key_hash" ON "api_keys" ("key_hash")`
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`COMMENT ON TABLE "api_keys" IS 'API keys for programmatic access — GOLD and PLATINIUM plans only'`
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPreferredLanguage1745000000000 implements MigrationInterface {
|
||||
name = 'AddPreferredLanguage1745000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "preferred_language" VARCHAR(2) NOT NULL DEFAULT 'fr'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "organizations"
|
||||
ADD COLUMN "default_language" VARCHAR(2) NOT NULL DEFAULT 'fr'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "organizations" DROP COLUMN "default_language"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "users" DROP COLUMN "preferred_language"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateBlogPostsTable1746000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'blog_posts',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: false,
|
||||
isUnique: true,
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'cover_image_url',
|
||||
type: 'varchar',
|
||||
length: '500',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'jsonb',
|
||||
isNullable: false,
|
||||
default: "'[]'",
|
||||
},
|
||||
{
|
||||
name: 'author_name',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
length: '20',
|
||||
isNullable: false,
|
||||
default: "'draft'",
|
||||
},
|
||||
{
|
||||
name: 'is_featured',
|
||||
type: 'boolean',
|
||||
isNullable: false,
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
name: 'published_at',
|
||||
type: 'timestamp',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
isNullable: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'blog_posts',
|
||||
new TableIndex({
|
||||
name: 'idx_blog_posts_status_published_at',
|
||||
columnNames: ['status', 'published_at'],
|
||||
})
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'blog_posts',
|
||||
new TableIndex({
|
||||
name: 'idx_blog_posts_category_status',
|
||||
columnNames: ['category', 'status'],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('blog_posts');
|
||||
}
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BlogPost, BlogPostCategory, BlogPostStatus } from '@domain/entities/blog-post.entity';
|
||||
import { BlogPostFilters, BlogPostRepository } from '@domain/ports/out/blog-post.repository';
|
||||
import { BlogPostOrmEntity } from '../entities/blog-post.orm-entity';
|
||||
|
||||
@Injectable()
|
||||
export class TypeOrmBlogPostRepository implements BlogPostRepository {
|
||||
constructor(
|
||||
@InjectRepository(BlogPostOrmEntity)
|
||||
private readonly ormRepository: Repository<BlogPostOrmEntity>
|
||||
) {}
|
||||
|
||||
async save(post: BlogPost): Promise<BlogPost> {
|
||||
const orm = this.toOrm(post);
|
||||
const saved = await this.ormRepository.save(orm);
|
||||
return this.toDomain(saved);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BlogPost | null> {
|
||||
const orm = await this.ormRepository.findOne({ where: { id } });
|
||||
return orm ? this.toDomain(orm) : null;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<BlogPost | null> {
|
||||
const orm = await this.ormRepository.findOne({ where: { slug } });
|
||||
return orm ? this.toDomain(orm) : null;
|
||||
}
|
||||
|
||||
async findByFilters(filters: BlogPostFilters): Promise<BlogPost[]> {
|
||||
const query = this.ormRepository.createQueryBuilder('post');
|
||||
|
||||
if (filters.status) {
|
||||
query.andWhere('post.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.category) {
|
||||
query.andWhere('post.category = :category', { category: filters.category });
|
||||
}
|
||||
|
||||
if (filters.isFeatured !== undefined) {
|
||||
query.andWhere('post.is_featured = :isFeatured', { isFeatured: filters.isFeatured });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
query.andWhere('(post.title ILIKE :search OR post.excerpt ILIKE :search)', {
|
||||
search: `%${filters.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
query.orderBy('post.published_at', 'DESC').addOrderBy('post.created_at', 'DESC');
|
||||
|
||||
if (filters.offset) query.skip(filters.offset);
|
||||
if (filters.limit) query.take(filters.limit);
|
||||
|
||||
const results = await query.getMany();
|
||||
return results.map(e => this.toDomain(e));
|
||||
}
|
||||
|
||||
async count(filters: BlogPostFilters): Promise<number> {
|
||||
const query = this.ormRepository.createQueryBuilder('post');
|
||||
|
||||
if (filters.status) {
|
||||
query.andWhere('post.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.category) {
|
||||
query.andWhere('post.category = :category', { category: filters.category });
|
||||
}
|
||||
if (filters.search) {
|
||||
query.andWhere('(post.title ILIKE :search OR post.excerpt ILIKE :search)', {
|
||||
search: `%${filters.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
return query.getCount();
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.ormRepository.delete(id);
|
||||
}
|
||||
|
||||
async slugExists(slug: string, excludeId?: string): Promise<boolean> {
|
||||
const query = this.ormRepository
|
||||
.createQueryBuilder('post')
|
||||
.where('post.slug = :slug', { slug });
|
||||
if (excludeId) {
|
||||
query.andWhere('post.id != :excludeId', { excludeId });
|
||||
}
|
||||
const count = await query.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
private toDomain(orm: BlogPostOrmEntity): BlogPost {
|
||||
return BlogPost.fromPersistence({
|
||||
id: orm.id,
|
||||
title: orm.title,
|
||||
slug: orm.slug,
|
||||
excerpt: orm.excerpt,
|
||||
content: orm.content,
|
||||
coverImageUrl: orm.cover_image_url,
|
||||
category: orm.category as BlogPostCategory,
|
||||
tags: orm.tags ?? [],
|
||||
authorName: orm.author_name,
|
||||
status: orm.status as BlogPostStatus,
|
||||
isFeatured: orm.is_featured,
|
||||
publishedAt: orm.published_at,
|
||||
createdAt: orm.created_at,
|
||||
updatedAt: orm.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
private toOrm(post: BlogPost): BlogPostOrmEntity {
|
||||
const orm = new BlogPostOrmEntity();
|
||||
orm.id = post.id;
|
||||
orm.title = post.title;
|
||||
orm.slug = post.slug;
|
||||
orm.excerpt = post.excerpt;
|
||||
orm.content = post.content;
|
||||
orm.cover_image_url = post.coverImageUrl;
|
||||
orm.category = post.category;
|
||||
orm.tags = post.tags;
|
||||
orm.author_name = post.authorName;
|
||||
orm.status = post.status;
|
||||
orm.is_featured = post.isFeatured;
|
||||
orm.published_at = post.publishedAt;
|
||||
orm.created_at = post.createdAt;
|
||||
orm.updated_at = post.updatedAt;
|
||||
return orm;
|
||||
}
|
||||
}
|
||||
@ -12,8 +12,6 @@ import {
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
HeadBucketCommand,
|
||||
CreateBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
@ -72,23 +70,6 @@ export class S3StorageAdapter implements StoragePort {
|
||||
);
|
||||
}
|
||||
|
||||
async ensureBucket(bucket: string): Promise<void> {
|
||||
if (!this.s3Client) return;
|
||||
|
||||
try {
|
||||
await this.s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
} catch (err: any) {
|
||||
const status = err.$metadata?.httpStatusCode;
|
||||
if (status === 404 || err.name === 'NoSuchBucket' || err.name === 'NotFound') {
|
||||
this.logger.log(`Bucket "${bucket}" not found — creating it automatically`);
|
||||
await this.s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
this.logger.log(`Bucket "${bucket}" created`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async upload(options: UploadOptions): Promise<StorageObject> {
|
||||
if (!this.s3Client) {
|
||||
throw new Error(
|
||||
@ -96,8 +77,6 @@ export class S3StorageAdapter implements StoragePort {
|
||||
);
|
||||
}
|
||||
|
||||
await this.ensureBucket(options.bucket);
|
||||
|
||||
try {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: options.bucket,
|
||||
@ -129,12 +108,6 @@ export class S3StorageAdapter implements StoragePort {
|
||||
}
|
||||
|
||||
async download(options: DownloadOptions): Promise<Buffer> {
|
||||
if (!this.s3Client) {
|
||||
throw new Error(
|
||||
'S3 Storage is not configured. Set AWS_S3_ENDPOINT or AWS credentials in .env'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: options.bucket,
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { VersioningType } from '@nestjs/common';
|
||||
import { ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
|
||||
import { DomainExceptionFilter } from './application/filters/domain-exception.filter';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
async function bootstrap() {
|
||||
@ -44,9 +42,9 @@ async function bootstrap() {
|
||||
type: VersioningType.URI,
|
||||
});
|
||||
|
||||
// Global validation pipe — i18n-aware (messages translated to caller locale)
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new I18nValidationPipe({
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
@ -56,15 +54,6 @@ async function bootstrap() {
|
||||
})
|
||||
);
|
||||
|
||||
// Global exception filters — each filter declares its target via @Catch(),
|
||||
// so they don't overlap: DomainExceptionFilter handles DomainException,
|
||||
// I18nValidationExceptionFilter handles class-validator errors.
|
||||
const i18nService = app.get(I18nService) as I18nService<Record<string, unknown>>;
|
||||
app.useGlobalFilters(
|
||||
new DomainExceptionFilter(i18nService),
|
||||
new I18nValidationExceptionFilter({ detailedErrors: false })
|
||||
);
|
||||
|
||||
// ─── Swagger documentation ────────────────────────────────────────────────
|
||||
const swaggerUser = configService.get<string>('SWAGGER_USERNAME');
|
||||
const swaggerPass = configService.get<string>('SWAGGER_PASSWORD');
|
||||
|
||||
@ -58,7 +58,7 @@ async function runMigrations() {
|
||||
console.log('✅ No pending migrations');
|
||||
} else {
|
||||
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
|
||||
migrations.forEach(migration => {
|
||||
migrations.forEach((migration) => {
|
||||
console.log(` - ${migration.name}`);
|
||||
});
|
||||
}
|
||||
@ -77,10 +77,10 @@ function startApplication() {
|
||||
|
||||
const app = spawn('node', ['dist/main'], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
env: process.env
|
||||
});
|
||||
|
||||
app.on('exit', code => {
|
||||
app.on('exit', (code) => {
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
@ -96,7 +96,7 @@ async function main() {
|
||||
startApplication();
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
main().catch((error) => {
|
||||
console.error('❌ Startup failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -114,10 +114,10 @@ async function syncDatabase() {
|
||||
});
|
||||
|
||||
// Update the database
|
||||
await pgClient.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
|
||||
JSON.stringify(validDocuments),
|
||||
bookingId,
|
||||
]);
|
||||
await pgClient.query(
|
||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
||||
[JSON.stringify(validDocuments), bookingId]
|
||||
);
|
||||
|
||||
updatedCount++;
|
||||
removedDocsCount += missingDocuments.length;
|
||||
@ -148,7 +148,7 @@ syncDatabase()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -56,12 +56,16 @@ async function testWorkflow() {
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
const bookingResponse = await axios.post(`${API_BASE}/csv-bookings`, form, {
|
||||
const bookingResponse = await axios.post(
|
||||
`${API_BASE}/csv-bookings`,
|
||||
form,
|
||||
{
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✅ Booking created successfully!');
|
||||
console.log('📦 Booking ID:', bookingResponse.data.id);
|
||||
@ -76,9 +80,7 @@ async function testWorkflow() {
|
||||
console.error('❌ Error:', error.response?.data || error.message);
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
console.error(
|
||||
'\n⚠️ Authentication failed. Please update TEST_USER credentials in the script.'
|
||||
);
|
||||
console.error('\n⚠️ Authentication failed. Please update TEST_USER credentials in the script.');
|
||||
}
|
||||
|
||||
if (error.response?.status === 400) {
|
||||
|
||||
@ -213,9 +213,7 @@ async function testEmailConfig() {
|
||||
console.log('📊 Résumé des tests:');
|
||||
console.log(' ✓ Vérifiez Mailtrap inbox: https://mailtrap.io/inboxes');
|
||||
console.log(' ✓ Recherchez les emails de test ci-dessus');
|
||||
console.log(
|
||||
' ✓ Si Test 2 et 3 réussissent, le backend doit être corrigé avec la configuration IP directe\n'
|
||||
);
|
||||
console.log(' ✓ Si Test 2 et 3 réussissent, le backend doit être corrigé avec la configuration IP directe\n');
|
||||
}
|
||||
|
||||
// Run test
|
||||
@ -224,7 +222,7 @@ testEmailConfig()
|
||||
console.log('✅ Tests terminés avec succès');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('❌ Erreur lors des tests:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -5,28 +5,25 @@ const transporter = nodemailer.createTransport({
|
||||
port: 2525,
|
||||
auth: {
|
||||
user: '2597bd31d265eb',
|
||||
pass: 'cd126234193c89',
|
||||
},
|
||||
pass: 'cd126234193c89'
|
||||
}
|
||||
});
|
||||
|
||||
console.log("🔄 Tentative d'envoi d'email...");
|
||||
console.log('🔄 Tentative d\'envoi d\'email...');
|
||||
|
||||
transporter
|
||||
.sendMail({
|
||||
transporter.sendMail({
|
||||
from: 'noreply@xpeditis.com',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Email depuis Portail Transporteur',
|
||||
text: 'Email de test pour vérifier la configuration',
|
||||
})
|
||||
.then(info => {
|
||||
text: 'Email de test pour vérifier la configuration'
|
||||
}).then(info => {
|
||||
console.log('✅ Email envoyé:', info.messageId);
|
||||
console.log('📧 Response:', info.response);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
}).catch(err => {
|
||||
console.error('❌ Erreur:', err.message);
|
||||
console.error('Code:', err.code);
|
||||
console.error('Command:', err.command);
|
||||
console.error('Stack:', err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user