diff --git a/apps/backend/apps/frontend/package-lock.json b/apps/backend/apps/frontend/package-lock.json new file mode 100644 index 0000000..760762c --- /dev/null +++ b/apps/backend/apps/frontend/package-lock.json @@ -0,0 +1,840 @@ +{ + "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" + } + } +} diff --git a/apps/backend/apps/frontend/package.json b/apps/backend/apps/frontend/package.json new file mode 100644 index 0000000..b250cbb --- /dev/null +++ b/apps/backend/apps/frontend/package.json @@ -0,0 +1,15 @@ +{ + "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" + } +} diff --git a/apps/backend/create-test-booking.js b/apps/backend/create-test-booking.js index cf0bd73..2740f62 100644 --- a/apps/backend/create-test-booking.js +++ b/apps/backend/create-test-booking.js @@ -1,114 +1,113 @@ -/** - * Script pour créer un booking de test avec statut PENDING - * Usage: node create-test-booking.js - */ - -const { Client } = require('pg'); -const { v4: uuidv4 } = require('uuid'); - -async function createTestBooking() { - const client = new Client({ - host: process.env.DATABASE_HOST || 'localhost', - port: parseInt(process.env.DATABASE_PORT || '5432'), - database: process.env.DATABASE_NAME || 'xpeditis_dev', - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - }); - - try { - await client.connect(); - console.log('✅ Connecté à la base de données'); - - const bookingId = uuidv4(); - const confirmationToken = uuidv4(); - const userId = '8cf7d5b3-d94f-44aa-bb5a-080002919dd1'; // User demo@xpeditis.com - const organizationId = '199fafa9-d26f-4cf9-9206-73432baa8f63'; - - // Create dummy documents in JSONB format - const dummyDocuments = JSON.stringify([ - { - id: uuidv4(), - type: 'BILL_OF_LADING', - fileName: 'bill-of-lading.pdf', - filePath: 'https://dummy-storage.com/documents/bill-of-lading.pdf', - mimeType: 'application/pdf', - size: 102400, // 100KB - uploadedAt: new Date().toISOString(), - }, - { - id: uuidv4(), - type: 'PACKING_LIST', - fileName: 'packing-list.pdf', - filePath: 'https://dummy-storage.com/documents/packing-list.pdf', - mimeType: 'application/pdf', - size: 51200, // 50KB - uploadedAt: new Date().toISOString(), - }, - { - id: uuidv4(), - type: 'COMMERCIAL_INVOICE', - fileName: 'commercial-invoice.pdf', - filePath: 'https://dummy-storage.com/documents/commercial-invoice.pdf', - mimeType: 'application/pdf', - size: 76800, // 75KB - uploadedAt: new Date().toISOString(), - }, - ]); - - const query = ` - INSERT INTO csv_bookings ( - id, user_id, organization_id, carrier_name, carrier_email, - origin, destination, volume_cbm, weight_kg, pallet_count, - price_usd, price_eur, primary_currency, transit_days, container_type, - status, confirmation_token, requested_at, notes, documents - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19 - ) RETURNING id, confirmation_token; - `; - - const values = [ - bookingId, - userId, - organizationId, - 'Test Carrier', - 'test@carrier.com', - 'NLRTM', // Rotterdam - 'USNYC', // New York - 25.5, // volume_cbm - 3500, // weight_kg - 10, // pallet_count - 1850.50, // price_usd - 1665.45, // price_eur - 'USD', // primary_currency - 28, // transit_days - 'LCL', // container_type - 'PENDING', // status - IMPORTANT! - confirmationToken, - 'Test booking created by script', - dummyDocuments, // documents JSONB - ]; - - const result = await client.query(query, values); - - console.log('\n🎉 Booking de test créé avec succès!'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(`📦 Booking ID: ${bookingId}`); - console.log(`🔑 Token: ${confirmationToken}`); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); - console.log('🔗 URLs de test:'); - console.log(` Accept: http://localhost:3000/carrier/accept/${confirmationToken}`); - console.log(` Reject: http://localhost:3000/carrier/reject/${confirmationToken}`); - 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); - } finally { - await client.end(); - } -} - -createTestBooking(); +/** + * Script pour créer un booking de test avec statut PENDING + * Usage: node create-test-booking.js + */ + +const { Client } = require('pg'); +const { v4: uuidv4 } = require('uuid'); + +async function createTestBooking() { + const client = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: parseInt(process.env.DATABASE_PORT || '5432'), + database: process.env.DATABASE_NAME || 'xpeditis_dev', + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + }); + + try { + await client.connect(); + console.log('✅ Connecté à la base de données'); + + const bookingId = uuidv4(); + const confirmationToken = uuidv4(); + const userId = '8cf7d5b3-d94f-44aa-bb5a-080002919dd1'; // User demo@xpeditis.com + const organizationId = '199fafa9-d26f-4cf9-9206-73432baa8f63'; + + // Create dummy documents in JSONB format + const dummyDocuments = JSON.stringify([ + { + id: uuidv4(), + type: 'BILL_OF_LADING', + fileName: 'bill-of-lading.pdf', + filePath: 'https://dummy-storage.com/documents/bill-of-lading.pdf', + mimeType: 'application/pdf', + size: 102400, // 100KB + uploadedAt: new Date().toISOString(), + }, + { + id: uuidv4(), + type: 'PACKING_LIST', + fileName: 'packing-list.pdf', + filePath: 'https://dummy-storage.com/documents/packing-list.pdf', + mimeType: 'application/pdf', + size: 51200, // 50KB + uploadedAt: new Date().toISOString(), + }, + { + id: uuidv4(), + type: 'COMMERCIAL_INVOICE', + fileName: 'commercial-invoice.pdf', + filePath: 'https://dummy-storage.com/documents/commercial-invoice.pdf', + mimeType: 'application/pdf', + size: 76800, // 75KB + uploadedAt: new Date().toISOString(), + }, + ]); + + const query = ` + INSERT INTO csv_bookings ( + id, user_id, organization_id, carrier_name, carrier_email, + origin, destination, volume_cbm, weight_kg, pallet_count, + price_usd, price_eur, primary_currency, transit_days, container_type, + status, confirmation_token, requested_at, notes, documents + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19 + ) RETURNING id, confirmation_token; + `; + + const values = [ + bookingId, + userId, + organizationId, + 'Test Carrier', + 'test@carrier.com', + 'NLRTM', // Rotterdam + 'USNYC', // New York + 25.5, // volume_cbm + 3500, // weight_kg + 10, // pallet_count + 1850.5, // price_usd + 1665.45, // price_eur + 'USD', // primary_currency + 28, // transit_days + 'LCL', // container_type + 'PENDING', // status - IMPORTANT! + confirmationToken, + 'Test booking created by script', + dummyDocuments, // documents JSONB + ]; + + const result = await client.query(query, values); + + console.log('\n🎉 Booking de test créé avec succès!'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`📦 Booking ID: ${bookingId}`); + console.log(`🔑 Token: ${confirmationToken}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + console.log('🔗 URLs de test:'); + console.log(` Accept: http://localhost:3000/carrier/accept/${confirmationToken}`); + console.log(` Reject: http://localhost:3000/carrier/reject/${confirmationToken}`); + 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); + } finally { + await client.end(); + } +} + +createTestBooking(); diff --git a/apps/backend/debug-email-flow.js b/apps/backend/debug-email-flow.js index 7d3f365..d105a92 100644 --- a/apps/backend/debug-email-flow.js +++ b/apps/backend/debug-email-flow.js @@ -1,321 +1,324 @@ -/** - * Script de debug pour tester le flux complet d'envoi d'email - * - * Ce script teste: - * 1. Connexion SMTP - * 2. Envoi d'un email simple - * 3. Envoi avec le template complet - */ - -require('dotenv').config(); -const nodemailer = require('nodemailer'); - -console.log('\n🔍 DEBUG - Flux d\'envoi d\'email transporteur\n'); -console.log('='.repeat(60)); - -// 1. Afficher la configuration -console.log('\n📋 CONFIGURATION ACTUELLE:'); -console.log('----------------------------'); -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_FROM:', process.env.SMTP_FROM); -console.log('APP_URL:', process.env.APP_URL); - -// 2. Vérifier les variables requises -console.log('\n✅ VÉRIFICATION DES VARIABLES:'); -console.log('--------------------------------'); -const requiredVars = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS']; -const missing = requiredVars.filter(v => !process.env[v]); -if (missing.length > 0) { - console.error('❌ Variables manquantes:', missing.join(', ')); - process.exit(1); -} else { - console.log('✅ Toutes les variables requises sont présentes'); -} - -// 3. Créer le transporter avec la même configuration que le backend -console.log('\n🔧 CRÉATION DU TRANSPORTER:'); -console.log('----------------------------'); - -const host = process.env.SMTP_HOST; -const port = parseInt(process.env.SMTP_PORT); -const user = process.env.SMTP_USER; -const pass = process.env.SMTP_PASS; -const secure = process.env.SMTP_SECURE === 'true'; - -// Même logique que dans email.adapter.ts -const useDirectIP = host.includes('mailtrap.io'); -const actualHost = useDirectIP ? '3.209.246.195' : host; -const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; - -console.log('Configuration détectée:'); -console.log(' Host original:', host); -console.log(' Utilise IP directe:', useDirectIP); -console.log(' Host réel:', actualHost); -console.log(' Server name (TLS):', serverName); -console.log(' Port:', port); -console.log(' Secure:', secure); - -const transporter = nodemailer.createTransport({ - host: actualHost, - port, - secure, - auth: { - user, - pass, - }, - tls: { - rejectUnauthorized: false, - servername: serverName, - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - dnsTimeout: 10000, -}); - -// 4. Tester la connexion -console.log('\n🔌 TEST DE CONNEXION SMTP:'); -console.log('---------------------------'); - -async function testConnection() { - try { - console.log('Vérification de la connexion...'); - await transporter.verify(); - console.log('✅ Connexion SMTP réussie!'); - return true; - } catch (error) { - console.error('❌ Échec de la connexion SMTP:'); - console.error(' Message:', error.message); - console.error(' Code:', error.code); - console.error(' Command:', error.command); - if (error.stack) { - console.error(' Stack:', error.stack.substring(0, 200) + '...'); - } - return false; - } -} - -// 5. Envoyer un email de test simple -async function sendSimpleEmail() { - console.log('\n📧 TEST 1: Email simple'); - console.log('------------------------'); - - try { - const info = await transporter.sendMail({ - from: process.env.SMTP_FROM || 'noreply@xpeditis.com', - to: 'test@example.com', - subject: 'Test Simple - ' + new Date().toISOString(), - text: 'Ceci est un test simple', - html: '

Test Simple

Ceci est un test simple

', - }); - - console.log('✅ Email simple envoyé avec succès!'); - console.log(' Message ID:', info.messageId); - console.log(' Response:', info.response); - console.log(' Accepted:', info.accepted); - console.log(' Rejected:', info.rejected); - return true; - } catch (error) { - console.error('❌ Échec d\'envoi email simple:'); - console.error(' Message:', error.message); - console.error(' Code:', error.code); - return false; - } -} - -// 6. Envoyer un email avec le template transporteur complet -async function sendCarrierEmail() { - console.log('\n📧 TEST 2: Email transporteur avec template'); - console.log('--------------------------------------------'); - - const bookingData = { - bookingId: 'TEST-' + Date.now(), - origin: 'FRPAR', - destination: 'USNYC', - volumeCBM: 15.5, - weightKG: 1200, - palletCount: 6, - priceUSD: 2500, - priceEUR: 2250, - primaryCurrency: 'USD', - transitDays: 18, - containerType: '40FT', - documents: [ - { type: 'Bill of Lading', fileName: 'bol-test.pdf' }, - { type: 'Packing List', fileName: 'packing-test.pdf' }, - { type: 'Commercial Invoice', fileName: 'invoice-test.pdf' }, - ], - }; - - const baseUrl = process.env.APP_URL || 'http://localhost:3000'; - const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/accept`; - const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/reject`; - - // Template HTML (version simplifiée pour le test) - const htmlTemplate = ` - - - - - - Nouvelle demande de réservation - - -
-
-

🚢 Nouvelle demande de réservation

-

Xpeditis

-
-
-

Bonjour,

-

Vous avez reçu une nouvelle demande de réservation via Xpeditis.

- -

📋 Détails du transport

- - - - - - - - - - - - - - - - - -
Route${bookingData.origin} → ${bookingData.destination}
Volume${bookingData.volumeCBM} CBM
Poids${bookingData.weightKG} kg
Prix - ${bookingData.priceUSD} USD -
- -
-

📄 Documents fournis

-
    - ${bookingData.documents.map(doc => `
  • 📄 ${doc.type}: ${doc.fileName}
  • `).join('')} -
-
- -
-

Veuillez confirmer votre décision :

-
- ✓ Accepter la demande - ✗ Refuser la demande -
-
- -
-

- ⚠️ Important
- Cette demande expire automatiquement dans 7 jours si aucune action n'est prise. -

-
-
-
-

Référence de réservation : ${bookingData.bookingId}

-

© 2025 Xpeditis. Tous droits réservés.

-

Cet email a été envoyé automatiquement. Merci de ne pas y répondre directement.

-
-
- - - `; - - try { - console.log('Données du booking:'); - console.log(' Booking ID:', bookingData.bookingId); - console.log(' Route:', bookingData.origin, '→', bookingData.destination); - console.log(' Prix:', bookingData.priceUSD, 'USD'); - console.log(' Accept URL:', acceptUrl); - console.log(' Reject URL:', rejectUrl); - console.log('\nEnvoi en cours...'); - - const info = await transporter.sendMail({ - from: process.env.SMTP_FROM || 'noreply@xpeditis.com', - to: 'carrier@test.com', - subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`, - html: htmlTemplate, - }); - - console.log('\n✅ Email transporteur envoyé avec succès!'); - console.log(' Message ID:', info.messageId); - console.log(' Response:', info.response); - console.log(' Accepted:', info.accepted); - console.log(' Rejected:', info.rejected); - console.log('\n📬 Vérifiez votre inbox Mailtrap:'); - console.log(' URL: https://mailtrap.io/inboxes'); - console.log(' Sujet: Nouvelle demande de réservation - FRPAR → USNYC'); - return true; - } catch (error) { - console.error('\n❌ Échec d\'envoi email transporteur:'); - console.error(' Message:', error.message); - console.error(' Code:', error.code); - console.error(' ResponseCode:', error.responseCode); - console.error(' Response:', error.response); - if (error.stack) { - console.error(' Stack:', error.stack.substring(0, 300)); - } - return false; - } -} - -// Exécuter tous les tests -async function runAllTests() { - console.log('\n🚀 DÉMARRAGE DES TESTS'); - console.log('='.repeat(60)); - - // Test 1: Connexion - const connectionOk = await testConnection(); - if (!connectionOk) { - console.log('\n❌ ARRÊT: La connexion SMTP a échoué'); - console.log(' Vérifiez vos credentials SMTP dans .env'); - process.exit(1); - } - - // Test 2: Email simple - const simpleEmailOk = await sendSimpleEmail(); - if (!simpleEmailOk) { - console.log('\n⚠️ L\'email simple a échoué, mais on continue...'); - } - - // Test 3: Email transporteur - const carrierEmailOk = await sendCarrierEmail(); - - // Résumé - console.log('\n' + '='.repeat(60)); - console.log('📊 RÉSUMÉ DES TESTS:'); - console.log('='.repeat(60)); - console.log('Connexion SMTP:', connectionOk ? '✅ OK' : '❌ ÉCHEC'); - console.log('Email simple:', simpleEmailOk ? '✅ OK' : '❌ ÉCHEC'); - console.log('Email transporteur:', carrierEmailOk ? '✅ OK' : '❌ ÉCHEC'); - - 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(' Si vous ne recevez pas les emails dans le backend,'); - 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.'); - } - - console.log('\n' + '='.repeat(60)); -} - -// Lancer les tests -runAllTests() - .then(() => { - console.log('\n✅ Tests terminés\n'); - process.exit(0); - }) - .catch(error => { - console.error('\n❌ Erreur fatale:', error); - process.exit(1); - }); +/** + * Script de debug pour tester le flux complet d'envoi d'email + * + * Ce script teste: + * 1. Connexion SMTP + * 2. Envoi d'un email simple + * 3. Envoi avec le template complet + */ + +require('dotenv').config(); +const nodemailer = require('nodemailer'); + +console.log("\n🔍 DEBUG - Flux d'envoi d'email transporteur\n"); +console.log('='.repeat(60)); + +// 1. Afficher la configuration +console.log('\n📋 CONFIGURATION ACTUELLE:'); +console.log('----------------------------'); +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_FROM:', process.env.SMTP_FROM); +console.log('APP_URL:', process.env.APP_URL); + +// 2. Vérifier les variables requises +console.log('\n✅ VÉRIFICATION DES VARIABLES:'); +console.log('--------------------------------'); +const requiredVars = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS']; +const missing = requiredVars.filter(v => !process.env[v]); +if (missing.length > 0) { + console.error('❌ Variables manquantes:', missing.join(', ')); + process.exit(1); +} else { + console.log('✅ Toutes les variables requises sont présentes'); +} + +// 3. Créer le transporter avec la même configuration que le backend +console.log('\n🔧 CRÉATION DU TRANSPORTER:'); +console.log('----------------------------'); + +const host = process.env.SMTP_HOST; +const port = parseInt(process.env.SMTP_PORT); +const user = process.env.SMTP_USER; +const pass = process.env.SMTP_PASS; +const secure = process.env.SMTP_SECURE === 'true'; + +// Même logique que dans email.adapter.ts +const useDirectIP = host.includes('mailtrap.io'); +const actualHost = useDirectIP ? '3.209.246.195' : host; +const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; + +console.log('Configuration détectée:'); +console.log(' Host original:', host); +console.log(' Utilise IP directe:', useDirectIP); +console.log(' Host réel:', actualHost); +console.log(' Server name (TLS):', serverName); +console.log(' Port:', port); +console.log(' Secure:', secure); + +const transporter = nodemailer.createTransport({ + host: actualHost, + port, + secure, + auth: { + user, + pass, + }, + tls: { + rejectUnauthorized: false, + servername: serverName, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 30000, + dnsTimeout: 10000, +}); + +// 4. Tester la connexion +console.log('\n🔌 TEST DE CONNEXION SMTP:'); +console.log('---------------------------'); + +async function testConnection() { + try { + console.log('Vérification de la connexion...'); + await transporter.verify(); + console.log('✅ Connexion SMTP réussie!'); + return true; + } catch (error) { + console.error('❌ Échec de la connexion SMTP:'); + console.error(' Message:', error.message); + console.error(' Code:', error.code); + console.error(' Command:', error.command); + if (error.stack) { + console.error(' Stack:', error.stack.substring(0, 200) + '...'); + } + return false; + } +} + +// 5. Envoyer un email de test simple +async function sendSimpleEmail() { + console.log('\n📧 TEST 1: Email simple'); + console.log('------------------------'); + + try { + const info = await transporter.sendMail({ + from: process.env.SMTP_FROM || 'noreply@xpeditis.com', + to: 'test@example.com', + subject: 'Test Simple - ' + new Date().toISOString(), + text: 'Ceci est un test simple', + html: '

Test Simple

Ceci est un test simple

', + }); + + console.log('✅ Email simple envoyé avec succès!'); + console.log(' Message ID:', info.messageId); + console.log(' Response:', info.response); + console.log(' Accepted:', info.accepted); + console.log(' Rejected:', info.rejected); + return true; + } catch (error) { + console.error("❌ Échec d'envoi email simple:"); + console.error(' Message:', error.message); + console.error(' Code:', error.code); + return false; + } +} + +// 6. Envoyer un email avec le template transporteur complet +async function sendCarrierEmail() { + console.log('\n📧 TEST 2: Email transporteur avec template'); + console.log('--------------------------------------------'); + + const bookingData = { + bookingId: 'TEST-' + Date.now(), + origin: 'FRPAR', + destination: 'USNYC', + volumeCBM: 15.5, + weightKG: 1200, + palletCount: 6, + priceUSD: 2500, + priceEUR: 2250, + primaryCurrency: 'USD', + transitDays: 18, + containerType: '40FT', + documents: [ + { type: 'Bill of Lading', fileName: 'bol-test.pdf' }, + { type: 'Packing List', fileName: 'packing-test.pdf' }, + { type: 'Commercial Invoice', fileName: 'invoice-test.pdf' }, + ], + }; + + const baseUrl = process.env.APP_URL || 'http://localhost:3000'; + const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/accept`; + const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/reject`; + + // Template HTML (version simplifiée pour le test) + const htmlTemplate = ` + + + + + + Nouvelle demande de réservation + + +
+
+

🚢 Nouvelle demande de réservation

+

Xpeditis

+
+
+

Bonjour,

+

Vous avez reçu une nouvelle demande de réservation via Xpeditis.

+ +

📋 Détails du transport

+ + + + + + + + + + + + + + + + + +
Route${bookingData.origin} → ${bookingData.destination}
Volume${bookingData.volumeCBM} CBM
Poids${bookingData.weightKG} kg
Prix + ${bookingData.priceUSD} USD +
+ +
+

📄 Documents fournis

+
    + ${bookingData.documents.map(doc => `
  • 📄 ${doc.type}: ${doc.fileName}
  • `).join('')} +
+
+ +
+

Veuillez confirmer votre décision :

+
+ ✓ Accepter la demande + ✗ Refuser la demande +
+
+ +
+

+ ⚠️ Important
+ Cette demande expire automatiquement dans 7 jours si aucune action n'est prise. +

+
+
+
+

Référence de réservation : ${bookingData.bookingId}

+

© 2025 Xpeditis. Tous droits réservés.

+

Cet email a été envoyé automatiquement. Merci de ne pas y répondre directement.

+
+
+ + + `; + + try { + console.log('Données du booking:'); + console.log(' Booking ID:', bookingData.bookingId); + console.log(' Route:', bookingData.origin, '→', bookingData.destination); + console.log(' Prix:', bookingData.priceUSD, 'USD'); + console.log(' Accept URL:', acceptUrl); + console.log(' Reject URL:', rejectUrl); + console.log('\nEnvoi en cours...'); + + const info = await transporter.sendMail({ + from: process.env.SMTP_FROM || 'noreply@xpeditis.com', + to: 'carrier@test.com', + subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`, + html: htmlTemplate, + }); + + console.log('\n✅ Email transporteur envoyé avec succès!'); + console.log(' Message ID:', info.messageId); + console.log(' Response:', info.response); + console.log(' Accepted:', info.accepted); + console.log(' Rejected:', info.rejected); + console.log('\n📬 Vérifiez votre inbox Mailtrap:'); + console.log(' URL: https://mailtrap.io/inboxes'); + console.log(' Sujet: Nouvelle demande de réservation - FRPAR → USNYC'); + return true; + } catch (error) { + console.error("\n❌ Échec d'envoi email transporteur:"); + console.error(' Message:', error.message); + console.error(' Code:', error.code); + console.error(' ResponseCode:', error.responseCode); + console.error(' Response:', error.response); + if (error.stack) { + console.error(' Stack:', error.stack.substring(0, 300)); + } + return false; + } +} + +// Exécuter tous les tests +async function runAllTests() { + console.log('\n🚀 DÉMARRAGE DES TESTS'); + console.log('='.repeat(60)); + + // Test 1: Connexion + const connectionOk = await testConnection(); + if (!connectionOk) { + console.log('\n❌ ARRÊT: La connexion SMTP a échoué'); + console.log(' Vérifiez vos credentials SMTP dans .env'); + process.exit(1); + } + + // Test 2: Email simple + const simpleEmailOk = await sendSimpleEmail(); + if (!simpleEmailOk) { + console.log("\n⚠️ L'email simple a échoué, mais on continue..."); + } + + // Test 3: Email transporteur + const carrierEmailOk = await sendCarrierEmail(); + + // Résumé + console.log('\n' + '='.repeat(60)); + console.log('📊 RÉSUMÉ DES TESTS:'); + console.log('='.repeat(60)); + console.log('Connexion SMTP:', connectionOk ? '✅ OK' : '❌ ÉCHEC'); + console.log('Email simple:', simpleEmailOk ? '✅ OK' : '❌ ÉCHEC'); + console.log('Email transporteur:', carrierEmailOk ? '✅ OK' : '❌ ÉCHEC'); + + 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(' Si vous ne recevez pas les emails dans le backend,'); + 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.'); + } + + console.log('\n' + '='.repeat(60)); +} + +// Lancer les tests +runAllTests() + .then(() => { + console.log('\n✅ Tests terminés\n'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Erreur fatale:', error); + process.exit(1); + }); diff --git a/apps/backend/delete-test-documents.js b/apps/backend/delete-test-documents.js index 2a043fe..4f9ed78 100644 --- a/apps/backend/delete-test-documents.js +++ b/apps/backend/delete-test-documents.js @@ -1,106 +1,106 @@ -/** - * Script to delete test documents from MinIO - * - * Deletes only small test files (< 1000 bytes) created by upload-test-documents.js - * Preserves real uploaded documents (larger files) - */ - -const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; -const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -async function deleteTestDocuments() { - try { - console.log('📋 Listing all files in bucket:', BUCKET_NAME); - - // List all files - let allFiles = []; - let continuationToken = null; - - do { - const command = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - ContinuationToken: continuationToken, - }); - - const response = await s3Client.send(command); - - if (response.Contents) { - allFiles = allFiles.concat(response.Contents); - } - - continuationToken = response.NextContinuationToken; - } while (continuationToken); - - console.log(`\n📊 Found ${allFiles.length} total files\n`); - - // Filter test files (small files < 1000 bytes) - const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD); - const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD); - - console.log(`🔍 Analysis:`); - console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`); - console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`); - - if (testFiles.length === 0) { - console.log('✅ No test files to delete'); - return; - } - - console.log(`🗑️ Deleting ${testFiles.length} test files:\n`); - - let deletedCount = 0; - for (const file of testFiles) { - console.log(` Deleting: ${file.Key} (${file.Size} bytes)`); - - try { - await s3Client.send( - new DeleteObjectCommand({ - Bucket: BUCKET_NAME, - Key: file.Key, - }) - ); - deletedCount++; - } catch (error) { - console.error(` ❌ Failed to delete ${file.Key}:`, error.message); - } - } - - console.log(`\n✅ Deleted ${deletedCount} test files`); - console.log(`✅ Preserved ${realFiles.length} real documents\n`); - - console.log('📂 Remaining real documents:'); - realFiles.forEach(file => { - const filename = file.Key.split('/').pop(); - const sizeMB = (file.Size / 1024 / 1024).toFixed(2); - console.log(` - ${filename} (${sizeMB} MB)`); - }); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } -} - -deleteTestDocuments() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to delete test documents from MinIO + * + * Deletes only small test files (< 1000 bytes) created by upload-test-documents.js + * Preserves real uploaded documents (larger files) + */ + +const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; +const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function deleteTestDocuments() { + try { + console.log('📋 Listing all files in bucket:', BUCKET_NAME); + + // List all files + let allFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allFiles = allFiles.concat(response.Contents); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(`\n📊 Found ${allFiles.length} total files\n`); + + // Filter test files (small files < 1000 bytes) + const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD); + const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD); + + console.log(`🔍 Analysis:`); + console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`); + console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`); + + if (testFiles.length === 0) { + console.log('✅ No test files to delete'); + return; + } + + console.log(`🗑️ Deleting ${testFiles.length} test files:\n`); + + let deletedCount = 0; + for (const file of testFiles) { + console.log(` Deleting: ${file.Key} (${file.Size} bytes)`); + + try { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: file.Key, + }) + ); + deletedCount++; + } catch (error) { + console.error(` ❌ Failed to delete ${file.Key}:`, error.message); + } + } + + console.log(`\n✅ Deleted ${deletedCount} test files`); + console.log(`✅ Preserved ${realFiles.length} real documents\n`); + + console.log('📂 Remaining real documents:'); + realFiles.forEach(file => { + const filename = file.Key.split('/').pop(); + const sizeMB = (file.Size / 1024 / 1024).toFixed(2); + console.log(` - ${filename} (${sizeMB} MB)`); + }); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } +} + +deleteTestDocuments() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/fix-domain-imports.js b/apps/backend/fix-domain-imports.js index 67f3ea8..756b186 100644 --- a/apps/backend/fix-domain-imports.js +++ b/apps/backend/fix-domain-imports.js @@ -1,42 +1,42 @@ -#!/usr/bin/env node - -/** - * Script to fix TypeScript imports in domain/services - * Replace relative paths with path aliases - */ - -const fs = require('fs'); -const path = require('path'); - -function fixImportsInFile(filePath) { - const content = fs.readFileSync(filePath, 'utf8'); - let modified = content; - - // 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/"); - - if (modified !== content) { - fs.writeFileSync(filePath, modified, 'utf8'); - return true; - } - return false; -} - -const servicesDir = path.join(__dirname, 'src/domain/services'); -console.log('🔧 Fixing domain/services imports...\n'); - -const files = fs.readdirSync(servicesDir); -let count = 0; - -for (const file of files) { - if (file.endsWith('.ts')) { - const filePath = path.join(servicesDir, file); - if (fixImportsInFile(filePath)) { - console.log(`✅ Fixed: ${filePath}`); - count++; - } - } -} - -console.log(`\n✅ Fixed ${count} files in domain/services`); +#!/usr/bin/env node + +/** + * Script to fix TypeScript imports in domain/services + * Replace relative paths with path aliases + */ + +const fs = require('fs'); +const path = require('path'); + +function fixImportsInFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + let modified = content; + + // 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/'); + + if (modified !== content) { + fs.writeFileSync(filePath, modified, 'utf8'); + return true; + } + return false; +} + +const servicesDir = path.join(__dirname, 'src/domain/services'); +console.log('🔧 Fixing domain/services imports...\n'); + +const files = fs.readdirSync(servicesDir); +let count = 0; + +for (const file of files) { + if (file.endsWith('.ts')) { + const filePath = path.join(servicesDir, file); + if (fixImportsInFile(filePath)) { + console.log(`✅ Fixed: ${filePath}`); + count++; + } + } +} + +console.log(`\n✅ Fixed ${count} files in domain/services`); diff --git a/apps/backend/fix-dummy-urls.js b/apps/backend/fix-dummy-urls.js index 721d170..3b93fe6 100644 --- a/apps/backend/fix-dummy-urls.js +++ b/apps/backend/fix-dummy-urls.js @@ -1,90 +1,90 @@ -/** - * Script to fix dummy storage URLs in the database - * - * This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs - */ - -const { Client } = require('pg'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -async function fixDummyUrls() { - const client = new Client({ - host: process.env.DATABASE_HOST || 'localhost', - port: process.env.DATABASE_PORT || 5432, - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - }); - - try { - await client.connect(); - console.log('✅ Connected to database'); - - // Get all CSV bookings with documents - const result = await client.query( - `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'` - ); - - console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`); - - let updatedCount = 0; - - for (const row of result.rows) { - const bookingId = row.id; - const documents = row.documents; - - // Update each document URL - 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(); - const documentId = doc.id; - - // Build proper MinIO URL - const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`; - - console.log(` Old: ${doc.filePath}`); - console.log(` New: ${newUrl}`); - - return { - ...doc, - filePath: newUrl, - }; - } - return doc; - }); - - // Update the database - await client.query( - `UPDATE csv_bookings SET documents = $1 WHERE id = $2`, - [JSON.stringify(updatedDocuments), bookingId] - ); - - updatedCount++; - console.log(`✅ Updated booking ${bookingId}\n`); - } - - console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); - console.log(`\n⚠️ Note: The actual files need to be uploaded to MinIO at the correct paths.`); - console.log(` You can upload test files or re-create the bookings with real file uploads.`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } finally { - await client.end(); - console.log('\n👋 Disconnected from database'); - } -} - -fixDummyUrls() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to fix dummy storage URLs in the database + * + * This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs + */ + +const { Client } = require('pg'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +async function fixDummyUrls() { + const client = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await client.connect(); + console.log('✅ Connected to database'); + + // Get all CSV bookings with documents + const result = await client.query( + `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'` + ); + + console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`); + + let updatedCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + // Update each document URL + 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(); + const documentId = doc.id; + + // Build proper MinIO URL + const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`; + + console.log(` Old: ${doc.filePath}`); + console.log(` New: ${newUrl}`); + + return { + ...doc, + filePath: newUrl, + }; + } + return doc; + }); + + // Update the database + await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [ + JSON.stringify(updatedDocuments), + bookingId, + ]); + + updatedCount++; + console.log(`✅ Updated booking ${bookingId}\n`); + } + + console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); + console.log(`\n⚠️ Note: The actual files need to be uploaded to MinIO at the correct paths.`); + console.log(` You can upload test files or re-create the bookings with real file uploads.`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await client.end(); + console.log('\n👋 Disconnected from database'); + } +} + +fixDummyUrls() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/fix-imports.js b/apps/backend/fix-imports.js index 719e044..413bbae 100644 --- a/apps/backend/fix-imports.js +++ b/apps/backend/fix-imports.js @@ -1,65 +1,68 @@ -#!/usr/bin/env node - -/** - * Script to fix TypeScript imports from relative paths to path aliases - * - * Replaces: - * - from '../../domain/...' → from '@domain/...' - * - from '../../../domain/...' → from '@domain/...' - * - from '../domain/...' → from '@domain/...' - * - from '../../../../domain/...' → from '@domain/...' - */ - -const fs = require('fs'); -const path = require('path'); - -function fixImportsInFile(filePath) { - const content = fs.readFileSync(filePath, 'utf8'); - let modified = content; - - // Replace all variations of relative domain imports with @domain alias - modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/"); - modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/"); - modified = modified.replace(/from ['"]\.\.\/\.\.\/domain\//g, "from '@domain/"); - 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/"); - - if (modified !== content) { - fs.writeFileSync(filePath, modified, 'utf8'); - return true; - } - return false; -} - -function walkDir(dir) { - const files = fs.readdirSync(dir); - let count = 0; - - for (const file of files) { - const filePath = path.join(dir, file); - const stat = fs.statSync(filePath); - - if (stat.isDirectory()) { - count += walkDir(filePath); - } else if (file.endsWith('.ts')) { - if (fixImportsInFile(filePath)) { - console.log(`✅ Fixed: ${filePath}`); - count++; - } - } - } - - return count; -} - -const srcDir = path.join(__dirname, 'src'); -console.log('🔧 Fixing TypeScript imports...\n'); - -const count = walkDir(srcDir); - -console.log(`\n✅ Fixed ${count} files`); +#!/usr/bin/env node + +/** + * Script to fix TypeScript imports from relative paths to path aliases + * + * Replaces: + * - from '../../domain/...' → from '@domain/...' + * - from '../../../domain/...' → from '@domain/...' + * - from '../domain/...' → from '@domain/...' + * - from '../../../../domain/...' → from '@domain/...' + */ + +const fs = require('fs'); +const path = require('path'); + +function fixImportsInFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + let modified = content; + + // Replace all variations of relative domain imports with @domain alias + modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/"); + modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/"); + modified = modified.replace(/from ['"]\.\.\/\.\.\/domain\//g, "from '@domain/"); + 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/'); + + if (modified !== content) { + fs.writeFileSync(filePath, modified, 'utf8'); + return true; + } + return false; +} + +function walkDir(dir) { + const files = fs.readdirSync(dir); + let count = 0; + + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + count += walkDir(filePath); + } else if (file.endsWith('.ts')) { + if (fixImportsInFile(filePath)) { + console.log(`✅ Fixed: ${filePath}`); + count++; + } + } + } + + return count; +} + +const srcDir = path.join(__dirname, 'src'); +console.log('🔧 Fixing TypeScript imports...\n'); + +const count = walkDir(srcDir); + +console.log(`\n✅ Fixed ${count} files`); diff --git a/apps/backend/fix-minio-hostname.js b/apps/backend/fix-minio-hostname.js index 1455958..9bf4501 100644 --- a/apps/backend/fix-minio-hostname.js +++ b/apps/backend/fix-minio-hostname.js @@ -1,81 +1,81 @@ -/** - * Script to fix minio hostname in document URLs - * - * Changes http://minio:9000 to http://localhost:9000 - */ - -const { Client } = require('pg'); -require('dotenv').config(); - -async function fixMinioHostname() { - const client = new Client({ - host: process.env.DATABASE_HOST || 'localhost', - port: process.env.DATABASE_PORT || 5432, - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - }); - - try { - await client.connect(); - console.log('✅ Connected to database'); - - // Find bookings with minio:9000 in URLs - const result = await client.query( - `SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'` - ); - - console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`); - - let updatedCount = 0; - - for (const row of result.rows) { - const bookingId = row.id; - const documents = row.documents; - - // Update each document URL - 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'); - - console.log(` Booking: ${bookingId}`); - console.log(` Old: ${doc.filePath}`); - console.log(` New: ${newUrl}\n`); - - return { - ...doc, - filePath: newUrl, - }; - } - return doc; - }); - - // Update the database - await client.query( - `UPDATE csv_bookings SET documents = $1 WHERE id = $2`, - [JSON.stringify(updatedDocuments), bookingId] - ); - - updatedCount++; - console.log(`✅ Updated booking ${bookingId}\n`); - } - - console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } finally { - await client.end(); - console.log('\n👋 Disconnected from database'); - } -} - -fixMinioHostname() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to fix minio hostname in document URLs + * + * Changes http://minio:9000 to http://localhost:9000 + */ + +const { Client } = require('pg'); +require('dotenv').config(); + +async function fixMinioHostname() { + const client = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await client.connect(); + console.log('✅ Connected to database'); + + // Find bookings with minio:9000 in URLs + const result = await client.query( + `SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'` + ); + + console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`); + + let updatedCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + // Update each document URL + 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'); + + console.log(` Booking: ${bookingId}`); + console.log(` Old: ${doc.filePath}`); + console.log(` New: ${newUrl}\n`); + + return { + ...doc, + filePath: newUrl, + }; + } + return doc; + }); + + // Update the database + await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [ + JSON.stringify(updatedDocuments), + bookingId, + ]); + + updatedCount++; + console.log(`✅ Updated booking ${bookingId}\n`); + } + + console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await client.end(); + console.log('\n👋 Disconnected from database'); + } +} + +fixMinioHostname() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/generate-hash.js b/apps/backend/generate-hash.js index cea5dde..ff2cc01 100644 --- a/apps/backend/generate-hash.js +++ b/apps/backend/generate-hash.js @@ -1,14 +1,14 @@ -const argon2 = require('argon2'); - -async function generateHash() { - const hash = await argon2.hash('Password123!', { - type: argon2.argon2id, - memoryCost: 65536, // 64 MB - timeCost: 3, - parallelism: 4, - }); - console.log('Argon2id hash for "Password123!":'); - console.log(hash); -} - -generateHash().catch(console.error); +const argon2 = require('argon2'); + +async function generateHash() { + const hash = await argon2.hash('Password123!', { + type: argon2.argon2id, + memoryCost: 65536, // 64 MB + timeCost: 3, + parallelism: 4, + }); + console.log('Argon2id hash for "Password123!":'); + console.log(hash); +} + +generateHash().catch(console.error); diff --git a/apps/backend/list-minio-files.js b/apps/backend/list-minio-files.js index 606ad07..8a8e8f0 100644 --- a/apps/backend/list-minio-files.js +++ b/apps/backend/list-minio-files.js @@ -1,92 +1,92 @@ -/** - * Script to list all files in MinIO xpeditis-documents bucket - */ - -const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -async function listFiles() { - try { - console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`); - - let allFiles = []; - let continuationToken = null; - - do { - const command = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - ContinuationToken: continuationToken, - }); - - const response = await s3Client.send(command); - - if (response.Contents) { - allFiles = allFiles.concat(response.Contents); - } - - continuationToken = response.NextContinuationToken; - } while (continuationToken); - - console.log(`Found ${allFiles.length} files total:\n`); - - // Group by booking ID - const byBooking = {}; - allFiles.forEach(file => { - const parts = file.Key.split('/'); - if (parts.length >= 3 && parts[0] === 'csv-bookings') { - const bookingId = parts[1]; - if (!byBooking[bookingId]) { - byBooking[bookingId] = []; - } - byBooking[bookingId].push({ - key: file.Key, - size: file.Size, - lastModified: file.LastModified, - }); - } else { - console.log(` Other: ${file.Key} (${file.Size} bytes)`); - } - }); - - console.log(`\nFiles grouped by booking:\n`); - Object.entries(byBooking).forEach(([bookingId, files]) => { - console.log(`📦 Booking: ${bookingId.substring(0, 8)}...`); - files.forEach(file => { - const filename = file.key.split('/').pop(); - console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`); - }); - console.log(''); - }); - - console.log(`\n📊 Summary:`); - console.log(` Total files: ${allFiles.length}`); - console.log(` Bookings with files: ${Object.keys(byBooking).length}`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } -} - -listFiles() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to list all files in MinIO xpeditis-documents bucket + */ + +const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function listFiles() { + try { + console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`); + + let allFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allFiles = allFiles.concat(response.Contents); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(`Found ${allFiles.length} files total:\n`); + + // Group by booking ID + const byBooking = {}; + allFiles.forEach(file => { + const parts = file.Key.split('/'); + if (parts.length >= 3 && parts[0] === 'csv-bookings') { + const bookingId = parts[1]; + if (!byBooking[bookingId]) { + byBooking[bookingId] = []; + } + byBooking[bookingId].push({ + key: file.Key, + size: file.Size, + lastModified: file.LastModified, + }); + } else { + console.log(` Other: ${file.Key} (${file.Size} bytes)`); + } + }); + + console.log(`\nFiles grouped by booking:\n`); + Object.entries(byBooking).forEach(([bookingId, files]) => { + console.log(`📦 Booking: ${bookingId.substring(0, 8)}...`); + files.forEach(file => { + const filename = file.key.split('/').pop(); + console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`); + }); + console.log(''); + }); + + console.log(`\n📊 Summary:`); + console.log(` Total files: ${allFiles.length}`); + console.log(` Bookings with files: ${Object.keys(byBooking).length}`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } +} + +listFiles() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/login-and-test.js b/apps/backend/login-and-test.js index b94a702..c90fea7 100644 --- a/apps/backend/login-and-test.js +++ b/apps/backend/login-and-test.js @@ -9,18 +9,21 @@ 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'); @@ -39,8 +42,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); @@ -50,7 +53,6 @@ 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) { diff --git a/apps/backend/restore-document-references.js b/apps/backend/restore-document-references.js index 3145ac4..077a90c 100644 --- a/apps/backend/restore-document-references.js +++ b/apps/backend/restore-document-references.js @@ -1,176 +1,182 @@ -/** - * Script to restore document references in database from MinIO files - * - * Scans MinIO for existing files and creates/updates database references - */ - -const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); -const { Client } = require('pg'); -const { v4: uuidv4 } = require('uuid'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -async function restoreDocumentReferences() { - const pgClient = new Client({ - host: process.env.DATABASE_HOST || 'localhost', - port: process.env.DATABASE_PORT || 5432, - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - }); - - try { - await pgClient.connect(); - console.log('✅ Connected to database\n'); - - // Get all MinIO files - console.log('📋 Listing files in MinIO...'); - let allFiles = []; - let continuationToken = null; - - do { - const command = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - ContinuationToken: continuationToken, - }); - - const response = await s3Client.send(command); - - if (response.Contents) { - allFiles = allFiles.concat(response.Contents); - } - - continuationToken = response.NextContinuationToken; - } while (continuationToken); - - console.log(` Found ${allFiles.length} files in MinIO\n`); - - // Group files by booking ID - const filesByBooking = {}; - allFiles.forEach(file => { - const parts = file.Key.split('/'); - if (parts.length >= 3 && parts[0] === 'csv-bookings') { - const bookingId = parts[1]; - const documentId = parts[2].split('-')[0]; // Extract UUID from filename - const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash) - - if (!filesByBooking[bookingId]) { - filesByBooking[bookingId] = []; - } - - filesByBooking[bookingId].push({ - key: file.Key, - documentId: documentId, - fileName: fileName, - size: file.Size, - lastModified: file.LastModified, - }); - } - }); - - console.log(`📦 Found files for ${Object.keys(filesByBooking).length} bookings\n`); - - let updatedCount = 0; - let createdDocsCount = 0; - - for (const [bookingId, files] of Object.entries(filesByBooking)) { - // Check if booking exists - const bookingResult = await pgClient.query( - 'SELECT id, documents FROM csv_bookings WHERE id = $1', - [bookingId] - ); - - if (bookingResult.rows.length === 0) { - console.log(`⚠️ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`); - continue; - } - - const booking = bookingResult.rows[0]; - const existingDocs = booking.documents || []; - - console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`); - console.log(` Existing documents in DB: ${existingDocs.length}`); - console.log(` Files in MinIO: ${files.length}`); - - // Create document references for files - const newDocuments = files.map(file => { - // Determine MIME type from file extension - const ext = file.fileName.split('.').pop().toLowerCase(); - const mimeTypeMap = { - pdf: 'application/pdf', - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - txt: 'text/plain', - }; - const mimeType = mimeTypeMap[ext] || 'application/octet-stream'; - - // Determine document type - let docType = 'OTHER'; - 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')) { - docType = 'COMMERCIAL_INVOICE'; - } - - const doc = { - id: file.documentId, - type: docType, - fileName: file.fileName, - filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`, - mimeType: mimeType, - size: file.size, - uploadedAt: file.lastModified.toISOString(), - }; - - console.log(` ✅ ${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`); - return doc; - }); - - // Update the booking with new document references - await pgClient.query( - 'UPDATE csv_bookings SET documents = $1 WHERE id = $2', - [JSON.stringify(newDocuments), bookingId] - ); - - updatedCount++; - createdDocsCount += newDocuments.length; - } - - console.log(`\n📊 Summary:`); - console.log(` Bookings updated: ${updatedCount}`); - console.log(` Document references created: ${createdDocsCount}`); - console.log(`\n✅ Document references restored`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } finally { - await pgClient.end(); - console.log('\n👋 Disconnected from database'); - } -} - -restoreDocumentReferences() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to restore document references in database from MinIO files + * + * Scans MinIO for existing files and creates/updates database references + */ + +const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); +const { Client } = require('pg'); +const { v4: uuidv4 } = require('uuid'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function restoreDocumentReferences() { + const pgClient = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await pgClient.connect(); + console.log('✅ Connected to database\n'); + + // Get all MinIO files + console.log('📋 Listing files in MinIO...'); + let allFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allFiles = allFiles.concat(response.Contents); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(` Found ${allFiles.length} files in MinIO\n`); + + // Group files by booking ID + const filesByBooking = {}; + allFiles.forEach(file => { + const parts = file.Key.split('/'); + if (parts.length >= 3 && parts[0] === 'csv-bookings') { + const bookingId = parts[1]; + const documentId = parts[2].split('-')[0]; // Extract UUID from filename + const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash) + + if (!filesByBooking[bookingId]) { + filesByBooking[bookingId] = []; + } + + filesByBooking[bookingId].push({ + key: file.Key, + documentId: documentId, + fileName: fileName, + size: file.Size, + lastModified: file.LastModified, + }); + } + }); + + console.log(`📦 Found files for ${Object.keys(filesByBooking).length} bookings\n`); + + let updatedCount = 0; + let createdDocsCount = 0; + + for (const [bookingId, files] of Object.entries(filesByBooking)) { + // Check if booking exists + const bookingResult = await pgClient.query( + 'SELECT id, documents FROM csv_bookings WHERE id = $1', + [bookingId] + ); + + if (bookingResult.rows.length === 0) { + console.log(`⚠️ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`); + continue; + } + + const booking = bookingResult.rows[0]; + const existingDocs = booking.documents || []; + + console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`); + console.log(` Existing documents in DB: ${existingDocs.length}`); + console.log(` Files in MinIO: ${files.length}`); + + // Create document references for files + const newDocuments = files.map(file => { + // Determine MIME type from file extension + const ext = file.fileName.split('.').pop().toLowerCase(); + const mimeTypeMap = { + pdf: 'application/pdf', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + txt: 'text/plain', + }; + const mimeType = mimeTypeMap[ext] || 'application/octet-stream'; + + // Determine document type + let docType = 'OTHER'; + 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') + ) { + docType = 'COMMERCIAL_INVOICE'; + } + + const doc = { + id: file.documentId, + type: docType, + fileName: file.fileName, + filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`, + mimeType: mimeType, + size: file.size, + uploadedAt: file.lastModified.toISOString(), + }; + + console.log(` ✅ ${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`); + return doc; + }); + + // Update the booking with new document references + await pgClient.query('UPDATE csv_bookings SET documents = $1 WHERE id = $2', [ + JSON.stringify(newDocuments), + bookingId, + ]); + + updatedCount++; + createdDocsCount += newDocuments.length; + } + + console.log(`\n📊 Summary:`); + console.log(` Bookings updated: ${updatedCount}`); + console.log(` Document references created: ${createdDocsCount}`); + console.log(`\n✅ Document references restored`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await pgClient.end(); + console.log('\n👋 Disconnected from database'); + } +} + +restoreDocumentReferences() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/run-migrations.js b/apps/backend/run-migrations.js index db1590a..3ffae11 100644 --- a/apps/backend/run-migrations.js +++ b/apps/backend/run-migrations.js @@ -1,44 +1,44 @@ -const { DataSource } = require('typeorm'); -const path = require('path'); - -const AppDataSource = new DataSource({ - type: 'postgres', - host: process.env.DATABASE_HOST, - port: parseInt(process.env.DATABASE_PORT, 10), - username: process.env.DATABASE_USER, - password: process.env.DATABASE_PASSWORD, - database: process.env.DATABASE_NAME, - entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], - migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], - synchronize: false, - logging: true, -}); - -console.log('🚀 Starting Xpeditis Backend Migration Script...'); -console.log('📦 Initializing DataSource...'); - -AppDataSource.initialize() - .then(async () => { - console.log('✅ DataSource initialized successfully'); - console.log('🔄 Running pending migrations...'); - - const migrations = await AppDataSource.runMigrations(); - - if (migrations.length === 0) { - console.log('✅ No pending migrations'); - } else { - console.log(`✅ Successfully ran ${migrations.length} migration(s):`); - migrations.forEach((migration) => { - console.log(` - ${migration.name}`); - }); - } - - await AppDataSource.destroy(); - console.log('✅ Database migrations completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('❌ Error during migration:'); - console.error(error); - process.exit(1); - }); +const { DataSource } = require('typeorm'); +const path = require('path'); + +const AppDataSource = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT, 10), + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], + migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], + synchronize: false, + logging: true, +}); + +console.log('🚀 Starting Xpeditis Backend Migration Script...'); +console.log('📦 Initializing DataSource...'); + +AppDataSource.initialize() + .then(async () => { + console.log('✅ DataSource initialized successfully'); + console.log('🔄 Running pending migrations...'); + + const migrations = await AppDataSource.runMigrations(); + + if (migrations.length === 0) { + console.log('✅ No pending migrations'); + } else { + console.log(`✅ Successfully ran ${migrations.length} migration(s):`); + migrations.forEach(migration => { + console.log(` - ${migration.name}`); + }); + } + + await AppDataSource.destroy(); + console.log('✅ Database migrations completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('❌ Error during migration:'); + console.error(error); + process.exit(1); + }); diff --git a/apps/backend/scripts/generate-ports-seed.ts b/apps/backend/scripts/generate-ports-seed.ts index d3f770c..62f20c3 100644 --- a/apps/backend/scripts/generate-ports-seed.ts +++ b/apps/backend/scripts/generate-ports-seed.ts @@ -210,10 +210,7 @@ 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; } @@ -244,13 +241,14 @@ 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 name = port.name.replace(/'/g, "''"); - const city = port.city.replace(/'/g, "''"); - const countryName = port.countryName.replace(/'/g, "''"); - const timezone = port.timezone ? `'${port.timezone}'` : 'NULL'; + const values = batch + .map(port => { + const name = port.name.replace(/'/g, "''"); + const city = port.city.replace(/'/g, "''"); + const countryName = port.countryName.replace(/'/g, "''"); + const timezone = port.timezone ? `'${port.timezone}'` : 'NULL'; - return `( + return `( '${port.code}', '${name}', '${city}', @@ -261,7 +259,8 @@ 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) @@ -321,7 +320,9 @@ 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); } @@ -342,7 +343,10 @@ 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); diff --git a/apps/backend/scripts/list-stripe-prices.js b/apps/backend/scripts/list-stripe-prices.js index 2756851..75c3ea7 100644 --- a/apps/backend/scripts/list-stripe-prices.js +++ b/apps/backend/scripts/list-stripe-prices.js @@ -5,7 +5,10 @@ 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'); @@ -46,7 +49,6 @@ 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); } diff --git a/apps/backend/set-bucket-policy.js b/apps/backend/set-bucket-policy.js index e6e9735..854a815 100644 --- a/apps/backend/set-bucket-policy.js +++ b/apps/backend/set-bucket-policy.js @@ -1,79 +1,79 @@ -/** - * Script to set MinIO bucket policy for public read access - * - * This allows documents to be downloaded directly via URL without authentication - */ - -const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -async function setBucketPolicy() { - try { - // Policy to allow public read access to all objects in the bucket - const policy = { - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Principal: '*', - Action: ['s3:GetObject'], - Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`], - }, - ], - }; - - console.log('📋 Setting bucket policy for:', BUCKET_NAME); - console.log('Policy:', JSON.stringify(policy, null, 2)); - - // Set the bucket policy - await s3Client.send( - new PutBucketPolicyCommand({ - Bucket: BUCKET_NAME, - Policy: JSON.stringify(policy), - }) - ); - - console.log('\n✅ Bucket policy set successfully!'); - console.log(` All objects in ${BUCKET_NAME} are now publicly readable`); - - // Verify the policy was set - console.log('\n🔍 Verifying bucket policy...'); - const getPolicy = await s3Client.send( - new GetBucketPolicyCommand({ - Bucket: BUCKET_NAME, - }) - ); - - console.log('✅ Current policy:', getPolicy.Policy); - - console.log('\n📝 Note: This allows public read access to all documents.'); - console.log(' For production, consider using signed URLs instead.'); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } -} - -setBucketPolicy() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to set MinIO bucket policy for public read access + * + * This allows documents to be downloaded directly via URL without authentication + */ + +const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function setBucketPolicy() { + try { + // Policy to allow public read access to all objects in the bucket + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`], + }, + ], + }; + + console.log('📋 Setting bucket policy for:', BUCKET_NAME); + console.log('Policy:', JSON.stringify(policy, null, 2)); + + // Set the bucket policy + await s3Client.send( + new PutBucketPolicyCommand({ + Bucket: BUCKET_NAME, + Policy: JSON.stringify(policy), + }) + ); + + console.log('\n✅ Bucket policy set successfully!'); + console.log(` All objects in ${BUCKET_NAME} are now publicly readable`); + + // Verify the policy was set + console.log('\n🔍 Verifying bucket policy...'); + const getPolicy = await s3Client.send( + new GetBucketPolicyCommand({ + Bucket: BUCKET_NAME, + }) + ); + + console.log('✅ Current policy:', getPolicy.Policy); + + console.log('\n📝 Note: This allows public read access to all documents.'); + console.log(' For production, consider using signed URLs instead.'); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } +} + +setBucketPolicy() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/setup-minio-bucket.js b/apps/backend/setup-minio-bucket.js index 4b94faf..6ce7c24 100644 --- a/apps/backend/setup-minio-bucket.js +++ b/apps/backend/setup-minio-bucket.js @@ -1,91 +1,91 @@ -#!/usr/bin/env node -/** - * Setup MinIO Bucket - * - * Creates the required bucket for document storage if it doesn't exist - */ - -const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3'); -require('dotenv').config(); - -const BUCKET_NAME = 'xpeditis-documents'; - -// Configure S3 client for MinIO -const s3Client = new S3Client({ - region: process.env.AWS_REGION || 'us-east-1', - endpoint: process.env.AWS_S3_ENDPOINT || 'http://localhost:9000', - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, // Required for MinIO -}); - -async function setupBucket() { - console.log('\n🪣 MinIO Bucket Setup'); - console.log('=========================================='); - console.log(`Bucket name: ${BUCKET_NAME}`); - console.log(`Endpoint: ${process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'}`); - console.log(''); - - try { - // Check if bucket exists - console.log('📋 Step 1: Checking if bucket exists...'); - try { - await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })); - console.log(`✅ Bucket '${BUCKET_NAME}' already exists`); - console.log(''); - console.log('✅ Setup complete! The bucket is ready to use.'); - process.exit(0); - } catch (error) { - if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { - console.log(`ℹ️ Bucket '${BUCKET_NAME}' does not exist`); - } else { - throw error; - } - } - - // Create bucket - console.log(''); - console.log('📋 Step 2: Creating bucket...'); - await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); - console.log(`✅ Bucket '${BUCKET_NAME}' created successfully!`); - - // Verify creation - console.log(''); - console.log('📋 Step 3: Verifying bucket...'); - await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })); - console.log(`✅ Bucket '${BUCKET_NAME}' verified!`); - - console.log(''); - console.log('=========================================='); - console.log('✅ Setup complete! The bucket is ready to use.'); - console.log(''); - console.log('You can now:'); - console.log(' 1. Create CSV bookings via the frontend'); - console.log(' 2. Upload documents to this bucket'); - console.log(' 3. View files at: http://localhost:9001 (MinIO Console)'); - console.log(''); - - process.exit(0); - } catch (error) { - console.error(''); - console.error('❌ ERROR: Failed to setup bucket'); - console.error(''); - console.error('Error details:'); - console.error(` Name: ${error.name}`); - console.error(` Message: ${error.message}`); - if (error.$metadata) { - console.error(` HTTP Status: ${error.$metadata.httpStatusCode}`); - } - console.error(''); - console.error('Common solutions:'); - console.error(' 1. Check if MinIO is running: docker ps | grep minio'); - console.error(' 2. Verify credentials in .env file'); - console.error(' 3. Ensure AWS_S3_ENDPOINT is set correctly'); - console.error(''); - process.exit(1); - } -} - -setupBucket(); +#!/usr/bin/env node +/** + * Setup MinIO Bucket + * + * Creates the required bucket for document storage if it doesn't exist + */ + +const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const BUCKET_NAME = 'xpeditis-documents'; + +// Configure S3 client for MinIO +const s3Client = new S3Client({ + region: process.env.AWS_REGION || 'us-east-1', + endpoint: process.env.AWS_S3_ENDPOINT || 'http://localhost:9000', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, // Required for MinIO +}); + +async function setupBucket() { + console.log('\n🪣 MinIO Bucket Setup'); + console.log('=========================================='); + console.log(`Bucket name: ${BUCKET_NAME}`); + console.log(`Endpoint: ${process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'}`); + console.log(''); + + try { + // Check if bucket exists + console.log('📋 Step 1: Checking if bucket exists...'); + try { + await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })); + console.log(`✅ Bucket '${BUCKET_NAME}' already exists`); + console.log(''); + console.log('✅ Setup complete! The bucket is ready to use.'); + process.exit(0); + } catch (error) { + if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { + console.log(`ℹ️ Bucket '${BUCKET_NAME}' does not exist`); + } else { + throw error; + } + } + + // Create bucket + console.log(''); + console.log('📋 Step 2: Creating bucket...'); + await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); + console.log(`✅ Bucket '${BUCKET_NAME}' created successfully!`); + + // Verify creation + console.log(''); + console.log('📋 Step 3: Verifying bucket...'); + await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })); + console.log(`✅ Bucket '${BUCKET_NAME}' verified!`); + + console.log(''); + console.log('=========================================='); + console.log('✅ Setup complete! The bucket is ready to use.'); + console.log(''); + console.log('You can now:'); + console.log(' 1. Create CSV bookings via the frontend'); + console.log(' 2. Upload documents to this bucket'); + console.log(' 3. View files at: http://localhost:9001 (MinIO Console)'); + console.log(''); + + process.exit(0); + } catch (error) { + console.error(''); + console.error('❌ ERROR: Failed to setup bucket'); + console.error(''); + console.error('Error details:'); + console.error(` Name: ${error.name}`); + console.error(` Message: ${error.message}`); + if (error.$metadata) { + console.error(` HTTP Status: ${error.$metadata.httpStatusCode}`); + } + console.error(''); + console.error('Common solutions:'); + console.error(' 1. Check if MinIO is running: docker ps | grep minio'); + console.error(' 2. Verify credentials in .env file'); + console.error(' 3. Ensure AWS_S3_ENDPOINT is set correctly'); + console.error(''); + process.exit(1); + } +} + +setupBucket(); diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index eece0f4..63b7647 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -28,6 +28,7 @@ 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'; @@ -179,6 +180,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; WebhooksModule, GDPRModule, AdminModule, + BlogModule, SubscriptionsModule, ApiKeysModule, LogsModule, diff --git a/apps/backend/src/application/admin/admin.module.ts b/apps/backend/src/application/admin/admin.module.ts index dd92262..ba4f5e1 100644 --- a/apps/backend/src/application/admin/admin.module.ts +++ b/apps/backend/src/application/admin/admin.module.ts @@ -29,18 +29,20 @@ import { CsvBookingsModule } from '../csv-bookings.module'; // Email import { EmailModule } from '@infrastructure/email/email.module'; -/** - * Admin Module - * - * Provides admin-only endpoints for managing all data in the system. - * All endpoints require ADMIN role. - */ +// Blog +import { BlogModule } from '../blog/blog.module'; + +// Storage +import { StorageModule } from '@infrastructure/storage/storage.module'; + @Module({ imports: [ TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]), ConfigModule, CsvBookingsModule, EmailModule, + BlogModule, + StorageModule, ], controllers: [AdminController], providers: [ diff --git a/apps/backend/src/application/blog/blog.module.ts b/apps/backend/src/application/blog/blog.module.ts new file mode 100644 index 0000000..7e31d7b --- /dev/null +++ b/apps/backend/src/application/blog/blog.module.ts @@ -0,0 +1,22 @@ +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 {} diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index a4ccb99..b9ecda5 100644 --- a/apps/backend/src/application/controllers/admin.controller.ts +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -6,6 +6,7 @@ import { Delete, Param, Body, + Query, HttpCode, HttpStatus, Logger, @@ -15,14 +16,22 @@ 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'; @@ -56,6 +65,25 @@ 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 * @@ -80,7 +108,9 @@ export class AdminController { private readonly csvBookingService: CsvBookingService, @Inject(SIRET_VERIFICATION_PORT) private readonly siretVerificationPort: SiretVerificationPort, - @Inject(EMAIL_PORT) private readonly emailPort: EmailPort + @Inject(EMAIL_PORT) private readonly emailPort: EmailPort, + private readonly blogService: BlogService, + @Inject(STORAGE_PORT) private readonly storage: StoragePort ) {} // ==================== USERS ENDPOINTS ==================== @@ -912,4 +942,134 @@ 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 { + 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, + }; + } } diff --git a/apps/backend/src/application/controllers/blog.controller.ts b/apps/backend/src/application/controllers/blog.controller.ts new file mode 100644 index 0000000..39845a9 --- /dev/null +++ b/apps/backend/src/application/controllers/blog.controller.ts @@ -0,0 +1,131 @@ +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 { + 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 { + 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 = { + 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 { + 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, + }; + } +} diff --git a/apps/backend/src/application/dto/blog-post.dto.ts b/apps/backend/src/application/dto/blog-post.dto.ts new file mode 100644 index 0000000..1dc280a --- /dev/null +++ b/apps/backend/src/application/dto/blog-post.dto.ts @@ -0,0 +1,150 @@ +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; +} diff --git a/apps/backend/src/application/services/blog.service.ts b/apps/backend/src/application/services/blog.service.ts new file mode 100644 index 0000000..7cd3a52 --- /dev/null +++ b/apps/backend/src/application/services/blog.service.ts @@ -0,0 +1,139 @@ +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 { + 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 { + 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 { + 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 { + await this.findOrFail(id); + await this.blogPostRepository.delete(id); + } + + async getPostById(id: string): Promise { + return this.findOrFail(id); + } + + async getPublishedPostBySlug(slug: string): Promise { + 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 { + const post = await this.blogPostRepository.findById(id); + if (!post) { + throw new NotFoundException(`Blog post with id "${id}" not found`); + } + return post; + } +} diff --git a/apps/backend/src/domain/entities/blog-post.entity.ts b/apps/backend/src/domain/entities/blog-post.entity.ts new file mode 100644 index 0000000..c16086f --- /dev/null +++ b/apps/backend/src/domain/entities/blog-post.entity.ts @@ -0,0 +1,132 @@ +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 + ): 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 }; + } +} diff --git a/apps/backend/src/domain/ports/out/blog-post.repository.ts b/apps/backend/src/domain/ports/out/blog-post.repository.ts new file mode 100644 index 0000000..07993a3 --- /dev/null +++ b/apps/backend/src/domain/ports/out/blog-post.repository.ts @@ -0,0 +1,22 @@ +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; + findById(id: string): Promise; + findBySlug(slug: string): Promise; + findByFilters(filters: BlogPostFilters): Promise; + count(filters: BlogPostFilters): Promise; + delete(id: string): Promise; + slugExists(slug: string, excludeId?: string): Promise; +} diff --git a/apps/backend/src/domain/ports/out/storage.port.ts b/apps/backend/src/domain/ports/out/storage.port.ts index 75d13a0..e03d27e 100644 --- a/apps/backend/src/domain/ports/out/storage.port.ts +++ b/apps/backend/src/domain/ports/out/storage.port.ts @@ -66,4 +66,9 @@ export interface StoragePort { * List objects in a bucket */ list(bucket: string, prefix?: string): Promise; + + /** + * Ensure a bucket exists, creating it if it does not + */ + ensureBucket(bucket: string): Promise; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts new file mode 100644 index 0000000..b86114c --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts @@ -0,0 +1,48 @@ +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; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1746000000000-CreateBlogPostsTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1746000000000-CreateBlogPostsTable.ts new file mode 100644 index 0000000..5bc66a0 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1746000000000-CreateBlogPostsTable.ts @@ -0,0 +1,116 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateBlogPostsTable1746000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.dropTable('blog_posts'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository.ts new file mode 100644 index 0000000..4503074 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository.ts @@ -0,0 +1,131 @@ +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 + ) {} + + async save(post: BlogPost): Promise { + const orm = this.toOrm(post); + const saved = await this.ormRepository.save(orm); + return this.toDomain(saved); + } + + async findById(id: string): Promise { + const orm = await this.ormRepository.findOne({ where: { id } }); + return orm ? this.toDomain(orm) : null; + } + + async findBySlug(slug: string): Promise { + const orm = await this.ormRepository.findOne({ where: { slug } }); + return orm ? this.toDomain(orm) : null; + } + + async findByFilters(filters: BlogPostFilters): Promise { + 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 { + 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 { + await this.ormRepository.delete(id); + } + + async slugExists(slug: string, excludeId?: string): Promise { + 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; + } +} diff --git a/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts b/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts index 496cf58..a054183 100644 --- a/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts +++ b/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts @@ -12,6 +12,8 @@ import { GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, + HeadBucketCommand, + CreateBucketCommand, ListObjectsV2Command, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; @@ -70,6 +72,23 @@ export class S3StorageAdapter implements StoragePort { ); } + async ensureBucket(bucket: string): Promise { + 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 { if (!this.s3Client) { throw new Error( @@ -77,6 +96,8 @@ export class S3StorageAdapter implements StoragePort { ); } + await this.ensureBucket(options.bucket); + try { const command = new PutObjectCommand({ Bucket: options.bucket, @@ -108,6 +129,12 @@ export class S3StorageAdapter implements StoragePort { } async download(options: DownloadOptions): Promise { + 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, diff --git a/apps/backend/startup.js b/apps/backend/startup.js index cb3fe47..02a7143 100644 --- a/apps/backend/startup.js +++ b/apps/backend/startup.js @@ -1,102 +1,102 @@ -#!/usr/bin/env node - -const { Client } = require('pg'); -const { DataSource } = require('typeorm'); -const path = require('path'); -const { spawn } = require('child_process'); - -async function waitForPostgres(maxAttempts = 30) { - console.log('⏳ Waiting for PostgreSQL to be ready...'); - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const client = new Client({ - host: process.env.DATABASE_HOST, - port: parseInt(process.env.DATABASE_PORT, 10), - user: process.env.DATABASE_USER, - password: process.env.DATABASE_PASSWORD, - database: process.env.DATABASE_NAME, - }); - - await client.connect(); - await client.end(); - console.log('✅ PostgreSQL is ready'); - return true; - } catch (error) { - console.log(`⏳ Attempt ${attempt}/${maxAttempts} - PostgreSQL not ready, retrying...`); - await new Promise(resolve => setTimeout(resolve, 2000)); - } - } - - console.error('❌ Failed to connect to PostgreSQL after', maxAttempts, 'attempts'); - process.exit(1); -} - -async function runMigrations() { - console.log('🔄 Running database migrations...'); - - const AppDataSource = new DataSource({ - type: 'postgres', - host: process.env.DATABASE_HOST, - port: parseInt(process.env.DATABASE_PORT, 10), - username: process.env.DATABASE_USER, - password: process.env.DATABASE_PASSWORD, - database: process.env.DATABASE_NAME, - entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], - migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], - synchronize: false, - logging: true, - }); - - try { - await AppDataSource.initialize(); - console.log('✅ DataSource initialized'); - - const migrations = await AppDataSource.runMigrations(); - - if (migrations.length === 0) { - console.log('✅ No pending migrations'); - } else { - console.log(`✅ Successfully ran ${migrations.length} migration(s):`); - migrations.forEach((migration) => { - console.log(` - ${migration.name}`); - }); - } - - await AppDataSource.destroy(); - console.log('✅ Database migrations completed'); - return true; - } catch (error) { - console.error('❌ Error during migration:', error); - process.exit(1); - } -} - -function startApplication() { - console.log('🚀 Starting NestJS application...'); - - const app = spawn('node', ['dist/main'], { - stdio: 'inherit', - env: process.env - }); - - app.on('exit', (code) => { - process.exit(code); - }); - - process.on('SIGTERM', () => app.kill('SIGTERM')); - process.on('SIGINT', () => app.kill('SIGINT')); -} - -async function main() { - console.log('🚀 Starting Xpeditis Backend...'); - - await waitForPostgres(); - await runMigrations(); - startApplication(); -} - -main().catch((error) => { - console.error('❌ Startup failed:', error); - process.exit(1); -}); +#!/usr/bin/env node + +const { Client } = require('pg'); +const { DataSource } = require('typeorm'); +const path = require('path'); +const { spawn } = require('child_process'); + +async function waitForPostgres(maxAttempts = 30) { + console.log('⏳ Waiting for PostgreSQL to be ready...'); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const client = new Client({ + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT, 10), + user: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + }); + + await client.connect(); + await client.end(); + console.log('✅ PostgreSQL is ready'); + return true; + } catch (error) { + console.log(`⏳ Attempt ${attempt}/${maxAttempts} - PostgreSQL not ready, retrying...`); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + + console.error('❌ Failed to connect to PostgreSQL after', maxAttempts, 'attempts'); + process.exit(1); +} + +async function runMigrations() { + console.log('🔄 Running database migrations...'); + + const AppDataSource = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT, 10), + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], + migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], + synchronize: false, + logging: true, + }); + + try { + await AppDataSource.initialize(); + console.log('✅ DataSource initialized'); + + const migrations = await AppDataSource.runMigrations(); + + if (migrations.length === 0) { + console.log('✅ No pending migrations'); + } else { + console.log(`✅ Successfully ran ${migrations.length} migration(s):`); + migrations.forEach(migration => { + console.log(` - ${migration.name}`); + }); + } + + await AppDataSource.destroy(); + console.log('✅ Database migrations completed'); + return true; + } catch (error) { + console.error('❌ Error during migration:', error); + process.exit(1); + } +} + +function startApplication() { + console.log('🚀 Starting NestJS application...'); + + const app = spawn('node', ['dist/main'], { + stdio: 'inherit', + env: process.env, + }); + + app.on('exit', code => { + process.exit(code); + }); + + process.on('SIGTERM', () => app.kill('SIGTERM')); + process.on('SIGINT', () => app.kill('SIGINT')); +} + +async function main() { + console.log('🚀 Starting Xpeditis Backend...'); + + await waitForPostgres(); + await runMigrations(); + startApplication(); +} + +main().catch(error => { + console.error('❌ Startup failed:', error); + process.exit(1); +}); diff --git a/apps/backend/sync-database-with-minio.js b/apps/backend/sync-database-with-minio.js index f7b1f44..67f611a 100644 --- a/apps/backend/sync-database-with-minio.js +++ b/apps/backend/sync-database-with-minio.js @@ -1,154 +1,154 @@ -/** - * Script to sync database with MinIO - * - * Removes document references from database for files that no longer exist in MinIO - */ - -const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); -const { Client } = require('pg'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -async function syncDatabase() { - const pgClient = new Client({ - host: process.env.DATABASE_HOST || 'localhost', - port: process.env.DATABASE_PORT || 5432, - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - }); - - try { - await pgClient.connect(); - console.log('✅ Connected to database\n'); - - // Get all MinIO files - console.log('📋 Listing files in MinIO...'); - let allMinioFiles = []; - let continuationToken = null; - - do { - const command = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - ContinuationToken: continuationToken, - }); - - const response = await s3Client.send(command); - - if (response.Contents) { - allMinioFiles = allMinioFiles.concat(response.Contents.map(f => f.Key)); - } - - continuationToken = response.NextContinuationToken; - } while (continuationToken); - - console.log(` Found ${allMinioFiles.length} files in MinIO\n`); - - // Create a set for faster lookup - const minioFilesSet = new Set(allMinioFiles); - - // Get all bookings with documents - const result = await pgClient.query( - `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND jsonb_array_length(documents::jsonb) > 0` - ); - - console.log(`📄 Found ${result.rows.length} bookings with documents in database\n`); - - let updatedCount = 0; - let removedDocsCount = 0; - let emptyBookingsCount = 0; - - for (const row of result.rows) { - const bookingId = row.id; - const documents = row.documents; - - // Filter documents to keep only those that exist in MinIO - const validDocuments = []; - const missingDocuments = []; - - for (const doc of documents) { - if (!doc.filePath) { - missingDocuments.push(doc); - continue; - } - - // Extract the S3 key from the URL - try { - const url = new URL(doc.filePath); - const pathname = url.pathname; - // Remove leading slash and bucket name - const key = pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); - - if (minioFilesSet.has(key)) { - validDocuments.push(doc); - } else { - missingDocuments.push(doc); - } - } catch (error) { - console.error(` ⚠️ Invalid URL for booking ${bookingId}: ${doc.filePath}`); - missingDocuments.push(doc); - } - } - - if (missingDocuments.length > 0) { - console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`); - console.log(` Total documents: ${documents.length}`); - console.log(` Valid documents: ${validDocuments.length}`); - console.log(` Missing documents: ${missingDocuments.length}`); - - missingDocuments.forEach(doc => { - console.log(` ❌ ${doc.fileName || 'Unknown'}`); - }); - - // Update the database - await pgClient.query( - `UPDATE csv_bookings SET documents = $1 WHERE id = $2`, - [JSON.stringify(validDocuments), bookingId] - ); - - updatedCount++; - removedDocsCount += missingDocuments.length; - - if (validDocuments.length === 0) { - emptyBookingsCount++; - console.log(` ⚠️ This booking now has NO documents`); - } - } - } - - console.log(`\n📊 Summary:`); - console.log(` Bookings updated: ${updatedCount}`); - console.log(` Documents removed from DB: ${removedDocsCount}`); - console.log(` Bookings with no documents: ${emptyBookingsCount}`); - console.log(`\n✅ Database synchronized with MinIO`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } finally { - await pgClient.end(); - console.log('\n👋 Disconnected from database'); - } -} - -syncDatabase() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to sync database with MinIO + * + * Removes document references from database for files that no longer exist in MinIO + */ + +const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); +const { Client } = require('pg'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function syncDatabase() { + const pgClient = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await pgClient.connect(); + console.log('✅ Connected to database\n'); + + // Get all MinIO files + console.log('📋 Listing files in MinIO...'); + let allMinioFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allMinioFiles = allMinioFiles.concat(response.Contents.map(f => f.Key)); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(` Found ${allMinioFiles.length} files in MinIO\n`); + + // Create a set for faster lookup + const minioFilesSet = new Set(allMinioFiles); + + // Get all bookings with documents + const result = await pgClient.query( + `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND jsonb_array_length(documents::jsonb) > 0` + ); + + console.log(`📄 Found ${result.rows.length} bookings with documents in database\n`); + + let updatedCount = 0; + let removedDocsCount = 0; + let emptyBookingsCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + // Filter documents to keep only those that exist in MinIO + const validDocuments = []; + const missingDocuments = []; + + for (const doc of documents) { + if (!doc.filePath) { + missingDocuments.push(doc); + continue; + } + + // Extract the S3 key from the URL + try { + const url = new URL(doc.filePath); + const pathname = url.pathname; + // Remove leading slash and bucket name + const key = pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); + + if (minioFilesSet.has(key)) { + validDocuments.push(doc); + } else { + missingDocuments.push(doc); + } + } catch (error) { + console.error(` ⚠️ Invalid URL for booking ${bookingId}: ${doc.filePath}`); + missingDocuments.push(doc); + } + } + + if (missingDocuments.length > 0) { + console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`); + console.log(` Total documents: ${documents.length}`); + console.log(` Valid documents: ${validDocuments.length}`); + console.log(` Missing documents: ${missingDocuments.length}`); + + missingDocuments.forEach(doc => { + console.log(` ❌ ${doc.fileName || 'Unknown'}`); + }); + + // Update the database + await pgClient.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [ + JSON.stringify(validDocuments), + bookingId, + ]); + + updatedCount++; + removedDocsCount += missingDocuments.length; + + if (validDocuments.length === 0) { + emptyBookingsCount++; + console.log(` ⚠️ This booking now has NO documents`); + } + } + } + + console.log(`\n📊 Summary:`); + console.log(` Bookings updated: ${updatedCount}`); + console.log(` Documents removed from DB: ${removedDocsCount}`); + console.log(` Bookings with no documents: ${emptyBookingsCount}`); + console.log(`\n✅ Database synchronized with MinIO`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await pgClient.end(); + console.log('\n👋 Disconnected from database'); + } +} + +syncDatabase() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/test-booking-workflow.js b/apps/backend/test-booking-workflow.js index c2f25d8..19889ee 100644 --- a/apps/backend/test-booking-workflow.js +++ b/apps/backend/test-booking-workflow.js @@ -12,7 +12,7 @@ const API_BASE = 'http://localhost:4000/api/v1'; // Test credentials - you need to use real credentials from your database const TEST_USER = { email: 'admin@xpeditis.com', // Change this to a real user email - password: 'Admin123!', // Change this to the real password + password: 'Admin123!', // Change this to the real password }; async function testWorkflow() { @@ -56,16 +56,12 @@ async function testWorkflow() { contentType: 'application/pdf', }); - const bookingResponse = await axios.post( - `${API_BASE}/csv-bookings`, - form, - { - headers: { - ...form.getHeaders(), - Authorization: `Bearer ${token}`, - }, - } - ); + 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); @@ -80,7 +76,9 @@ 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) { diff --git a/apps/backend/test-carrier-email-fix.js b/apps/backend/test-carrier-email-fix.js index 88b7b6e..2e60159 100644 --- a/apps/backend/test-carrier-email-fix.js +++ b/apps/backend/test-carrier-email-fix.js @@ -1,228 +1,230 @@ -/** - * Script de test pour vérifier l'envoi d'email aux transporteurs - * - * Usage: node test-carrier-email-fix.js - */ - -const nodemailer = require('nodemailer'); - -async function testEmailConfig() { - console.log('🔍 Test de configuration email Mailtrap...\n'); - - const config = { - host: process.env.SMTP_HOST || 'sandbox.smtp.mailtrap.io', - port: parseInt(process.env.SMTP_PORT || '2525'), - user: process.env.SMTP_USER || '2597bd31d265eb', - pass: process.env.SMTP_PASS || 'cd126234193c89', - }; - - console.log('📧 Configuration SMTP:'); - console.log(` Host: ${config.host}`); - console.log(` Port: ${config.port}`); - console.log(` User: ${config.user}`); - console.log(` Pass: ${config.pass.substring(0, 4)}***\n`); - - // Test 1: Configuration standard (peut échouer avec timeout DNS) - console.log('Test 1: Configuration standard...'); - try { - const transporter1 = nodemailer.createTransport({ - host: config.host, - port: config.port, - secure: false, - auth: { - user: config.user, - pass: config.pass, - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - }); - - await transporter1.sendMail({ - from: 'noreply@xpeditis.com', - to: 'test@xpeditis.com', - subject: 'Test Email - Configuration Standard', - html: '

Test réussi!

Configuration standard fonctionne.

', - }); - - console.log('✅ Test 1 RÉUSSI - Configuration standard OK\n'); - } catch (error) { - console.error('❌ Test 1 ÉCHOUÉ:', error.message); - console.error(' Code:', error.code); - console.error(' Timeout?', error.message.includes('ETIMEOUT')); - console.log(''); - } - - // Test 2: Configuration avec IP directe (devrait toujours fonctionner) - console.log('Test 2: Configuration avec IP directe...'); - try { - const useDirectIP = config.host.includes('mailtrap.io'); - const actualHost = useDirectIP ? '3.209.246.195' : config.host; - const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host; - - console.log(` Utilisation IP directe: ${useDirectIP}`); - console.log(` Host réel: ${actualHost}`); - console.log(` Server name (TLS): ${serverName}`); - - const transporter2 = nodemailer.createTransport({ - host: actualHost, - port: config.port, - secure: false, - auth: { - user: config.user, - pass: config.pass, - }, - tls: { - rejectUnauthorized: false, - servername: serverName, - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - dnsTimeout: 10000, - }); - - const result = await transporter2.sendMail({ - from: 'noreply@xpeditis.com', - to: 'test@xpeditis.com', - subject: 'Test Email - Configuration IP Directe', - html: '

Test réussi!

Configuration avec IP directe fonctionne.

', - }); - - console.log('✅ Test 2 RÉUSSI - Configuration IP directe OK'); - console.log(` Message ID: ${result.messageId}`); - console.log(` Response: ${result.response}\n`); - } catch (error) { - console.error('❌ Test 2 ÉCHOUÉ:', error.message); - console.error(' Code:', error.code); - console.log(''); - } - - // Test 3: Template HTML de booking transporteur - console.log('Test 3: Envoi avec template HTML complet...'); - try { - const useDirectIP = config.host.includes('mailtrap.io'); - const actualHost = useDirectIP ? '3.209.246.195' : config.host; - const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host; - - const transporter3 = nodemailer.createTransport({ - host: actualHost, - port: config.port, - secure: false, - auth: { - user: config.user, - pass: config.pass, - }, - tls: { - rejectUnauthorized: false, - servername: serverName, - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - dnsTimeout: 10000, - }); - - const bookingData = { - bookingId: 'TEST-' + Date.now(), - origin: 'FRPAR', - destination: 'USNYC', - volumeCBM: 10.5, - weightKG: 850, - palletCount: 4, - priceUSD: 1500, - priceEUR: 1350, - primaryCurrency: 'USD', - transitDays: 15, - containerType: '20FT', - documents: [ - { type: 'Bill of Lading', fileName: 'bol.pdf' }, - { type: 'Packing List', fileName: 'packing_list.pdf' }, - ], - acceptUrl: 'http://localhost:3000/carrier/booking/accept', - rejectUrl: 'http://localhost:3000/carrier/booking/reject', - }; - - const htmlTemplate = ` - - - - -
-
-

🚢 Nouvelle demande de réservation

-

Xpeditis

-
-
-

Bonjour,

-

Vous avez reçu une nouvelle demande de réservation via Xpeditis.

-

📋 Détails du transport

- - - - - - - - - - - - - -
Route${bookingData.origin} → ${bookingData.destination}
Volume${bookingData.volumeCBM} CBM
Prix - ${bookingData.priceUSD} USD -
-
-

Veuillez confirmer votre décision :

- ✓ Accepter - ✗ Refuser -
-
-

- ⚠️ Important
- Cette demande expire automatiquement dans 7 jours si aucune action n'est prise. -

-
-
-
-

Référence : ${bookingData.bookingId}

-

© 2025 Xpeditis. Tous droits réservés.

-
-
- - - `; - - const result = await transporter3.sendMail({ - from: 'noreply@xpeditis.com', - to: 'carrier@test.com', - subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`, - html: htmlTemplate, - }); - - console.log('✅ Test 3 RÉUSSI - Email complet avec template envoyé'); - console.log(` Message ID: ${result.messageId}`); - console.log(` Response: ${result.response}\n`); - } catch (error) { - console.error('❌ Test 3 ÉCHOUÉ:', error.message); - console.error(' Code:', error.code); - console.log(''); - } - - 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'); -} - -// Run test -testEmailConfig() - .then(() => { - console.log('✅ Tests terminés avec succès'); - process.exit(0); - }) - .catch((error) => { - console.error('❌ Erreur lors des tests:', error); - process.exit(1); - }); +/** + * Script de test pour vérifier l'envoi d'email aux transporteurs + * + * Usage: node test-carrier-email-fix.js + */ + +const nodemailer = require('nodemailer'); + +async function testEmailConfig() { + console.log('🔍 Test de configuration email Mailtrap...\n'); + + const config = { + host: process.env.SMTP_HOST || 'sandbox.smtp.mailtrap.io', + port: parseInt(process.env.SMTP_PORT || '2525'), + user: process.env.SMTP_USER || '2597bd31d265eb', + pass: process.env.SMTP_PASS || 'cd126234193c89', + }; + + console.log('📧 Configuration SMTP:'); + console.log(` Host: ${config.host}`); + console.log(` Port: ${config.port}`); + console.log(` User: ${config.user}`); + console.log(` Pass: ${config.pass.substring(0, 4)}***\n`); + + // Test 1: Configuration standard (peut échouer avec timeout DNS) + console.log('Test 1: Configuration standard...'); + try { + const transporter1 = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: false, + auth: { + user: config.user, + pass: config.pass, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 30000, + }); + + await transporter1.sendMail({ + from: 'noreply@xpeditis.com', + to: 'test@xpeditis.com', + subject: 'Test Email - Configuration Standard', + html: '

Test réussi!

Configuration standard fonctionne.

', + }); + + console.log('✅ Test 1 RÉUSSI - Configuration standard OK\n'); + } catch (error) { + console.error('❌ Test 1 ÉCHOUÉ:', error.message); + console.error(' Code:', error.code); + console.error(' Timeout?', error.message.includes('ETIMEOUT')); + console.log(''); + } + + // Test 2: Configuration avec IP directe (devrait toujours fonctionner) + console.log('Test 2: Configuration avec IP directe...'); + try { + const useDirectIP = config.host.includes('mailtrap.io'); + const actualHost = useDirectIP ? '3.209.246.195' : config.host; + const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host; + + console.log(` Utilisation IP directe: ${useDirectIP}`); + console.log(` Host réel: ${actualHost}`); + console.log(` Server name (TLS): ${serverName}`); + + const transporter2 = nodemailer.createTransport({ + host: actualHost, + port: config.port, + secure: false, + auth: { + user: config.user, + pass: config.pass, + }, + tls: { + rejectUnauthorized: false, + servername: serverName, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 30000, + dnsTimeout: 10000, + }); + + const result = await transporter2.sendMail({ + from: 'noreply@xpeditis.com', + to: 'test@xpeditis.com', + subject: 'Test Email - Configuration IP Directe', + html: '

Test réussi!

Configuration avec IP directe fonctionne.

', + }); + + console.log('✅ Test 2 RÉUSSI - Configuration IP directe OK'); + console.log(` Message ID: ${result.messageId}`); + console.log(` Response: ${result.response}\n`); + } catch (error) { + console.error('❌ Test 2 ÉCHOUÉ:', error.message); + console.error(' Code:', error.code); + console.log(''); + } + + // Test 3: Template HTML de booking transporteur + console.log('Test 3: Envoi avec template HTML complet...'); + try { + const useDirectIP = config.host.includes('mailtrap.io'); + const actualHost = useDirectIP ? '3.209.246.195' : config.host; + const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host; + + const transporter3 = nodemailer.createTransport({ + host: actualHost, + port: config.port, + secure: false, + auth: { + user: config.user, + pass: config.pass, + }, + tls: { + rejectUnauthorized: false, + servername: serverName, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 30000, + dnsTimeout: 10000, + }); + + const bookingData = { + bookingId: 'TEST-' + Date.now(), + origin: 'FRPAR', + destination: 'USNYC', + volumeCBM: 10.5, + weightKG: 850, + palletCount: 4, + priceUSD: 1500, + priceEUR: 1350, + primaryCurrency: 'USD', + transitDays: 15, + containerType: '20FT', + documents: [ + { type: 'Bill of Lading', fileName: 'bol.pdf' }, + { type: 'Packing List', fileName: 'packing_list.pdf' }, + ], + acceptUrl: 'http://localhost:3000/carrier/booking/accept', + rejectUrl: 'http://localhost:3000/carrier/booking/reject', + }; + + const htmlTemplate = ` + + + + +
+
+

🚢 Nouvelle demande de réservation

+

Xpeditis

+
+
+

Bonjour,

+

Vous avez reçu une nouvelle demande de réservation via Xpeditis.

+

📋 Détails du transport

+ + + + + + + + + + + + + +
Route${bookingData.origin} → ${bookingData.destination}
Volume${bookingData.volumeCBM} CBM
Prix + ${bookingData.priceUSD} USD +
+
+

Veuillez confirmer votre décision :

+ ✓ Accepter + ✗ Refuser +
+
+

+ ⚠️ Important
+ Cette demande expire automatiquement dans 7 jours si aucune action n'est prise. +

+
+
+
+

Référence : ${bookingData.bookingId}

+

© 2025 Xpeditis. Tous droits réservés.

+
+
+ + + `; + + const result = await transporter3.sendMail({ + from: 'noreply@xpeditis.com', + to: 'carrier@test.com', + subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`, + html: htmlTemplate, + }); + + console.log('✅ Test 3 RÉUSSI - Email complet avec template envoyé'); + console.log(` Message ID: ${result.messageId}`); + console.log(` Response: ${result.response}\n`); + } catch (error) { + console.error('❌ Test 3 ÉCHOUÉ:', error.message); + console.error(' Code:', error.code); + console.log(''); + } + + 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' + ); +} + +// Run test +testEmailConfig() + .then(() => { + console.log('✅ Tests terminés avec succès'); + process.exit(0); + }) + .catch(error => { + console.error('❌ Erreur lors des tests:', error); + process.exit(1); + }); diff --git a/apps/backend/test-carrier-email.js b/apps/backend/test-carrier-email.js index 41833f5..5b44776 100644 --- a/apps/backend/test-carrier-email.js +++ b/apps/backend/test-carrier-email.js @@ -5,25 +5,28 @@ 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({ - 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 => { - console.log('✅ Email envoyé:', info.messageId); - console.log('📧 Response:', info.response); - process.exit(0); -}).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); -}); +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 => { + console.log('✅ Email envoyé:', info.messageId); + console.log('📧 Response:', info.response); + process.exit(0); + }) + .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); + }); diff --git a/apps/backend/test-email-ip.js b/apps/backend/test-email-ip.js index 1ea3bbf..e7d7093 100644 --- a/apps/backend/test-email-ip.js +++ b/apps/backend/test-email-ip.js @@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config); console.log('\n1️⃣ Verifying SMTP connection...'); -transporter.verify() +transporter + .verify() .then(() => { console.log('✅ SMTP connection verified!'); console.log('\n2️⃣ Sending test email...'); @@ -40,17 +41,17 @@ transporter.verify() from: 'noreply@xpeditis.com', to: 'test@example.com', subject: 'Test Xpeditis - Envoi Direct IP', - html: '

✅ Email envoyé avec succès!

Ce test utilise l\'IP directe pour contourner le DNS.

', + html: "

✅ Email envoyé avec succès!

Ce test utilise l'IP directe pour contourner le DNS.

", }); }) - .then((info) => { + .then(info => { console.log('✅ Email sent successfully!'); console.log('📧 Message ID:', info.messageId); console.log('📬 Response:', info.response); console.log('\n🎉 SUCCESS! Email sending works with IP directly.'); process.exit(0); }) - .catch((error) => { + .catch(error => { console.error('\n❌ ERROR:', error.message); console.error('Code:', error.code); console.error('Command:', error.command); diff --git a/apps/backend/test-email-service.js b/apps/backend/test-email-service.js index 973e401..94da3f0 100644 --- a/apps/backend/test-email-service.js +++ b/apps/backend/test-email-service.js @@ -6,7 +6,8 @@ const axios = require('axios'); const API_URL = 'http://localhost:4000/api/v1'; // Token d'authentification (admin@xpeditis.com) -const AUTH_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MTI3Y2M0Zi04Yzg4LTRjNGUtYmU1ZC1hNmY1ZTE2MWZlNDMiLCJlbWFpbCI6ImFkbWluQHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiMWZhOWE1NjUtZjNjOC00ZTExLTliMzAtMTIwZDEwNTJjZWYwIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2NDg3NDQ2MSwiZXhwIjoxNzY0ODc1MzYxfQ.l_-97_rikGj-DP8aA14CK-Ab-0Usy722MRe1lqi0u9I'; +const AUTH_TOKEN = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MTI3Y2M0Zi04Yzg4LTRjNGUtYmU1ZC1hNmY1ZTE2MWZlNDMiLCJlbWFpbCI6ImFkbWluQHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiMWZhOWE1NjUtZjNjOC00ZTExLTliMzAtMTIwZDEwNTJjZWYwIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2NDg3NDQ2MSwiZXhwIjoxNzY0ODc1MzYxfQ.l_-97_rikGj-DP8aA14CK-Ab-0Usy722MRe1lqi0u9I'; async function testCsvBookingEmail() { console.log('🧪 Test envoi email via CSV booking...\n'); @@ -19,7 +20,10 @@ async function testCsvBookingEmail() { // Créer un fichier de test temporaire const testFile = Buffer.from('Test document content'); - form.append('documents', testFile, { filename: 'test-document.pdf', contentType: 'application/pdf' }); + form.append('documents', testFile, { + filename: 'test-document.pdf', + contentType: 'application/pdf', + }); // Ajouter les champs du formulaire form.append('carrierName', 'Test Carrier Email'); @@ -41,8 +45,8 @@ async function testCsvBookingEmail() { const response = await axios.post(`${API_URL}/csv-bookings`, form, { headers: { ...form.getHeaders(), - 'Authorization': `Bearer ${AUTH_TOKEN}` - } + Authorization: `Bearer ${AUTH_TOKEN}`, + }, }); console.log('✅ Réponse reçue:', response.status); @@ -51,11 +55,10 @@ async function testCsvBookingEmail() { console.log('1. Les logs du backend pour voir "Email sent to carrier:"'); console.log('2. Votre inbox Mailtrap: https://mailtrap.io/inboxes'); console.log('3. Email destinataire: test-carrier@example.com'); - } catch (error) { console.error('❌ Erreur:', error.response?.data || error.message); if (error.response?.status === 401) { - console.error('\n⚠️ Token expiré. Connectez-vous d\'abord avec:'); + console.error("\n⚠️ Token expiré. Connectez-vous d'abord avec:"); console.error('POST /api/v1/auth/login'); console.error('{ "email": "admin@xpeditis.com", "password": "..." }'); } diff --git a/apps/backend/test-email.js b/apps/backend/test-email.js index f2a1a5e..ed32a70 100644 --- a/apps/backend/test-email.js +++ b/apps/backend/test-email.js @@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config); console.log('\nVerifying SMTP connection...'); -transporter.verify() +transporter + .verify() .then(() => { console.log('✅ SMTP connection verified successfully!'); console.log('\nSending test email...'); @@ -43,13 +44,13 @@ transporter.verify() html: '

Test Email

If you see this, email sending works!

', }); }) - .then((info) => { + .then(info => { console.log('✅ Email sent successfully!'); console.log('Message ID:', info.messageId); console.log('Response:', info.response); process.exit(0); }) - .catch((error) => { + .catch(error => { console.error('❌ Error:', error.message); console.error('Full error:', error); process.exit(1); diff --git a/apps/backend/test-smtp-simple.js b/apps/backend/test-smtp-simple.js index 1b2b4d4..b80b4ce 100644 --- a/apps/backend/test-smtp-simple.js +++ b/apps/backend/test-smtp-simple.js @@ -1,74 +1,74 @@ -#!/usr/bin/env node - -// Test SMTP ultra-simple pour identifier le problème -const nodemailer = require('nodemailer'); -require('dotenv').config(); - -console.log('🔍 Test SMTP Simple\n'); -console.log('Configuration:'); -console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'NON DÉFINI'); -console.log(' SMTP_PORT:', process.env.SMTP_PORT || 'NON DÉFINI'); -console.log(' SMTP_USER:', process.env.SMTP_USER || 'NON DÉFINI'); -console.log(' SMTP_PASS:', process.env.SMTP_PASS ? '***' : 'NON DÉFINI'); -console.log(''); - -const host = process.env.SMTP_HOST; -const port = parseInt(process.env.SMTP_PORT || '2525'); -const user = process.env.SMTP_USER; -const pass = process.env.SMTP_PASS; - -// Appliquer le même fix DNS que dans email.adapter.ts -const useDirectIP = host && host.includes('mailtrap.io'); -const actualHost = useDirectIP ? '3.209.246.195' : host; -const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; - -console.log('Fix DNS:'); -console.log(' Utilise IP directe:', useDirectIP); -console.log(' Host réel:', actualHost); -console.log(' Server name:', serverName); -console.log(''); - -const transporter = nodemailer.createTransport({ - host: actualHost, - port, - secure: false, - auth: { user, pass }, - tls: { - rejectUnauthorized: false, - servername: serverName, - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - dnsTimeout: 10000, -}); - -async function test() { - try { - console.log('Test 1: Vérification de la connexion...'); - await transporter.verify(); - console.log('✅ Connexion SMTP OK\n'); - - console.log('Test 2: Envoi d\'un email...'); - const info = await transporter.sendMail({ - from: 'noreply@xpeditis.com', - to: 'test@example.com', - subject: 'Test - ' + new Date().toISOString(), - html: '

Test réussi!

Ce message confirme que l\'envoi d\'email fonctionne.

', - }); - - console.log('✅ Email envoyé avec succès!'); - console.log(' Message ID:', info.messageId); - console.log(' Response:', info.response); - console.log(''); - console.log('✅ TOUS LES TESTS RÉUSSIS - Le SMTP fonctionne!'); - process.exit(0); - } catch (error) { - console.error('❌ ERREUR:', error.message); - console.error(' Code:', error.code); - console.error(' Command:', error.command); - process.exit(1); - } -} - -test(); +#!/usr/bin/env node + +// Test SMTP ultra-simple pour identifier le problème +const nodemailer = require('nodemailer'); +require('dotenv').config(); + +console.log('🔍 Test SMTP Simple\n'); +console.log('Configuration:'); +console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'NON DÉFINI'); +console.log(' SMTP_PORT:', process.env.SMTP_PORT || 'NON DÉFINI'); +console.log(' SMTP_USER:', process.env.SMTP_USER || 'NON DÉFINI'); +console.log(' SMTP_PASS:', process.env.SMTP_PASS ? '***' : 'NON DÉFINI'); +console.log(''); + +const host = process.env.SMTP_HOST; +const port = parseInt(process.env.SMTP_PORT || '2525'); +const user = process.env.SMTP_USER; +const pass = process.env.SMTP_PASS; + +// Appliquer le même fix DNS que dans email.adapter.ts +const useDirectIP = host && host.includes('mailtrap.io'); +const actualHost = useDirectIP ? '3.209.246.195' : host; +const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; + +console.log('Fix DNS:'); +console.log(' Utilise IP directe:', useDirectIP); +console.log(' Host réel:', actualHost); +console.log(' Server name:', serverName); +console.log(''); + +const transporter = nodemailer.createTransport({ + host: actualHost, + port, + secure: false, + auth: { user, pass }, + tls: { + rejectUnauthorized: false, + servername: serverName, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 30000, + dnsTimeout: 10000, +}); + +async function test() { + try { + console.log('Test 1: Vérification de la connexion...'); + await transporter.verify(); + console.log('✅ Connexion SMTP OK\n'); + + console.log("Test 2: Envoi d'un email..."); + const info = await transporter.sendMail({ + from: 'noreply@xpeditis.com', + to: 'test@example.com', + subject: 'Test - ' + new Date().toISOString(), + html: "

Test réussi!

Ce message confirme que l'envoi d'email fonctionne.

", + }); + + console.log('✅ Email envoyé avec succès!'); + console.log(' Message ID:', info.messageId); + console.log(' Response:', info.response); + console.log(''); + console.log('✅ TOUS LES TESTS RÉUSSIS - Le SMTP fonctionne!'); + process.exit(0); + } catch (error) { + console.error('❌ ERREUR:', error.message); + console.error(' Code:', error.code); + console.error(' Command:', error.command); + process.exit(1); + } +} + +test(); diff --git a/apps/backend/tsconfig.build.json b/apps/backend/tsconfig.build.json index 1d7acd8..64f86c6 100644 --- a/apps/backend/tsconfig.build.json +++ b/apps/backend/tsconfig.build.json @@ -1,4 +1,4 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] -} +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/backend/upload-test-documents.js b/apps/backend/upload-test-documents.js index 69e3323..95d358a 100644 --- a/apps/backend/upload-test-documents.js +++ b/apps/backend/upload-test-documents.js @@ -1,185 +1,185 @@ -/** - * Script to upload test documents to MinIO - */ - -const { S3Client, PutObjectCommand, CreateBucketCommand } = require('@aws-sdk/client-s3'); -const { Client: PgClient } = require('pg'); -const fs = require('fs'); -const path = require('path'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -// Create a simple PDF buffer (minimal valid PDF) -function createTestPDF(title) { - return Buffer.from( - `%PDF-1.4 -1 0 obj -<< -/Type /Catalog -/Pages 2 0 R ->> -endobj -2 0 obj -<< -/Type /Pages -/Kids [3 0 R] -/Count 1 ->> -endobj -3 0 obj -<< -/Type /Page -/Parent 2 0 R -/MediaBox [0 0 612 792] -/Contents 4 0 R -/Resources << -/Font << -/F1 << -/Type /Font -/Subtype /Type1 -/BaseFont /Helvetica ->> ->> ->> ->> -endobj -4 0 obj -<< -/Length 100 ->> -stream -BT -/F1 24 Tf -100 700 Td -(${title}) Tj -ET -endstream -endobj -xref -0 5 -0000000000 65535 f -0000000009 00000 n -0000000058 00000 n -0000000115 00000 n -0000000300 00000 n -trailer -<< -/Size 5 -/Root 1 0 R ->> -startxref -450 -%%EOF`, - 'utf-8' - ); -} - -async function uploadTestDocuments() { - const pgClient = new PgClient({ - host: process.env.DATABASE_HOST || 'localhost', - port: process.env.DATABASE_PORT || 5432, - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - }); - - try { - // Connect to database - await pgClient.connect(); - console.log('✅ Connected to database'); - - // Create bucket if it doesn't exist - try { - await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); - console.log(`✅ Created bucket: ${BUCKET_NAME}`); - } catch (error) { - if (error.name === 'BucketAlreadyOwnedByYou' || error.Code === 'BucketAlreadyOwnedByYou') { - console.log(`✅ Bucket already exists: ${BUCKET_NAME}`); - } else { - console.log(`⚠️ Could not create bucket (might already exist): ${error.message}`); - } - } - - // Get all CSV bookings with documents - const result = await pgClient.query( - `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL` - ); - - console.log(`\n📄 Found ${result.rows.length} bookings with documents\n`); - - let uploadedCount = 0; - - for (const row of result.rows) { - const bookingId = row.id; - const documents = row.documents; - - console.log(`\n📦 Processing booking: ${bookingId}`); - - for (const doc of documents) { - if (!doc.filePath || !doc.filePath.includes(MINIO_ENDPOINT)) { - console.log(` ⏭️ Skipping document (not a MinIO URL): ${doc.fileName}`); - continue; - } - - // Extract the S3 key from the URL - const url = new URL(doc.filePath); - const key = url.pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); - - // Create test PDF content - const pdfContent = createTestPDF(doc.fileName || 'Test Document'); - - try { - // Upload to MinIO - await s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - Body: pdfContent, - ContentType: doc.mimeType || 'application/pdf', - }) - ); - - console.log(` ✅ Uploaded: ${doc.fileName}`); - console.log(` Path: ${key}`); - uploadedCount++; - } catch (error) { - console.error(` ❌ Failed to upload ${doc.fileName}:`, error.message); - } - } - } - - console.log(`\n🎉 Successfully uploaded ${uploadedCount} test documents to MinIO`); - console.log(`\n📍 MinIO Console: http://localhost:9001`); - console.log(` Username: minioadmin`); - console.log(` Password: minioadmin`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } finally { - await pgClient.end(); - console.log('\n👋 Disconnected from database'); - } -} - -uploadTestDocuments() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to upload test documents to MinIO + */ + +const { S3Client, PutObjectCommand, CreateBucketCommand } = require('@aws-sdk/client-s3'); +const { Client: PgClient } = require('pg'); +const fs = require('fs'); +const path = require('path'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +// Create a simple PDF buffer (minimal valid PDF) +function createTestPDF(title) { + return Buffer.from( + `%PDF-1.4 +1 0 obj +<< +/Type /Catalog +/Pages 2 0 R +>> +endobj +2 0 obj +<< +/Type /Pages +/Kids [3 0 R] +/Count 1 +>> +endobj +3 0 obj +<< +/Type /Page +/Parent 2 0 R +/MediaBox [0 0 612 792] +/Contents 4 0 R +/Resources << +/Font << +/F1 << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +endobj +4 0 obj +<< +/Length 100 +>> +stream +BT +/F1 24 Tf +100 700 Td +(${title}) Tj +ET +endstream +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000300 00000 n +trailer +<< +/Size 5 +/Root 1 0 R +>> +startxref +450 +%%EOF`, + 'utf-8' + ); +} + +async function uploadTestDocuments() { + const pgClient = new PgClient({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + // Connect to database + await pgClient.connect(); + console.log('✅ Connected to database'); + + // Create bucket if it doesn't exist + try { + await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); + console.log(`✅ Created bucket: ${BUCKET_NAME}`); + } catch (error) { + if (error.name === 'BucketAlreadyOwnedByYou' || error.Code === 'BucketAlreadyOwnedByYou') { + console.log(`✅ Bucket already exists: ${BUCKET_NAME}`); + } else { + console.log(`⚠️ Could not create bucket (might already exist): ${error.message}`); + } + } + + // Get all CSV bookings with documents + const result = await pgClient.query( + `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL` + ); + + console.log(`\n📄 Found ${result.rows.length} bookings with documents\n`); + + let uploadedCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + console.log(`\n📦 Processing booking: ${bookingId}`); + + for (const doc of documents) { + if (!doc.filePath || !doc.filePath.includes(MINIO_ENDPOINT)) { + console.log(` ⏭️ Skipping document (not a MinIO URL): ${doc.fileName}`); + continue; + } + + // Extract the S3 key from the URL + const url = new URL(doc.filePath); + const key = url.pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); + + // Create test PDF content + const pdfContent = createTestPDF(doc.fileName || 'Test Document'); + + try { + // Upload to MinIO + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Body: pdfContent, + ContentType: doc.mimeType || 'application/pdf', + }) + ); + + console.log(` ✅ Uploaded: ${doc.fileName}`); + console.log(` Path: ${key}`); + uploadedCount++; + } catch (error) { + console.error(` ❌ Failed to upload ${doc.fileName}:`, error.message); + } + } + } + + console.log(`\n🎉 Successfully uploaded ${uploadedCount} test documents to MinIO`); + console.log(`\n📍 MinIO Console: http://localhost:9001`); + console.log(` Username: minioadmin`); + console.log(` Password: minioadmin`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await pgClient.end(); + console.log('\n👋 Disconnected from database'); + } +} + +uploadTestDocuments() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/frontend/app/[locale]/about/page.tsx b/apps/frontend/app/[locale]/about/page.tsx index 105bf0d..a49585e 100644 --- a/apps/frontend/app/[locale]/about/page.tsx +++ b/apps/frontend/app/[locale]/about/page.tsx @@ -90,7 +90,10 @@ export default function AboutPage() { {/* Hero Section */} -
+
@@ -155,9 +158,7 @@ export default function AboutPage() {

{t('mission.title')}

-

- {t('mission.body')} -

+

{t('mission.body')}

{t('vision.title')}

-

- {t('vision.body')} -

+

{t('vision.body')}

@@ -186,11 +185,7 @@ export default function AboutPage() { >
{STATS.map((stat, index) => ( - + -

{t('valuesTitle')}

-

- {t('valuesSubtitle')} -

+

+ {t('valuesTitle')} +

+

{t('valuesSubtitle')}

- {VALUES.map((value) => { + {VALUES.map(value => { const IconComponent = value.icon; return (
-

{t(`values.${value.key}.title`)}

+

+ {t(`values.${value.key}.title`)} +

{t(`values.${value.key}.description`)}

); @@ -259,10 +256,10 @@ export default function AboutPage() { transition={{ duration: 0.8 }} className="text-center mb-16" > -

{t('timelineTitle')}

-

- {t('timelineSubtitle')} -

+

+ {t('timelineTitle')} +

+

{t('timelineSubtitle')}

@@ -286,13 +283,19 @@ export default function AboutPage() { transition={{ duration: 0.7, ease: 'easeOut' }} className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`} > -
+
-
+
{year}
-

{t(`timeline.${year}.title`)}

+

+ {t(`timeline.${year}.title`)} +

{t(`timeline.${year}.description`)}

@@ -302,7 +305,13 @@ export default function AboutPage() { initial={{ scale: 0 }} whileInView={{ scale: 1 }} viewport={{ once: true, amount: 0.6 }} - transition={{ duration: 0.4, delay: 0.15, type: 'spring', stiffness: 320, damping: 18 }} + transition={{ + duration: 0.4, + delay: 0.15, + type: 'spring', + stiffness: 320, + damping: 18, + }} className="w-5 h-5 bg-brand-turquoise rounded-full border-4 border-white shadow-lg ring-2 ring-brand-turquoise/30" />
@@ -324,10 +333,10 @@ export default function AboutPage() { transition={{ duration: 0.8 }} className="text-center mb-16" > -

{t('teamTitle')}

-

- {t('teamSubtitle')} -

+

+ {t('teamTitle')} +

+

{t('teamSubtitle')}

- {TEAM.map((member) => ( + {TEAM.map(member => (

{member.name}

-

{t(`team.${member.key}.role`)}

+

+ {t(`team.${member.key}.role`)} +

{t(`team.${member.key}.bio`)}

@@ -376,12 +387,8 @@ export default function AboutPage() { viewport={{ once: true }} transition={{ duration: 0.8 }} > -

- {t('cta.title')} -

-

- {t('cta.body')} -

+

{t('cta.title')}

+

{t('cta.body')}

= { + industry: 'Industrie', + technology: 'Technologie', + guides: 'Guides', + news: 'Actualités', +}; + +export default function BlogPostPage() { + const params = useParams(); + const slug = params?.slug as string; + + const [post, setPost] = useState(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + + useEffect(() => { + if (!slug) return; + + let cancelled = false; + + getBlogPost(slug) + .then(p => { + if (!cancelled) setPost(p); + }) + .catch(() => { + if (!cancelled) setNotFound(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [slug]); + + if (loading) { + return ( +
+ +
+
+
+
+
+
+
+ +
+ ); + } + + if (notFound || !post) { + return ( +
+ +
+ +

Article introuvable

+

+ Cet article n'existe pas ou n'est pas encore publié. +

+ + + + Retour au blog + + +
+ +
+ ); + } + + return ( +
+ + + {/* Hero */} +
+
+
+
+ +
+ + + + + Retour au blog + + + +
+ + {CATEGORY_LABELS[post.category] ?? post.category} + + {post.isFeatured && ( + + À la une + + )} +
+ +

+ {post.title} +

+ +

{post.excerpt}

+ +
+
+ + {post.authorName} +
+ {post.publishedAt && ( +
+ + {formatDate(post.publishedAt)} +
+ )} + {post.tags.length > 0 && ( +
+ + {post.tags.join(', ')} +
+ )} +
+
+
+ +
+ + + +
+
+ + {/* Cover Image */} + {post.coverImageUrl && ( +
+ +
+ )} + + {/* Content */} +
+
+ + + {/* Tags */} + {post.tags.length > 0 && ( +
+ {post.tags.map(tag => ( + + #{tag} + + ))} +
+ )} + + {/* Back link */} +
+ + + + Retour au blog + + +
+
+
+ + +
+ ); +} diff --git a/apps/frontend/app/[locale]/blog/page.tsx b/apps/frontend/app/[locale]/blog/page.tsx index 9c4e18a..2a2843e 100644 --- a/apps/frontend/app/[locale]/blog/page.tsx +++ b/apps/frontend/app/[locale]/blog/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { motion, useInView } from 'framer-motion'; @@ -19,9 +19,16 @@ import { type LucideIcon, } from 'lucide-react'; import { LandingHeader, LandingFooter } from '@/components/layout'; +import { getBlogPosts, type BlogPost, type BlogPostCategory } from '@/lib/api/blog'; -type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news'; -type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents'; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; +function resolveUrl(url: string | null | undefined): string | undefined { + if (!url) return undefined; + if (url.startsWith('http')) return url; + return `${API_BASE_URL}${url}`; +} + +type CategoryKey = 'all' | BlogPostCategory; const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [ { key: 'all', icon: BookOpen }, @@ -31,20 +38,36 @@ const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [ { key: 'news', icon: Globe }, ]; -const ARTICLES: { id: number; key: ArticleKey; category: Exclude; tags: string[] }[] = [ - { id: 2, key: 'incoterms', category: 'guides', tags: ['Incoterms', 'Guide', 'Commerce'] }, - { id: 3, key: 'costs', category: 'guides', tags: ['Optimisation', 'Costs', 'Strategy'] }, - { id: 4, key: 'ports', category: 'industry', tags: ['Ports', 'Europe', 'Stats'] }, - { id: 5, key: 'funding', category: 'news', tags: ['Funding', 'Growth', 'Xpeditis'] }, - { id: 6, key: 'green', category: 'industry', tags: ['Environment', 'Decarbonization', 'Sustainability'] }, - { id: 7, key: 'api', category: 'technology', tags: ['API', 'Integration', 'Technical'] }, - { id: 8, key: 'documents', category: 'guides', tags: ['Documents', 'Export', 'Customs'] }, -]; +const containerVariants = { + hidden: { opacity: 0, y: 50 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.6, staggerChildren: 0.1 }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.5 } }, +}; + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('fr-FR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} export default function BlogPage() { const t = useTranslations('marketing.blog'); const [selectedCategory, setSelectedCategory] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); + const [posts, setPosts] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [featuredPost, setFeaturedPost] = useState(null); const heroRef = useRef(null); const articlesRef = useRef(null); @@ -54,44 +77,50 @@ export default function BlogPage() { const isArticlesInView = useInView(articlesRef, { once: true }); const isCategoriesInView = useInView(categoriesRef, { once: true }); - const filteredArticles = ARTICLES.filter((article) => { - const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory; - const title = t(`articles.${article.key}.title` as any); - const excerpt = t(`articles.${article.key}.excerpt` as any); - const searchMatch = - searchQuery === '' || - title.toLowerCase().includes(searchQuery.toLowerCase()) || - excerpt.toLowerCase().includes(searchQuery.toLowerCase()); - return categoryMatch && searchMatch; - }); + useEffect(() => { + let cancelled = false; - const containerVariants = { - hidden: { opacity: 0, y: 50 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.6, - staggerChildren: 0.1, - }, - }, - }; + const load = async () => { + setLoading(true); + try { + const res = await getBlogPosts({ + category: selectedCategory !== 'all' ? selectedCategory : undefined, + search: searchQuery || undefined, + limit: 50, + }); + if (!cancelled) { + const featured = res.posts.find(p => p.isFeatured) ?? res.posts[0] ?? null; + const rest = res.posts.filter(p => p !== featured); + setFeaturedPost(featured); + setPosts(rest); + setTotal(res.total); + } + } catch { + if (!cancelled) { + setPosts([]); + setFeaturedPost(null); + setTotal(0); + } + } finally { + if (!cancelled) setLoading(false); + } + }; - const itemVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { duration: 0.5 }, - }, - }; + load(); + return () => { + cancelled = true; + }; + }, [selectedCategory, searchQuery]); return (
{/* Hero Section */} -
+
@@ -126,7 +155,6 @@ export default function BlogPage() { {t('intro')}

- {/* Search Bar */} setSearchQuery(e.target.value)} + onChange={e => setSearchQuery(e.target.value)} className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none" />
@@ -147,7 +175,6 @@ export default function BlogPage() {
- {/* Wave */}
- {CATEGORIES.map((category) => { + {CATEGORIES.map(category => { const IconComponent = category.icon; const isActive = selectedCategory === category.key; return ( @@ -190,64 +217,71 @@ export default function BlogPage() {
{/* Featured Article */} -
-
- - -
-
-
- -
- -
-
-
- - {t('featuredBadge')} - - - {t('categories.technology')} - + {!loading && featuredPost && ( +
+
+ + +
+
+ {featuredPost.coverImageUrl ? ( +
+ ) : ( +
+
+ )} -

- {t('featured.title')} -

- -

{t('featured.excerpt')}

- -
-
- - {t('featured.author')} +
+
+
+ + {t('featuredBadge')} + + + {t(`categories.${featuredPost.category}`)} +
-
- - {t('featured.date')} -
-
- - {t('featured.readTime')} -
-
-
- {t('readArticle')} - +

+ {featuredPost.title} +

+ +

{featuredPost.excerpt}

+ +
+
+ + {featuredPost.authorName} +
+ {featuredPost.publishedAt && ( +
+ + {formatDate(featuredPost.publishedAt)} +
+ )} +
+ +
+ {t('readArticle')} + +
-
- - -
-
+ + +
+
+ )} {/* Articles Grid */}
@@ -259,10 +293,26 @@ export default function BlogPage() { className="flex items-center justify-between mb-12" >

{t('allTitle')}

- {t('articlesCount', { count: filteredArticles.length })} + {t('articlesCount', { count: posts.length })} - {filteredArticles.length === 0 ? ( + {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : posts.length === 0 ? (

{t('noResults.title')}

@@ -275,30 +325,36 @@ export default function BlogPage() { animate={isArticlesInView ? 'visible' : 'hidden'} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" > - {filteredArticles.map((article) => ( - - + {posts.map(post => ( + +
-
- +
+ {post.coverImageUrl ? ( + {post.title} + ) : ( + + )}
- {t(`categories.${article.category}`)} + {t(`categories.${post.category}`)}

- {t(`articles.${article.key}.title` as any)} + {post.title}

-

- {t(`articles.${article.key}.excerpt` as any)} -

+

{post.excerpt}

- {article.tags.map((tag) => ( + {post.tags.map(tag => (
- {t(`articles.${article.key}.author` as any)} + {post.authorName}
-
- {t(`articles.${article.key}.date` as any)} - + {post.publishedAt && ( +
- {t(`articles.${article.key}.readTime` as any)} - -
+ {formatDate(post.publishedAt)} +
+ )}
@@ -330,21 +385,6 @@ export default function BlogPage() { ))} )} - - {/* Load More */} - {filteredArticles.length > 0 && ( - - - - )}
@@ -357,12 +397,8 @@ export default function BlogPage() { viewport={{ once: true }} transition={{ duration: 0.8 }} > -

- {t('newsletter.title')} -

-

- {t('newsletter.body')} -

+

{t('newsletter.title')}

+

{t('newsletter.body')}

-

- {t('newsletter.disclaimer')} -

+

{t('newsletter.disclaimer')}

diff --git a/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx b/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx index 54be80d..6927dfe 100644 --- a/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx +++ b/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx @@ -81,9 +81,7 @@ export default function BookingConfirmPage() { /> -

- {t('errorTitle')} -

+

{t('errorTitle')}

{error}

@@ -98,9 +96,7 @@ export default function BookingConfirmPage() { -

- {t('errorContact')} -

+

{t('errorContact')}

); @@ -134,22 +130,14 @@ export default function BookingConfirmPage() {
-

- {t('successTitle')} -

-

- {t('successHeadline')} -

-

- {t('successBody')} -

+

{t('successTitle')}

+

{t('successHeadline')}

+

{t('successBody')}

{/* Booking Summary */}
-

- {t('summaryTitle')} -

+

{t('summaryTitle')}

@@ -197,14 +185,12 @@ export default function BookingConfirmPage() {
{booking.primaryCurrency === 'USD' ? `$${booking.priceUSD.toLocaleString()}` - : `€${booking.priceEUR.toLocaleString()}` - } + : `€${booking.priceEUR.toLocaleString()}`}
{booking.primaryCurrency === 'USD' ? `(€${booking.priceEUR.toLocaleString()})` - : `($${booking.priceUSD.toLocaleString()})` - } + : `($${booking.priceUSD.toLocaleString()})`}
@@ -222,7 +208,12 @@ export default function BookingConfirmPage() {

- + {t('nextStepsTitle')}

@@ -239,10 +230,23 @@ export default function BookingConfirmPage() {

{t('labels.documents')}

{booking.documents.map((doc, index) => ( -
+
- - + +

{doc.fileName}

diff --git a/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx b/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx index f4b8a2a..228ebe7 100644 --- a/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx +++ b/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx @@ -89,9 +89,7 @@ export default function BookingRejectPage() { />
-

- {t('errorTitle')} -

+

{t('errorTitle')}

{error}

@@ -106,9 +104,7 @@ export default function BookingRejectPage() {
-

- {t('errorContact')} -

+

{t('errorContact')}

); @@ -137,21 +133,13 @@ export default function BookingRejectPage() {
-

- {t('rejectedTitle')} -

-

- {t('rejectedHeadline')} -

-

- {t('rejectedBody')} -

+

{t('rejectedTitle')}

+

{t('rejectedHeadline')}

+

{t('rejectedBody')}

-

- {t('summaryTitle')} -

+

{t('summaryTitle')}

@@ -181,8 +169,7 @@ export default function BookingRejectPage() { {booking.primaryCurrency === 'USD' ? `$${booking.priceUSD.toLocaleString()}` - : `€${booking.priceEUR.toLocaleString()}` - } + : `€${booking.priceEUR.toLocaleString()}`}
@@ -200,13 +187,16 @@ export default function BookingRejectPage() {

- + {t('infoTitle')}

-

- {t('infoBody')} -

+

{t('infoBody')}

@@ -259,12 +249,8 @@ export default function BookingRejectPage() { />
-

- {t('formTitle')} -

-

- {t('formIntro')} -

+

{t('formTitle')}

+

{t('formIntro')}

@@ -275,8 +261,18 @@ export default function BookingRejectPage() { >
{t('addReason')} - - + +
@@ -289,18 +285,14 @@ export default function BookingRejectPage() { id="reason" rows={4} value={reason} - onChange={(e) => setReason(e.target.value)} + onChange={e => setReason(e.target.value)} placeholder={t('reasonPlaceholder')} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none" maxLength={500} />
-

- {t('reasonHint')} -

- - {reason.length}/500 - +

{t('reasonHint')}

+ {reason.length}/500
)} @@ -320,16 +312,36 @@ export default function BookingRejectPage() { > {isRejecting ? ( <> - - - + + + {t('submitting')} ) : ( <> - + {t('submit')} @@ -344,9 +356,7 @@ export default function BookingRejectPage() { -

- {t('helpText')} -

+

{t('helpText')}

); diff --git a/apps/frontend/app/[locale]/careers/page.tsx b/apps/frontend/app/[locale]/careers/page.tsx index 1af1e75..5437892 100644 --- a/apps/frontend/app/[locale]/careers/page.tsx +++ b/apps/frontend/app/[locale]/careers/page.tsx @@ -64,15 +64,76 @@ type JobRecord = { }; const JOBS: JobRecord[] = [ - { id: 1, key: 'frontend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '65K - 85K €', icon: Code }, - { id: 2, key: 'backend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '55K - 75K €', icon: Code }, - { id: 3, key: 'pm', department: 'Product', location: 'Paris', type: 'CDI', remote: true, salary: '60K - 80K €', icon: LineChart }, - { id: 4, key: 'ae', department: 'Sales', location: 'Rotterdam', type: 'CDI', remote: false, salary: '50K - 70K € + variable', icon: Megaphone }, - { id: 5, key: 'csm', department: 'Customer Success', location: 'Paris', type: 'CDI', remote: true, salary: '45K - 60K €', icon: Headphones }, - { id: 6, key: 'data', department: 'Data', location: 'Hambourg', type: 'CDI', remote: true, salary: '50K - 65K €', icon: LineChart }, + { + id: 1, + key: 'frontend', + department: 'Engineering', + location: 'Paris', + type: 'CDI', + remote: true, + salary: '65K - 85K €', + icon: Code, + }, + { + id: 2, + key: 'backend', + department: 'Engineering', + location: 'Paris', + type: 'CDI', + remote: true, + salary: '55K - 75K €', + icon: Code, + }, + { + id: 3, + key: 'pm', + department: 'Product', + location: 'Paris', + type: 'CDI', + remote: true, + salary: '60K - 80K €', + icon: LineChart, + }, + { + id: 4, + key: 'ae', + department: 'Sales', + location: 'Rotterdam', + type: 'CDI', + remote: false, + salary: '50K - 70K € + variable', + icon: Megaphone, + }, + { + id: 5, + key: 'csm', + department: 'Customer Success', + location: 'Paris', + type: 'CDI', + remote: true, + salary: '45K - 60K €', + icon: Headphones, + }, + { + id: 6, + key: 'data', + department: 'Data', + location: 'Hambourg', + type: 'CDI', + remote: true, + salary: '50K - 65K €', + icon: LineChart, + }, ]; -const DEPARTMENT_VALUES: DepartmentValue[] = ['all', 'Engineering', 'Product', 'Sales', 'Customer Success', 'Data']; +const DEPARTMENT_VALUES: DepartmentValue[] = [ + 'all', + 'Engineering', + 'Product', + 'Sales', + 'Customer Success', + 'Data', +]; const LOCATION_VALUES: LocationValue[] = ['all', 'Paris', 'Rotterdam', 'Hambourg']; const JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const; @@ -92,7 +153,7 @@ export default function CareersPage() { const isJobsInView = useInView(jobsRef, { once: true }); const isCultureInView = useInView(cultureRef, { once: true }); - const filteredJobs = JOBS.filter((job) => { + const filteredJobs = JOBS.filter(job => { const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment; const locationMatch = selectedLocation === 'all' || job.location === selectedLocation; return departmentMatch && locationMatch; @@ -124,7 +185,10 @@ export default function CareersPage() { {/* Hero Section */} -
+
@@ -221,9 +285,7 @@ export default function CareersPage() {

{t('benefitsTitle')}

-

- {t('benefitsSubtitle')} -

+

{t('benefitsSubtitle')}

- {BENEFITS.map((benefit) => { + {BENEFITS.map(benefit => { const IconComponent = benefit.icon; return (
-

{t(`benefits.${benefit.key}.title`)}

+

+ {t(`benefits.${benefit.key}.title`)} +

{t(`benefits.${benefit.key}.description`)}

); @@ -254,7 +318,10 @@ export default function CareersPage() {
{/* Culture Section */} -
+
{t('cultureTitle')} -

- {t('cultureBody')} -

+

{t('cultureBody')}

    {CULTURE_ITEMS.map((itemKey, index) => ( - {[1, 2, 3, 4].map((i) => ( + {[1, 2, 3, 4].map(i => (
    {t('jobsTitle')} -

    - {t('jobsSubtitle')} -

    +

    {t('jobsSubtitle')}

    {/* Filters */} @@ -332,12 +395,14 @@ export default function CareersPage() {
    @@ -346,10 +411,10 @@ export default function CareersPage() {
    -