diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index ce43f91..2240c5f 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -60,6 +60,7 @@ "react-leaflet": "^5.0.0", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1", + "sharp": "^0.35.3", "socket.io": "^4.8.1", "stripe": "^14.14.0", "typeorm": "^0.3.17", @@ -1444,6 +1445,16 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -1696,6 +1707,554 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@ioredis/as-callback": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@ioredis/as-callback/-/as-callback-3.0.0.tgz", @@ -12858,9 +13417,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -12972,6 +13531,55 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index c83f196..a7d1e84 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -76,6 +76,7 @@ "react-leaflet": "^5.0.0", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1", + "sharp": "^0.35.3", "socket.io": "^4.8.1", "stripe": "^14.14.0", "typeorm": "^0.3.17", diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index 040ff1b..17f20f8 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -70,6 +70,12 @@ export class AuthService { organizationData?: RegisterOrganizationDto, invitationRole?: string ): Promise<{ accessToken: string; refreshToken: string; user: any }> { + // Emails are stored lowercase (enforced by the chk_users_email DB CHECK + // constraint), so normalize before any lookup or insert. Without this, an + // address containing uppercase letters fails the user INSERT after the + // organization has already been created — leaving an orphaned organization. + email = email.trim().toLowerCase(); + this.logger.log(`Registering new user: ${email}`); const existingUser = await this.userRepository.findByEmail(email); @@ -90,6 +96,9 @@ export class AuthService { // 2. If organizationData is provided (new user), create a new organization // 3. Otherwise, use default organization const finalOrganizationId = await this.resolveOrganizationId(organizationId, organizationData); + // Track whether a brand-new organization was created for this registration, + // so it can be rolled back if the subsequent user creation fails. + const createdNewOrg = !organizationId && !!organizationData; // Determine role: // - If invitation role is provided (invited user), use it @@ -121,7 +130,28 @@ export class AuthService { role: userRole, }); - const savedUser = await this.userRepository.save(user); + let savedUser: User; + try { + savedUser = await this.userRepository.save(user); + } catch (err) { + // The organization is created before the user (non-atomic flow). If the + // user INSERT fails, roll the organization back so a retry isn't blocked + // by an orphaned organization ("name already exists"). + if (createdNewOrg) { + try { + await this.organizationRepository.deleteById(finalOrganizationId); + this.logger.warn( + `Rolled back orphaned organization ${finalOrganizationId} after failed user creation` + ); + } catch (cleanupErr) { + this.logger.error( + `Failed to roll back organization ${finalOrganizationId}`, + cleanupErr as Error + ); + } + } + throw err; + } // Allocate a license for the new user try { @@ -158,6 +188,7 @@ export class AuthService { password: string, rememberMe = false ): Promise<{ accessToken: string; refreshToken: string; user: any }> { + email = email.trim().toLowerCase(); this.logger.log(`Login attempt for: ${email}`); const user = await this.userRepository.findByEmail(email); diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index 5c8393c..939da63 100644 --- a/apps/backend/src/application/controllers/admin.controller.ts +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -24,6 +24,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import { v4 as uuidv4 } from 'uuid'; import * as path from 'path'; +import sharp from 'sharp'; import { ApiTags, ApiOperation, @@ -756,6 +757,12 @@ export class AdminController { routeDescription: booking.getRouteDescription(), isExpired: booking.isExpired(), price: booking.getPriceInCurrency(primaryCurrency), + commissionRate: booking.commissionRate, + commissionAmountEur: booking.commissionAmountEur, + freightTotal: booking.freightTotal, + freightCurrency: booking.freightCurrency, + fobTotal: booking.fobTotal, + fobCurrency: booking.fobCurrency, }; } @@ -995,17 +1002,79 @@ export class AdminController { return { url: `/api/v1/blog/images/${filename}`, filename }; } + @Post('blog/cover-images') + @UseInterceptors( + FileInterceptor('image', { + storage: memoryStorage(), + limits: { fileSize: MAX_IMAGE_SIZE }, + fileFilter: (_req, file, cb) => { + // SVG cannot be raster-cropped; restrict to raster formats. + if (['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.mimetype)) { + cb(null, true); + } else { + cb(new BadRequestException('Only JPG, PNG, WebP or GIF images are allowed'), false); + } + }, + }) + ) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'Upload and auto-crop a blog cover image (Admin only)', + description: + 'Resizes and crops the image to the public 16:9 card ratio (1280x720) so covers are never distorted or cut off on the public site.', + }) + @ApiResponse({ + status: 201, + schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } }, + }) + async uploadBlogCoverImage( + @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 cover image: ${file.originalname}`); + + // Crop to the public blog card ratio (16:9). "attention" focuses on the most + // salient region so important content is preserved. + let processed: Buffer; + try { + processed = await sharp(file.buffer) + .resize(1280, 720, { fit: 'cover', position: sharp.strategy.attention }) + .webp({ quality: 82 }) + .toBuffer(); + } catch (err: any) { + this.logger.error(`Failed to process cover image: ${err?.message}`); + throw new BadRequestException("Impossible de traiter l'image"); + } + + const filename = `${uuidv4()}-cover.webp`; + const key = `blog-images/${filename}`; + + await this.storage.upload({ + bucket: BLOG_IMAGES_BUCKET, + key, + body: processed, + contentType: 'image/webp', + }); + + this.logger.log(`[ADMIN] Blog cover 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: 'trashed', required: false, type: Boolean }) @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('trashed') trashed?: string, @Query('limit') limit = 50, @Query('offset') offset = 0, @CurrentUser() user?: UserPayload @@ -1015,6 +1084,7 @@ export class AdminController { status, category, search, + trashed: trashed === 'true', limit: Number(limit), offset: Number(offset), }); @@ -1045,15 +1115,45 @@ export class AdminController { @Delete('blog/:id') @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete a blog post (Admin only)' }) + @ApiOperation({ summary: 'Move a blog post to the trash (Admin only)' }) async deleteBlogPost( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload ): Promise { - this.logger.log(`[ADMIN: ${user.email}] Deleting blog post: ${id}`); + this.logger.log(`[ADMIN: ${user.email}] Trashing blog post: ${id}`); await this.blogService.deletePost(id); } + @Post('blog/:id/restore') + @ApiOperation({ summary: 'Restore a blog post from the trash (Admin only)' }) + async restoreBlogPost(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { + this.logger.log(`[ADMIN: ${user.email}] Restoring blog post: ${id}`); + const post = await this.blogService.restorePost(id); + return this.mapBlogPostToDto(post); + } + + @Delete('blog/:id/permanent') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Permanently delete a blog post (Admin only)' }) + async permanentlyDeleteBlogPost( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[ADMIN: ${user.email}] Permanently deleting blog post: ${id}`); + await this.blogService.permanentlyDeletePost(id); + } + + @Post('blog/:id/duplicate') + @ApiOperation({ summary: 'Duplicate a blog post as a new draft (Admin only)' }) + async duplicateBlogPost( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload + ) { + this.logger.log(`[ADMIN: ${user.email}] Duplicating blog post: ${id}`); + const post = await this.blogService.duplicatePost(id); + return this.mapBlogPostToDto(post); + } + private mapBlogPostToDto(post: BlogPost) { return { id: post.id, @@ -1072,6 +1172,10 @@ export class AdminController { metaDescription: post.metaDescription, primaryKeyword: post.primaryKeyword, secondaryKeywords: post.secondaryKeywords, + aiSummary: post.aiSummary, + faq: post.faq, + keyTakeaways: post.keyTakeaways, + aiEntities: post.aiEntities, 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 index 3a36562..663516e 100644 --- a/apps/backend/src/application/controllers/blog.controller.ts +++ b/apps/backend/src/application/controllers/blog.controller.ts @@ -117,7 +117,7 @@ export class BlogController { slug: post.slug, excerpt: post.excerpt, content: post.content, - coverImageUrl: post.coverImageUrl, + coverImageUrl: post.coverImageUrl ?? undefined, category: post.category, tags: post.tags, authorName: post.authorName, @@ -128,6 +128,10 @@ export class BlogController { metaDescription: post.metaDescription, primaryKeyword: post.primaryKeyword, secondaryKeywords: post.secondaryKeywords, + aiSummary: post.aiSummary ?? undefined, + faq: post.faq, + keyTakeaways: post.keyTakeaways, + aiEntities: post.aiEntities, createdAt: post.createdAt, updatedAt: post.updatedAt, }; diff --git a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts index 1ef266a..32c59b9 100644 --- a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts +++ b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts @@ -1,4 +1,5 @@ -import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common'; +import { Controller, Get, Post, Param, Query, Body, Res, StreamableFile } from '@nestjs/common'; +import { Response } from 'express'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger'; import { Public } from '../decorators/public.decorator'; import { CsvBookingService } from '../services/csv-booking.service'; @@ -173,4 +174,75 @@ export class CsvBookingActionsController { async getBookingDocuments(@Param('token') token: string): Promise { return this.csvBookingService.getDocumentsForCarrier(token); } + + /** + * Download a single booking document, streamed through the API (PUBLIC - token-based). + * + * Password-protected bookings must use POST with the password in the body. + * + * POST /api/v1/csv-booking-actions/documents/:token/:documentId/download + */ + @Public() + @Post('documents/:token/:documentId/download') + @ApiOperation({ + summary: 'Download a booking document with password (public)', + description: + 'Streams a single booking document to the carrier. Applies the same access rules as the documents list (booking accepted + password when protected).', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiParam({ name: 'documentId', description: 'Document ID' }) + @ApiBody({ type: VerifyDocumentAccessDto }) + @ApiResponse({ status: 200, description: 'Document streamed successfully.' }) + @ApiResponse({ status: 401, description: 'Invalid or missing password' }) + @ApiResponse({ status: 404, description: 'Booking or document not found' }) + async downloadBookingDocumentWithPassword( + @Param('token') token: string, + @Param('documentId') documentId: string, + @Body() dto: VerifyDocumentAccessDto, + @Res({ passthrough: true }) res: Response + ): Promise { + return this.streamDocument(token, documentId, dto?.password, res); + } + + /** + * Download a single booking document (PUBLIC - token-based) - Legacy without password. + * + * GET /api/v1/csv-booking-actions/documents/:token/:documentId/download + */ + @Public() + @Get('documents/:token/:documentId/download') + @ApiOperation({ + summary: 'Download a booking document (public) - Legacy', + description: + 'Streams a single booking document for bookings without password protection. Protected bookings must use the POST variant.', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiParam({ name: 'documentId', description: 'Document ID' }) + @ApiResponse({ status: 200, description: 'Document streamed successfully.' }) + @ApiResponse({ status: 401, description: 'Password required for this booking' }) + @ApiResponse({ status: 404, description: 'Booking or document not found' }) + async downloadBookingDocument( + @Param('token') token: string, + @Param('documentId') documentId: string, + @Res({ passthrough: true }) res: Response + ): Promise { + return this.streamDocument(token, documentId, undefined, res); + } + + private async streamDocument( + token: string, + documentId: string, + password: string | undefined, + res: Response + ): Promise { + const { buffer, fileName, mimeType } = await this.csvBookingService.streamDocumentForCarrier( + token, + documentId, + password + ); + + res.setHeader('Content-Type', mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); + return new StreamableFile(buffer); + } } diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index 07a19ca..aba25fc 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -47,6 +47,8 @@ import { CsvBookingResponseDto, CsvBookingListResponseDto, CsvBookingStatsDto, + UpdateCsvBookingDetailsDto, + UpdateCsvBookingRateDto, } from '../dto/csv-booking.dto'; /** @@ -166,12 +168,12 @@ export class CsvBookingsController { // ADMIN users bypass shipment limits if (req.user.role !== 'ADMIN') { - // Check shipment limit (Bronze plan = 12/year) + // Check the paid-reservation limit (free/Bronze plan = 5 paid shipments/year) const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); const maxShipments = subscription.plan.maxShipmentsPerYear; if (maxShipments !== -1) { const currentYear = new Date().getFullYear(); - const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( + const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear( organizationId, currentYear ); @@ -226,6 +228,35 @@ export class CsvBookingsController { return await this.csvBookingService.getUserBookings(userId, page, limit); } + /** + * Get the reservation (paid shipment) quota for the current organization. + * + * Uses the organization's real plan (not the ADMIN PLATINIUM override) and + * counts only PAID shipments this year, so the UI can show an upgrade prompt. + * + * GET /api/v1/csv-bookings/reservation-quota + */ + @Get('reservation-quota') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get the paid-reservation quota for the current organization' }) + @ApiResponse({ status: 200, description: 'Quota retrieved successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getReservationQuota( + @Request() req: any + ): Promise<{ max: number; used: number; unlimited: boolean; limitReached: boolean }> { + const organizationId = req.user.organizationId; + const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); + const max = subscription.plan.maxShipmentsPerYear; + const unlimited = max === -1; + const currentYear = new Date().getFullYear(); + const used = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear( + organizationId, + currentYear + ); + return { max, used, unlimited, limitReached: !unlimited && used >= max }; + } + /** * Get booking statistics for user * @@ -560,6 +591,68 @@ export class CsvBookingsController { return await this.csvBookingService.cancelBooking(id, userId); } + /** + * Update booking cargo details before payment + * + * PATCH /api/v1/csv-bookings/:id/details + */ + @Patch(':id/details') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update booking details before payment', + description: + 'Edit cargo characteristics (volume, weight, pallets, notes) of a booking awaiting payment. Only the owner can edit, and only while the booking is PENDING_PAYMENT.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Booking details updated successfully', + type: CsvBookingResponseDto, + }) + @ApiResponse({ status: 400, description: 'Booking cannot be edited (invalid status or values)' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async updateBookingDetails( + @Param('id') id: string, + @Body() dto: UpdateCsvBookingDetailsDto, + @Request() req: any + ): Promise { + const userId = req.user.id; + return await this.csvBookingService.updateBookingDetails(id, userId, dto); + } + + /** + * Re-apply a full rate selection before payment + * + * PATCH /api/v1/csv-bookings/:id/rate + */ + @Patch(':id/rate') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update booking rate/route before payment', + description: + 'Re-apply a rate selection (carrier, route, container, transit, cargo, price) to a PENDING_PAYMENT booking. Only the owner can edit.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Booking rate updated successfully', + type: CsvBookingResponseDto, + }) + @ApiResponse({ status: 400, description: 'Booking cannot be edited (invalid status or values)' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async updateBookingRate( + @Param('id') id: string, + @Body() dto: UpdateCsvBookingRateDto, + @Request() req: any + ): Promise { + const userId = req.user.id; + return await this.csvBookingService.updateBookingRate(id, userId, dto); + } + /** * Add documents to an existing booking * diff --git a/apps/backend/src/application/controllers/organizations.controller.ts b/apps/backend/src/application/controllers/organizations.controller.ts index f20eeb7..bb272d4 100644 --- a/apps/backend/src/application/controllers/organizations.controller.ts +++ b/apps/backend/src/application/controllers/organizations.controller.ts @@ -42,10 +42,7 @@ import { ORGANIZATION_REPOSITORY, } from '@domain/ports/out/organization.repository'; import { Organization, OrganizationType } from '@domain/entities/organization.entity'; -import { - NotificationType, - NotificationPriority, -} from '@domain/entities/notification.entity'; +import { NotificationType, NotificationPriority } from '@domain/entities/notification.entity'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; @@ -71,7 +68,8 @@ export class OrganizationsController { private readonly logger = new Logger(OrganizationsController.name); constructor( - @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, + @Inject(ORGANIZATION_REPOSITORY) + private readonly organizationRepository: OrganizationRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, private readonly notificationService: NotificationService ) {} @@ -320,9 +318,7 @@ export class OrganizationsController { }) @ApiResponse({ status: 200, description: 'Approval request sent to admins' }) @ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' }) - async requestSiretApproval( - @CurrentUser() user: UserPayload - ): Promise<{ message: string }> { + async requestSiretApproval(@CurrentUser() user: UserPayload): Promise<{ message: string }> { const organization = await this.organizationRepository.findById(user.organizationId); if (!organization) { throw new NotFoundException('Organization not found'); diff --git a/apps/backend/src/application/dto/blog-post.dto.ts b/apps/backend/src/application/dto/blog-post.dto.ts index 129db6c..ef3f78f 100644 --- a/apps/backend/src/application/dto/blog-post.dto.ts +++ b/apps/backend/src/application/dto/blog-post.dto.ts @@ -10,12 +10,64 @@ import { MaxLength, MinLength, Matches, + ValidateIf, + ValidateNested, } from 'class-validator'; -import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity'; +import { Type } from 'class-transformer'; +import { + BlogPostStatus, + type BlogPostCategory, + type BlogFaqItem, +} from '@domain/entities/blog-post.entity'; const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news']; -export class CreateBlogPostDto { +export class BlogFaqItemDto implements BlogFaqItem { + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(500) + question: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(4000) + answer: string; +} + +/** + * GEO (Generative Engine Optimisation) fields, shared by create/update DTOs. + */ +export class BlogGeoFields { + @ApiPropertyOptional({ description: 'AI summary / TL;DR of the article' }) + @IsOptional() + @ValidateIf((_, value) => value !== null) + @IsString() + @MaxLength(2000) + aiSummary?: string | null; + + @ApiPropertyOptional({ type: [BlogFaqItemDto], description: 'FAQ (question/answer pairs)' }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BlogFaqItemDto) + faq?: BlogFaqItemDto[]; + + @ApiPropertyOptional({ type: [String], description: 'Key takeaways' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + keyTakeaways?: string[]; + + @ApiPropertyOptional({ type: [String], description: 'Related entities / topics' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + aiEntities?: string[]; +} + +export class CreateBlogPostDto extends BlogGeoFields { @ApiProperty() @IsString() @IsNotEmpty() @@ -94,7 +146,7 @@ export class CreateBlogPostDto { secondaryKeywords?: string[]; } -export class UpdateBlogPostDto { +export class UpdateBlogPostDto extends BlogGeoFields { @ApiPropertyOptional() @IsOptional() @IsString() @@ -121,11 +173,15 @@ export class UpdateBlogPostDto { @IsString() content?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ + nullable: true, + description: 'Cover image URL. Pass null to remove the current cover image.', + }) @IsOptional() + @ValidateIf((_, value) => value !== null) @IsString() @MaxLength(500) - coverImageUrl?: string; + coverImageUrl?: string | null; @ApiPropertyOptional({ enum: CATEGORIES }) @IsOptional() @@ -201,6 +257,10 @@ export class BlogPostResponseDto { @ApiPropertyOptional() metaDescription?: string; @ApiPropertyOptional() primaryKeyword?: string; @ApiProperty({ type: [String] }) secondaryKeywords: string[]; + @ApiPropertyOptional() aiSummary?: string | null; + @ApiProperty({ type: [BlogFaqItemDto] }) faq: BlogFaqItemDto[]; + @ApiProperty({ type: [String] }) keyTakeaways: string[]; + @ApiProperty({ type: [String] }) aiEntities: string[]; @ApiProperty() createdAt: Date; @ApiProperty() updatedAt: Date; } diff --git a/apps/backend/src/application/dto/csv-booking.dto.ts b/apps/backend/src/application/dto/csv-booking.dto.ts index d32f5f8..775f562 100644 --- a/apps/backend/src/application/dto/csv-booking.dto.ts +++ b/apps/backend/src/application/dto/csv-booking.dto.ts @@ -6,6 +6,7 @@ import { Min, IsOptional, IsEnum, + IsObject, MinLength, MaxLength, } from 'class-validator'; @@ -104,6 +105,44 @@ export class CreateCsvBookingDto { @IsEnum(['USD', 'EUR']) primaryCurrency: string; + @ApiPropertyOptional({ + description: 'Freight charge total (in freightCurrency)', + example: 420.0, + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + freightTotal?: number; + + @ApiPropertyOptional({ + description: 'Currency of the freight charge', + enum: ['USD', 'EUR'], + example: 'USD', + }) + @IsOptional() + @IsEnum(['USD', 'EUR']) + freightCurrency?: string; + + @ApiPropertyOptional({ + description: 'FOB charges total (in fobCurrency)', + example: 245.0, + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + fobTotal?: number; + + @ApiPropertyOptional({ + description: 'Currency of the FOB charges', + enum: ['USD', 'EUR'], + example: 'EUR', + }) + @IsOptional() + @IsEnum(['USD', 'EUR']) + fobCurrency?: string; + @ApiProperty({ description: 'Transit time in days', example: 28, @@ -131,10 +170,203 @@ export class CreateCsvBookingDto { @MaxLength(1000) notes?: string; + @ApiPropertyOptional({ + description: + 'Selected options & services as a JSON string (multipart), e.g. {"insurance":true}', + example: '{"insurance":true,"customsStop":true}', + }) + @IsOptional() + @IsString() + @MaxLength(2000) + options?: string; + // Documents will be handled via file upload interceptor // Not included in DTO validation but processed separately } +/** + * Update CSV Booking Details DTO + * + * Editable cargo characteristics before payment. Carrier, route and price are + * derived from the selected rate and cannot be changed here. + */ +export class UpdateCsvBookingDetailsDto { + @ApiPropertyOptional({ description: 'Total volume in cubic meters (CBM)', example: 12.5 }) + @IsOptional() + @IsNumber() + @Min(0.01) + volumeCBM?: number; + + @ApiPropertyOptional({ description: 'Total weight in kilograms', example: 1500 }) + @IsOptional() + @IsNumber() + @Min(0.01) + weightKG?: number; + + @ApiPropertyOptional({ description: 'Number of pallets', example: 5 }) + @IsOptional() + @IsNumber() + @Min(0) + palletCount?: number; + + @ApiPropertyOptional({ description: 'Additional notes', example: 'Please handle with care' }) + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; + + // Recomputed pricing (sent when cargo details change). Optional; when omitted + // the price is left unchanged. + @ApiPropertyOptional({ description: 'Recomputed total price in USD' }) + @IsOptional() + @IsNumber() + @Min(0) + priceUSD?: number; + + @ApiPropertyOptional({ description: 'Recomputed total price in EUR' }) + @IsOptional() + @IsNumber() + @Min(0) + priceEUR?: number; + + @ApiPropertyOptional({ description: 'Primary currency (USD/EUR)' }) + @IsOptional() + @IsString() + @MaxLength(3) + primaryCurrency?: string; + + @ApiPropertyOptional({ description: 'Recomputed freight total' }) + @IsOptional() + @IsNumber() + @Min(0) + freightTotal?: number; + + @ApiPropertyOptional({ description: 'Freight currency' }) + @IsOptional() + @IsString() + @MaxLength(3) + freightCurrency?: string; + + @ApiPropertyOptional({ description: 'Recomputed FOB total' }) + @IsOptional() + @IsNumber() + @Min(0) + fobTotal?: number; + + @ApiPropertyOptional({ description: 'FOB currency' }) + @IsOptional() + @IsString() + @MaxLength(3) + fobCurrency?: string; +} + +/** + * Update CSV Booking Rate DTO + * + * Full re-selection of a rate before payment (carrier + route + container + + * transit + cargo + price). Used when the user re-runs the search and picks a + * (possibly different) rate for a PENDING_PAYMENT booking. + */ +export class UpdateCsvBookingRateDto { + @ApiProperty({ example: 'SSC Consolidation' }) + @IsString() + @MinLength(2) + @MaxLength(200) + carrierName: string; + + @ApiProperty({ example: 'bookings@example.com' }) + @IsEmail() + carrierEmail: string; + + @ApiProperty({ example: 'FRLIO' }) + @IsString() + @MaxLength(5) + origin: string; + + @ApiProperty({ example: 'AUADL' }) + @IsString() + @MaxLength(5) + destination: string; + + @ApiProperty({ example: 'LCL' }) + @IsString() + @MaxLength(50) + containerType: string; + + @ApiProperty({ example: 46 }) + @IsNumber() + @Min(1) + transitDays: number; + + @ApiProperty({ example: 12.5 }) + @IsNumber() + @Min(0.01) + volumeCBM: number; + + @ApiProperty({ example: 1500 }) + @IsNumber() + @Min(0.01) + weightKG: number; + + @ApiProperty({ example: 5 }) + @IsNumber() + @Min(0) + palletCount: number; + + @ApiProperty({ example: 0 }) + @IsNumber() + @Min(0) + priceUSD: number; + + @ApiProperty({ example: 258 }) + @IsNumber() + @Min(0) + priceEUR: number; + + @ApiProperty({ example: 'EUR' }) + @IsString() + @MaxLength(3) + primaryCurrency: string; + + @ApiPropertyOptional({ example: 150 }) + @IsOptional() + @IsNumber() + @Min(0) + freightTotal?: number; + + @ApiPropertyOptional({ example: 'EUR' }) + @IsOptional() + @IsString() + @MaxLength(3) + freightCurrency?: string; + + @ApiPropertyOptional({ example: 108 }) + @IsOptional() + @IsNumber() + @Min(0) + fobTotal?: number; + + @ApiPropertyOptional({ example: 'EUR' }) + @IsOptional() + @IsString() + @MaxLength(3) + fobCurrency?: string; + + @ApiPropertyOptional({ example: 'Handle with care' }) + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; + + @ApiPropertyOptional({ + description: 'Selected options & services (customs, insurance, DG, handling…)', + example: { insurance: true, customsStop: true }, + }) + @IsOptional() + @IsObject() + options?: Record; +} + /** * Document DTO for response */ @@ -365,6 +597,36 @@ export class CsvBookingResponseDto { example: 313.27, }) commissionAmountEur?: number; + + @ApiPropertyOptional({ + description: 'Freight charge total (in freightCurrency)', + example: 420.0, + }) + freightTotal?: number; + + @ApiPropertyOptional({ + description: 'Currency of the freight charge', + example: 'USD', + }) + freightCurrency?: string; + + @ApiPropertyOptional({ + description: 'FOB charges total (in fobCurrency)', + example: 245.0, + }) + fobTotal?: number; + + @ApiPropertyOptional({ + description: 'Currency of the FOB charges', + example: 'EUR', + }) + fobCurrency?: string; + + @ApiPropertyOptional({ + description: 'Selected options & services (customs, insurance, DG, handling…)', + example: { insurance: true }, + }) + options?: Record; } /** diff --git a/apps/backend/src/application/dto/subscription.dto.ts b/apps/backend/src/application/dto/subscription.dto.ts index 5302528..573c335 100644 --- a/apps/backend/src/application/dto/subscription.dto.ts +++ b/apps/backend/src/application/dto/subscription.dto.ts @@ -207,14 +207,14 @@ export class PlanDetailsDto { maxLicenses: number; @ApiProperty({ - example: 249, + example: 299, description: 'Monthly price in EUR', }) monthlyPriceEur: number; @ApiProperty({ - example: 2739, - description: 'Yearly price in EUR (11 months)', + example: 3289, + description: 'Yearly price in EUR (11 months — 1 month deducted)', }) yearlyPriceEur: number; @@ -225,10 +225,10 @@ export class PlanDetailsDto { maxShipmentsPerYear: number; @ApiProperty({ - example: 3, - description: 'Commission rate percentage on shipments', + example: 10, + description: 'Per-booking fee in EUR (-1 for custom / on quote)', }) - commissionRatePercent: number; + bookingFeeEur: number; @ApiProperty({ example: 'email', diff --git a/apps/backend/src/application/organizations/organizations.module.ts b/apps/backend/src/application/organizations/organizations.module.ts index 8449035..2712e05 100644 --- a/apps/backend/src/application/organizations/organizations.module.ts +++ b/apps/backend/src/application/organizations/organizations.module.ts @@ -9,11 +9,7 @@ import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/ import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity'; @Module({ - imports: [ - TypeOrmModule.forFeature([OrganizationOrmEntity]), - UsersModule, - NotificationsModule, - ], + imports: [TypeOrmModule.forFeature([OrganizationOrmEntity]), UsersModule, NotificationsModule], controllers: [OrganizationsController], providers: [ { diff --git a/apps/backend/src/application/rates/rates.module.ts b/apps/backend/src/application/rates/rates.module.ts index af47ef8..6d98c01 100644 --- a/apps/backend/src/application/rates/rates.module.ts +++ b/apps/backend/src/application/rates/rates.module.ts @@ -50,11 +50,17 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit cache: any, rateQuoteRepo: any, portRepo: any, - carrierRepo: any, + carrierRepo: any ) => { return new RateSearchService(connectors, cache, rateQuoteRepo, portRepo, carrierRepo); }, - inject: ['CarrierConnectors', CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY], + inject: [ + 'CarrierConnectors', + CACHE_PORT, + RATE_QUOTE_REPOSITORY, + PORT_REPOSITORY, + CARRIER_REPOSITORY, + ], }, ], exports: [RATE_QUOTE_REPOSITORY, RateSearchService], diff --git a/apps/backend/src/application/services/blog.service.ts b/apps/backend/src/application/services/blog.service.ts index 5e581d8..d12ece9 100644 --- a/apps/backend/src/application/services/blog.service.ts +++ b/apps/backend/src/application/services/blog.service.ts @@ -58,6 +58,10 @@ export class BlogService implements OnApplicationBootstrap { metaDescription: dto.metaDescription, primaryKeyword: dto.primaryKeyword, secondaryKeywords: dto.secondaryKeywords ?? [], + aiSummary: dto.aiSummary, + faq: dto.faq ?? [], + keyTakeaways: dto.keyTakeaways ?? [], + aiEntities: dto.aiEntities ?? [], }); if (dto.scheduledAt) { @@ -92,6 +96,10 @@ export class BlogService implements OnApplicationBootstrap { metaDescription: dto.metaDescription, primaryKeyword: dto.primaryKeyword, secondaryKeywords: dto.secondaryKeywords, + aiSummary: dto.aiSummary, + faq: dto.faq, + keyTakeaways: dto.keyTakeaways, + aiEntities: dto.aiEntities, }); if (dto.scheduledAt) { @@ -108,11 +116,83 @@ export class BlogService implements OnApplicationBootstrap { return this.blogPostRepository.save(updated); } + /** + * Soft-delete a post: move it to the trash. It can be restored later or + * permanently deleted from the trash. + */ async deletePost(id: string): Promise { - await this.findOrFail(id); + const post = await this.findOrFail(id); + await this.blogPostRepository.save(post.trash()); + } + + /** Restore a post from the trash. */ + async restorePost(id: string): Promise { + const post = await this.findOrFail(id); + return this.blogPostRepository.save(post.restore()); + } + + /** + * Permanently delete a post (hard delete). Also removes its cover image from + * storage when it is a locally hosted blog image (best-effort). + */ + async permanentlyDeletePost(id: string): Promise { + const post = await this.findOrFail(id); + + const coverUrl = post.coverImageUrl; + if (coverUrl) { + const match = coverUrl.match(/\/api\/v1\/blog\/images\/([^/?#]+)$/); + if (match) { + try { + await this.storage.delete({ bucket: BLOG_IMAGES_BUCKET, key: `blog-images/${match[1]}` }); + } catch (err: any) { + this.logger.warn(`Could not delete cover image for post ${id}: ${err?.message}`); + } + } + } + await this.blogPostRepository.delete(id); } + /** + * Duplicate a post as a new draft with a unique slug. + */ + async duplicatePost(id: string): Promise { + const source = await this.findOrFail(id); + const slug = await this.generateUniqueSlug(source.slug); + + const copy = BlogPost.create({ + id: uuidv4(), + title: `${source.title} (copie)`, + slug, + excerpt: source.excerpt, + content: source.content, + coverImageUrl: source.coverImageUrl, + category: source.category, + tags: [...source.tags], + authorName: source.authorName, + metaTitle: source.metaTitle, + metaDescription: source.metaDescription, + primaryKeyword: source.primaryKeyword, + secondaryKeywords: [...source.secondaryKeywords], + aiSummary: source.aiSummary, + faq: source.faq.map(item => ({ ...item })), + keyTakeaways: [...source.keyTakeaways], + aiEntities: [...source.aiEntities], + }); + + return this.blogPostRepository.save(copy); + } + + private async generateUniqueSlug(baseSlug: string): Promise { + let candidate = `${baseSlug}-copie`; + let counter = 2; + while (await this.blogPostRepository.slugExists(candidate)) { + candidate = `${baseSlug}-copie-${counter}`; + counter += 1; + } + return candidate; + } + async getPostById(id: string): Promise { return this.findOrFail(id); } diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index f22516a..3bb52a8 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -31,6 +31,8 @@ import { CsvBookingDocumentDto, CsvBookingListResponseDto, CsvBookingStatsDto, + UpdateCsvBookingDetailsDto, + UpdateCsvBookingRateDto, } from '../dto/csv-booking.dto'; import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; import { SubscriptionService } from './subscription.service'; @@ -160,18 +162,26 @@ export class CsvBookingService { // Upload documents to S3 const documents = await this.uploadDocuments(files, bookingId); - // Calculate commission based on organization's subscription plan - let commissionRate = 5; // default Bronze - let commissionAmountEur = 0; - try { - const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); - commissionRate = subscription.plan.commissionRatePercent; - } catch (error: any) { - this.logger.error(`Failed to get subscription for commission: ${error?.message}`); - } - commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100; + // Flat per-booking service fee (forfait par booking) based on the org's plan. + // A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the + // booking skips the payment gate and the carrier is notified immediately. + const bookingFeeEur = await this.resolveBookingFeeEur(organizationId); + const requiresPayment = bookingFeeEur > 0; + const initialStatus = requiresPayment + ? CsvBookingStatus.PENDING_PAYMENT + : CsvBookingStatus.PENDING; + + // Create domain entity (no email sent yet when a payment is required) + let parsedOptions: Record = {}; + if (dto.options) { + try { + const parsed = JSON.parse(dto.options); + if (parsed && typeof parsed === 'object') parsedOptions = parsed; + } catch { + this.logger.warn(`Ignoring invalid options JSON for booking creation`); + } + } - // Create domain entity in PENDING_PAYMENT status (no email sent yet) const booking = new CsvBooking( bookingId, userId, @@ -188,7 +198,7 @@ export class CsvBookingService { dto.primaryCurrency, dto.transitDays, dto.containerType, - CsvBookingStatus.PENDING_PAYMENT, + initialStatus, documents, confirmationToken, new Date(), @@ -196,8 +206,14 @@ export class CsvBookingService { dto.notes, undefined, bookingNumber, - commissionRate, - commissionAmountEur + undefined, // commissionRate — flat fee, no percentage + requiresPayment ? bookingFeeEur : 0, + undefined, // stripePaymentIntentId — set after payment + dto.freightTotal, + dto.freightCurrency, + dto.fobTotal, + dto.fobCurrency, + parsedOptions ); // Save to database @@ -214,15 +230,37 @@ export class CsvBookingService { } this.logger.log( - `CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}€` + `CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: ${initialStatus}, booking fee: ${bookingFeeEur > 0 ? `${bookingFeeEur}€` : 'none (custom)'}` ); - // NO email sent to carrier yet - will be sent after commission payment - // NO notification yet - will be created after payment confirmation + if (requiresPayment) { + // Carrier email + notification are sent after the booking fee is paid + } else { + // No fee to collect — notify the carrier immediately + await this.sendCarrierBookingRequest(booking, bookingNumber, documentPassword); + await this.notifyBookingRequestSent(booking); + } return this.toResponseDto(savedBooking); } + /** + * Resolve the flat per-booking fee (forfait par booking) for an organization + * from its subscription plan. Returns the plan's bookingFeeEur, or 0 when the + * plan has a custom fee (-1, e.g. Platinium "sur mesure") or on error — such + * bookings are not auto-charged. + */ + private async resolveBookingFeeEur(organizationId: string): Promise { + try { + const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); + const fee = subscription.plan.bookingFeeEur; + return fee > 0 ? fee : 0; + } catch (error: any) { + this.logger.error(`Failed to resolve booking fee: ${error?.message}`); + return 0; + } + } + /** * Create a Stripe Checkout session for commission payment */ @@ -250,7 +288,7 @@ export class CsvBookingService { const commissionAmountEur = booking.commissionAmountEur || 0; if (commissionAmountEur <= 0) { - throw new BadRequestException('Commission amount is invalid'); + throw new BadRequestException('Booking fee amount is invalid'); } const amountCents = Math.round(commissionAmountEur * 100); @@ -261,13 +299,13 @@ export class CsvBookingService { currency: 'eur', customerEmail: userEmail, organizationId: booking.organizationId, - bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()} → ${booking.destination.getValue()}`, + bookingDescription: `Frais de booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()} → ${booking.destination.getValue()}`, successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`, }); this.logger.log( - `Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR` + `Created Stripe booking-fee checkout for booking ${bookingId}: ${amountCents} cents EUR` ); return { @@ -332,7 +370,22 @@ export class CsvBookingService { const bookingNumber = ormBooking?.bookingNumber; const documentPassword = await this.syncDocumentPassword(booking.id); - // NOW send email to carrier + // NOW send email to carrier + notify the user (booking fee has been paid) + await this.sendCarrierBookingRequest(booking, bookingNumber, documentPassword); + await this.notifyBookingRequestSent(booking); + + return this.toResponseDto(updatedBooking); + } + + /** + * Send the booking request email to the carrier. + * Used both when no booking fee is due (at creation) and after the fee is paid. + */ + private async sendCarrierBookingRequest( + booking: CsvBooking, + bookingNumber: string | null | undefined, + documentPassword: string | null | undefined + ): Promise { try { await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { bookingId: booking.id, @@ -359,8 +412,12 @@ export class CsvBookingService { } catch (error: any) { this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); } + } - // Create notification for user + /** + * Notify the user that their booking request has been sent to the carrier. + */ + private async notifyBookingRequestSent(booking: CsvBooking): Promise { try { const notification = Notification.create({ id: uuidv4(), @@ -369,15 +426,13 @@ export class CsvBookingService { type: NotificationType.CSV_BOOKING_REQUEST_SENT, priority: NotificationPriority.MEDIUM, title: 'Booking Request Sent', - message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`, + message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully.`, metadata: { bookingId: booking.id, carrierName: booking.carrierName }, }); await this.notificationRepository.save(notification); } catch (error: any) { this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack); } - - return this.toResponseDto(updatedBooking); } /** @@ -693,20 +748,17 @@ export class CsvBookingService { throw new NotFoundException('Réservation introuvable'); } - // Generate signed URLs for all documents - const documentsWithUrls = await Promise.all( - booking.documents.map(async doc => { - const signedUrl = await this.generateSignedUrlForDocument(doc.filePath); - return { - id: doc.id, - type: doc.type, - fileName: doc.fileName, - mimeType: doc.mimeType, - size: doc.size, - downloadUrl: signedUrl, - }; - }) - ); + // Build proxy download URLs. The file is streamed through the API rather than + // served via a presigned storage URL, whose internal host is unreachable from + // the carrier's browser. The frontend prepends the public API base URL. + const documentsWithUrls = booking.documents.map(doc => ({ + id: doc.id, + type: doc.type, + fileName: doc.fileName, + mimeType: doc.mimeType, + size: doc.size, + downloadUrl: `/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`, + })); const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR'; @@ -753,12 +805,10 @@ export class CsvBookingService { } /** - * Generate signed URL for a document file path + * Extract the storage object key from a stored document file path. + * Handles raw keys, "bucket/key" paths and full URLs. */ - private async generateSignedUrlForDocument(filePath: string): Promise { - const bucket = 'xpeditis-documents'; - - // Extract key from the file path + private extractDocumentKey(filePath: string): string { let key = filePath; if (filePath.includes('xpeditis-documents/')) { key = filePath.split('xpeditis-documents/')[1]; @@ -769,10 +819,65 @@ export class CsvBookingService { key = key.replace('xpeditis-documents/', ''); } } + return key; + } - // Generate signed URL with 1 hour expiration - const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600); - return signedUrl; + /** + * Stream a booking document to the carrier through the API (public, token-based). + * + * Downloads the file from storage and returns it as a buffer so the API can + * proxy it to the carrier's browser. This avoids handing out presigned storage + * URLs whose host (internal MinIO/S3 endpoint) is not reachable from outside. + * + * Applies the same access rules as {@link getDocumentsForCarrier}: the booking + * must be ACCEPTED and, when protected, the correct password must be supplied. + */ + async streamDocumentForCarrier( + token: string, + documentId: string, + password?: string + ): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> { + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { confirmationToken: token }, + }); + + if (!ormBooking) { + throw new NotFoundException('Réservation introuvable'); + } + + if (ormBooking.status !== 'ACCEPTED') { + throw new BadRequestException("Cette réservation n'a pas encore été acceptée"); + } + + if (ormBooking.passwordHash) { + if (!password) { + throw new UnauthorizedException('Mot de passe requis pour accéder aux documents'); + } + const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password); + if (!isPasswordValid) { + throw new UnauthorizedException('Mot de passe incorrect'); + } + } + + const booking = await this.csvBookingRepository.findByToken(token); + if (!booking) { + throw new NotFoundException('Réservation introuvable'); + } + + const document = booking.documents.find(doc => doc.id === documentId); + if (!document) { + throw new NotFoundException('Document introuvable'); + } + + const bucket = 'xpeditis-documents'; + const key = this.extractDocumentKey(document.filePath); + const buffer = await this.storageAdapter.download({ bucket, key }); + + return { + buffer, + fileName: document.fileName, + mimeType: document.mimeType || 'application/octet-stream', + }; } /** @@ -795,20 +900,12 @@ export class CsvBookingService { // Accept the booking (domain logic validates status) booking.accept(); - // Apply commission based on organization's subscription plan - try { - const subscription = await this.subscriptionService.getOrCreateSubscription( - booking.organizationId - ); - const commissionRate = subscription.plan.commissionRatePercent; - const baseAmountEur = booking.priceEUR; - booking.applyCommission(commissionRate, baseAmountEur); - this.logger.log( - `Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}€` - ); - } catch (error: any) { - this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack); - } + // Apply the flat per-booking service fee (forfait par booking) from the org's plan + const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId); + booking.applyBookingFee(bookingFeeEur); + this.logger.log( + `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}` + ); // Save updated booking const updatedBooking = await this.csvBookingRepository.update(booking); @@ -927,6 +1024,123 @@ export class CsvBookingService { return this.toResponseDto(updatedBooking); } + /** + * Update the cargo details of a booking before payment (user action). + * + * Only the owner can edit, and only while the booking is PENDING_PAYMENT. + */ + async updateBookingDetails( + id: string, + userId: string, + dto: UpdateCsvBookingDetailsDto + ): Promise { + this.logger.log(`Updating booking details ${id} by user ${userId}`); + + const booking = await this.csvBookingRepository.findById(id); + + if (!booking) { + throw new NotFoundException('Booking not found'); + } + + // Verify user owns this booking + if (booking.userId !== userId) { + throw new NotFoundException('Booking not found'); + } + + // Detect whether any pricing field was provided + const hasPricing = + dto.priceUSD !== undefined || + dto.priceEUR !== undefined || + dto.primaryCurrency !== undefined || + dto.freightTotal !== undefined || + dto.freightCurrency !== undefined || + dto.fobTotal !== undefined || + dto.fobCurrency !== undefined; + + // Apply the changes (domain logic validates status + values) + try { + booking.editDetails({ + volumeCBM: dto.volumeCBM, + weightKG: dto.weightKG, + palletCount: dto.palletCount, + notes: dto.notes, + pricing: hasPricing + ? { + priceUSD: dto.priceUSD, + priceEUR: dto.priceEUR, + primaryCurrency: dto.primaryCurrency, + freightTotal: dto.freightTotal, + freightCurrency: dto.freightCurrency, + fobTotal: dto.fobTotal, + fobCurrency: dto.fobCurrency, + } + : undefined, + }); + } catch (err) { + throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking details'); + } + + const updatedBooking = await this.csvBookingRepository.update(booking); + this.logger.log(`Booking ${id} details updated`); + + return this.toResponseDto(updatedBooking); + } + + /** + * Re-apply a full rate selection to a booking before payment (user action). + * + * Used when the user re-runs the search and picks a (possibly different) rate: + * carrier, route, container, transit, cargo and price are all replaced. Only + * the owner can edit, and only while the booking is PENDING_PAYMENT. + */ + async updateBookingRate( + id: string, + userId: string, + dto: UpdateCsvBookingRateDto + ): Promise { + this.logger.log(`Updating booking rate ${id} by user ${userId}`); + + const booking = await this.csvBookingRepository.findById(id); + + if (!booking) { + throw new NotFoundException('Booking not found'); + } + + if (booking.userId !== userId) { + throw new NotFoundException('Booking not found'); + } + + try { + booking.editFromRate({ + carrierName: dto.carrierName, + carrierEmail: dto.carrierEmail, + origin: PortCode.create(dto.origin), + destination: PortCode.create(dto.destination), + containerType: dto.containerType, + transitDays: dto.transitDays, + volumeCBM: dto.volumeCBM, + weightKG: dto.weightKG, + palletCount: dto.palletCount, + priceUSD: dto.priceUSD, + priceEUR: dto.priceEUR, + primaryCurrency: dto.primaryCurrency, + freightTotal: dto.freightTotal, + freightCurrency: dto.freightCurrency, + fobTotal: dto.fobTotal, + fobCurrency: dto.fobCurrency, + notes: dto.notes, + options: dto.options, + }); + } catch (err) { + throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking rate'); + } + + const updatedBooking = await this.csvBookingRepository.update(booking); + this.logger.log(`Booking ${id} rate updated`); + + return this.toResponseDto(updatedBooking); + } + /** * Get bookings for a user (paginated) */ @@ -1183,31 +1397,14 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - // Verify booking is still pending or awaiting payment - if ( - booking.status !== CsvBookingStatus.PENDING_PAYMENT && - booking.status !== CsvBookingStatus.PENDING - ) { - throw new BadRequestException('Cannot delete documents from a booking that is not pending'); - } - - // Find the document + // Find the document (no status restriction — aligned with replaceDocument; + // deleting down to zero documents is allowed) const documentIndex = booking.documents.findIndex(doc => doc.id === documentId); if (documentIndex === -1) { throw new NotFoundException(`Document with ID ${documentId} not found`); } - // Ensure at least one document remains - if (booking.documents.length <= 1) { - throw new BadRequestException( - 'Cannot delete the last document. At least one document is required.' - ); - } - - // Get the document to delete (for potential S3 cleanup - currently kept for audit) - const _documentToDelete = booking.documents[documentIndex]; - // Remove document from array const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId); @@ -1382,6 +1579,11 @@ export class CsvBookingService { price: booking.getPriceInCurrency(primaryCurrency), commissionRate: booking.commissionRate, commissionAmountEur: booking.commissionAmountEur, + freightTotal: booking.freightTotal, + freightCurrency: booking.freightCurrency, + fobTotal: booking.fobTotal, + fobCurrency: booking.fobCurrency, + options: booking.options ?? {}, }; } diff --git a/apps/backend/src/application/services/subscription.service.ts b/apps/backend/src/application/services/subscription.service.ts index 255c0e3..741d834 100644 --- a/apps/backend/src/application/services/subscription.service.ts +++ b/apps/backend/src/application/services/subscription.service.ts @@ -674,7 +674,7 @@ export class SubscriptionService { monthlyPriceEur: plan.monthlyPriceEur, yearlyPriceEur: plan.yearlyPriceEur, maxShipmentsPerYear: plan.maxShipmentsPerYear, - commissionRatePercent: plan.commissionRatePercent, + bookingFeeEur: plan.bookingFeeEur, supportLevel: plan.supportLevel, statusBadge: plan.statusBadge, planFeatures: [...plan.planFeatures], diff --git a/apps/backend/src/domain/entities/blog-post.entity.ts b/apps/backend/src/domain/entities/blog-post.entity.ts index 74a0f56..b62b5f7 100644 --- a/apps/backend/src/domain/entities/blog-post.entity.ts +++ b/apps/backend/src/domain/entities/blog-post.entity.ts @@ -7,13 +7,19 @@ export enum BlogPostStatus { export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news'; +/** A single question/answer pair used for GEO (AI) optimisation and FAQ schema. */ +export interface BlogFaqItem { + question: string; + answer: string; +} + interface BlogPostProps { id: string; title: string; slug: string; excerpt: string; content: string; - coverImageUrl?: string; + coverImageUrl?: string | null; category: BlogPostCategory; tags: string[]; authorName: string; @@ -24,6 +30,13 @@ interface BlogPostProps { metaDescription?: string; primaryKeyword?: string; secondaryKeywords: string[]; + // GEO (Generative Engine Optimisation) — helps AI/LLM engines cite the article. + aiSummary?: string | null; + faq: BlogFaqItem[]; + keyTakeaways: string[]; + aiEntities: string[]; + // Soft delete (trash). Null/undefined means the post is not trashed. + deletedAt?: Date | null; createdAt: Date; updatedAt: Date; } @@ -32,12 +45,27 @@ export class BlogPost { private constructor(private readonly props: BlogPostProps) {} static create( - props: Omit + props: Omit< + BlogPostProps, + | 'status' + | 'isFeatured' + | 'publishedAt' + | 'createdAt' + | 'updatedAt' + | 'faq' + | 'keyTakeaways' + | 'aiEntities' + | 'deletedAt' + > & + Partial> ): BlogPost { const now = new Date(); return new BlogPost({ ...props, secondaryKeywords: props.secondaryKeywords ?? [], + faq: props.faq ?? [], + keyTakeaways: props.keyTakeaways ?? [], + aiEntities: props.aiEntities ?? [], status: BlogPostStatus.DRAFT, isFeatured: false, createdAt: now, @@ -64,7 +92,7 @@ export class BlogPost { get content(): string { return this.props.content; } - get coverImageUrl(): string | undefined { + get coverImageUrl(): string | null | undefined { return this.props.coverImageUrl; } get category(): BlogPostCategory { @@ -97,6 +125,24 @@ export class BlogPost { get secondaryKeywords(): string[] { return this.props.secondaryKeywords; } + get aiSummary(): string | null | undefined { + return this.props.aiSummary; + } + get faq(): BlogFaqItem[] { + return this.props.faq; + } + get keyTakeaways(): string[] { + return this.props.keyTakeaways; + } + get aiEntities(): string[] { + return this.props.aiEntities; + } + get deletedAt(): Date | null | undefined { + return this.props.deletedAt; + } + get isTrashed(): boolean { + return !!this.props.deletedAt; + } get createdAt(): Date { return this.props.createdAt; } @@ -121,12 +167,26 @@ export class BlogPost { | 'metaDescription' | 'primaryKeyword' | 'secondaryKeywords' + | 'aiSummary' + | 'faq' + | 'keyTakeaways' + | 'aiEntities' > > ): BlogPost { return new BlogPost({ ...this.props, ...data, updatedAt: new Date() }); } + /** Move the post to the trash (soft delete). */ + trash(): BlogPost { + return new BlogPost({ ...this.props, deletedAt: new Date(), updatedAt: new Date() }); + } + + /** Restore the post from the trash. */ + restore(): BlogPost { + return new BlogPost({ ...this.props, deletedAt: null, updatedAt: new Date() }); + } + publish(): BlogPost { return new BlogPost({ ...this.props, @@ -163,6 +223,7 @@ export class BlogPost { } isVisibleToPublic(): boolean { + if (this.props.deletedAt) return false; if (this.props.status === BlogPostStatus.PUBLISHED) return true; if ( this.props.status === BlogPostStatus.SCHEDULED && diff --git a/apps/backend/src/domain/entities/csv-booking.entity.spec.ts b/apps/backend/src/domain/entities/csv-booking.entity.spec.ts index 7bcd7a8..01edc3a 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.spec.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.spec.ts @@ -191,7 +191,9 @@ describe('CsvBooking Entity', () => { }).toThrow('Invalid carrier email format'); }); - it('should throw error if no documents provided', () => { + it('should allow reconstituting a booking with no documents (all deleted before payment)', () => { + // A booking may end up with zero documents after the owner deletes them. + // Reconstitution must not fail (creation still requires >=1 doc at the service level). expect(() => { new CsvBooking( 'booking-123', @@ -214,7 +216,7 @@ describe('CsvBooking Entity', () => { 'token-abc123', new Date() ); - }).toThrow('At least one document is required for booking'); + }).not.toThrow(); }); }); @@ -484,4 +486,24 @@ describe('CsvBooking Entity', () => { expect(responseTime).toBeLessThan(1); // Should be less than 1 hour for this test }); }); + + describe('applyBookingFee', () => { + it('should set a flat per-booking fee and clear the percentage rate', () => { + const booking = createValidBooking(); + + booking.applyBookingFee(10); + + expect(booking.commissionAmountEur).toBe(10); + expect(booking.commissionRate).toBeUndefined(); + }); + + it('should store no fee when the plan fee is custom or zero', () => { + const booking = createValidBooking(); + + booking.applyBookingFee(-1); + + expect(booking.commissionAmountEur).toBe(0); + expect(booking.commissionRate).toBeUndefined(); + }); + }); }); diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index ca0b7de..c6c3ee1 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -63,18 +63,22 @@ export class CsvBooking { public readonly id: string, public readonly userId: string, public readonly organizationId: string, - public readonly carrierName: string, - public readonly carrierEmail: string, - public readonly origin: PortCode, - public readonly destination: PortCode, - public readonly volumeCBM: number, - public readonly weightKG: number, - public readonly palletCount: number, - public readonly priceUSD: number, - public readonly priceEUR: number, - public readonly primaryCurrency: string, - public readonly transitDays: number, - public readonly containerType: string, + // Carrier + route + transit + container — editable before payment when the + // user re-runs the search and picks another rate (see editFromRate). + public carrierName: string, + public carrierEmail: string, + public origin: PortCode, + public destination: PortCode, + // Cargo characteristics — editable before payment (see editDetails). + public volumeCBM: number, + public weightKG: number, + public palletCount: number, + // Pricing — recomputed when cargo details change before payment (see editDetails). + public priceUSD: number, + public priceEUR: number, + public primaryCurrency: string, + public transitDays: number, + public containerType: string, public status: CsvBookingStatus, public readonly documents: CsvBookingDocument[], public readonly confirmationToken: string, @@ -85,7 +89,16 @@ export class CsvBooking { public readonly bookingNumber?: string, public commissionRate?: number, public commissionAmountEur?: number, - public stripePaymentIntentId?: string + public stripePaymentIntentId?: string, + // Detailed transport cost breakdown (informational — paid to the carrier, + // not collected by Xpeditis). Freight and FOB may be in different currencies. + public freightTotal?: number, + public freightCurrency?: string, + public fobTotal?: number, + public fobCurrency?: string, + // Options & services selected in the search form (customs, insurance, DG, + // handling…). Stored as a flat map of enabled flags. Editable before payment. + public options: Record = {} ) { this.validate(); } @@ -144,19 +157,34 @@ export class CsvBooking { throw new Error('Confirmation token is required'); } - if (!this.documents || this.documents.length === 0) { - throw new Error('At least one document is required for booking'); - } + // Note: at least one document is required *at creation* (enforced in the + // create flow). A booking may end up with zero documents after the owner + // deletes them, so reconstitution must not fail here. } /** * Apply commission to the booking + * + * @deprecated Commission has been replaced by a flat per-booking fee. + * Use {@link applyBookingFee} instead. */ applyCommission(ratePercent: number, baseAmountEur: number): void { this.commissionRate = ratePercent; this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100; } + /** + * Apply the flat per-booking service fee (forfait par booking). + * + * Replaces the percentage-based commission: the amount is the subscription + * plan's fixed booking fee, so the percentage rate is cleared. A fee <= 0 + * (e.g. Platinium "sur mesure") means no automatic charge. + */ + applyBookingFee(feeEur: number): void { + this.commissionRate = undefined; + this.commissionAmountEur = feeEur > 0 ? feeEur : 0; + } + /** * Mark commission payment as completed → transition to PENDING * @@ -270,6 +298,145 @@ export class CsvBooking { this.respondedAt = new Date(); } + /** + * Edit the cargo details of a booking before it is paid. + * + * Only allowed while the booking is awaiting payment (PENDING_PAYMENT), i.e. + * before it is sent to the carrier. Carrier, route and price derive from the + * selected rate and are not editable here. + * + * @throws Error if the booking is not in PENDING_PAYMENT status or values are invalid + */ + editDetails(details: { + volumeCBM?: number; + weightKG?: number; + palletCount?: number; + notes?: string; + pricing?: { + priceUSD?: number; + priceEUR?: number; + primaryCurrency?: string; + freightTotal?: number; + freightCurrency?: string; + fobTotal?: number; + fobCurrency?: string; + }; + }): void { + if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { + throw new Error( + `Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT bookings can be edited.` + ); + } + + if (details.volumeCBM !== undefined) { + if (details.volumeCBM <= 0) { + throw new Error('Volume must be positive'); + } + this.volumeCBM = details.volumeCBM; + } + + if (details.weightKG !== undefined) { + if (details.weightKG <= 0) { + throw new Error('Weight must be positive'); + } + this.weightKG = details.weightKG; + } + + if (details.palletCount !== undefined) { + if (details.palletCount < 0) { + throw new Error('Pallet count cannot be negative'); + } + this.palletCount = details.palletCount; + } + + if (details.notes !== undefined) { + this.notes = details.notes; + } + + // Pricing is recomputed from the current rate when cargo details change. + const p = details.pricing; + if (p) { + if (p.priceUSD !== undefined) { + if (p.priceUSD < 0) throw new Error('Price cannot be negative'); + this.priceUSD = p.priceUSD; + } + if (p.priceEUR !== undefined) { + if (p.priceEUR < 0) throw new Error('Price cannot be negative'); + this.priceEUR = p.priceEUR; + } + if (p.primaryCurrency !== undefined) this.primaryCurrency = p.primaryCurrency; + if (p.freightTotal !== undefined) this.freightTotal = p.freightTotal; + if (p.freightCurrency !== undefined) this.freightCurrency = p.freightCurrency; + if (p.fobTotal !== undefined) this.fobTotal = p.fobTotal; + if (p.fobCurrency !== undefined) this.fobCurrency = p.fobCurrency; + } + } + + /** + * Re-apply a full rate selection before payment: the user re-ran the search + * and picked a rate, so carrier, route, container, transit, cargo and price + * are all replaced. Only allowed while the booking is PENDING_PAYMENT. + * + * @throws Error if the booking is not PENDING_PAYMENT or values are invalid + */ + editFromRate(data: { + carrierName: string; + carrierEmail: string; + origin: PortCode; + destination: PortCode; + containerType: string; + transitDays: number; + volumeCBM: number; + weightKG: number; + palletCount: number; + priceUSD: number; + priceEUR: number; + primaryCurrency: string; + freightTotal?: number; + freightCurrency?: string; + fobTotal?: number; + fobCurrency?: string; + notes?: string; + options?: Record; + }): void { + if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { + throw new Error( + `Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT bookings can be edited.` + ); + } + if (!data.carrierName || data.carrierName.trim().length === 0) { + throw new Error('Carrier name is required'); + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!data.carrierEmail || !emailRegex.test(data.carrierEmail)) { + throw new Error('Invalid carrier email format'); + } + if (data.volumeCBM <= 0) throw new Error('Volume must be positive'); + if (data.weightKG <= 0) throw new Error('Weight must be positive'); + if (data.palletCount < 0) throw new Error('Pallet count cannot be negative'); + if (data.transitDays <= 0) throw new Error('Transit days must be positive'); + if (data.priceUSD < 0 || data.priceEUR < 0) throw new Error('Price cannot be negative'); + + this.carrierName = data.carrierName; + this.carrierEmail = data.carrierEmail; + this.origin = data.origin; + this.destination = data.destination; + this.containerType = data.containerType; + this.transitDays = data.transitDays; + this.volumeCBM = data.volumeCBM; + this.weightKG = data.weightKG; + this.palletCount = data.palletCount; + this.priceUSD = data.priceUSD; + this.priceEUR = data.priceEUR; + this.primaryCurrency = data.primaryCurrency; + this.freightTotal = data.freightTotal; + this.freightCurrency = data.freightCurrency; + this.fobTotal = data.fobTotal; + this.fobCurrency = data.fobCurrency; + if (data.notes !== undefined) this.notes = data.notes; + if (data.options !== undefined) this.options = data.options; + } + /** * Check if booking has expired (7 days without response) * @@ -434,7 +601,12 @@ export class CsvBooking { bookingNumber?: string, commissionRate?: number, commissionAmountEur?: number, - stripePaymentIntentId?: string + stripePaymentIntentId?: string, + freightTotal?: number, + freightCurrency?: string, + fobTotal?: number, + fobCurrency?: string, + options?: Record ): CsvBooking { // Create instance without calling constructor validation const booking = Object.create(CsvBooking.prototype); @@ -466,6 +638,11 @@ export class CsvBooking { booking.commissionRate = commissionRate; booking.commissionAmountEur = commissionAmountEur; booking.stripePaymentIntentId = stripePaymentIntentId; + booking.freightTotal = freightTotal; + booking.freightCurrency = freightCurrency; + booking.fobTotal = fobTotal; + booking.fobCurrency = fobCurrency; + booking.options = options ?? {}; return booking; } diff --git a/apps/backend/src/domain/entities/subscription.entity.ts b/apps/backend/src/domain/entities/subscription.entity.ts index 3cde08c..34e49fd 100644 --- a/apps/backend/src/domain/entities/subscription.entity.ts +++ b/apps/backend/src/domain/entities/subscription.entity.ts @@ -80,10 +80,10 @@ export class Subscription { } /** - * Get the commission rate for this subscription's plan + * Get the per-booking fee for this subscription's plan */ - get commissionRatePercent(): number { - return this.props.plan.commissionRatePercent; + get bookingFeeEur(): number { + return this.props.plan.bookingFeeEur; } /** diff --git a/apps/backend/src/domain/ports/out/blog-post.repository.ts b/apps/backend/src/domain/ports/out/blog-post.repository.ts index 8e8e039..2f6d4a3 100644 --- a/apps/backend/src/domain/ports/out/blog-post.repository.ts +++ b/apps/backend/src/domain/ports/out/blog-post.repository.ts @@ -11,6 +11,8 @@ export interface BlogPostFilters { offset?: number; /** When true, also include SCHEDULED posts whose publishedAt <= now */ includeScheduled?: boolean; + /** When true, return only trashed posts; otherwise trashed posts are excluded. */ + trashed?: boolean; } export interface BlogPostRepository { diff --git a/apps/backend/src/domain/ports/out/shipment-counter.port.ts b/apps/backend/src/domain/ports/out/shipment-counter.port.ts index 0aaad05..e489eb1 100644 --- a/apps/backend/src/domain/ports/out/shipment-counter.port.ts +++ b/apps/backend/src/domain/ports/out/shipment-counter.port.ts @@ -12,4 +12,14 @@ export interface ShipmentCounterPort { * Count all shipments (bookings + CSV bookings) created by an organization in a given year. */ countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise; + + /** + * Count only PAID shipments (fee paid / payment declared / accepted) created by + * an organization in a given year. Unpaid drafts (PENDING_PAYMENT), rejected and + * cancelled bookings are excluded. + */ + countPaidShipmentsForOrganizationInYear( + organizationId: string, + year: number + ): Promise; } diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts index ddbcaa2..cadca2c 100644 --- a/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts @@ -233,8 +233,30 @@ describe('SubscriptionPlan Value Object', () => { it('should return correct prices for SILVER plan', () => { const plan = SubscriptionPlan.silver(); - expect(plan.monthlyPriceEur).toBe(249); - expect(plan.yearlyPriceEur).toBe(2739); + expect(plan.monthlyPriceEur).toBe(299); + // Annual billing = 11 months (1 month deducted) + expect(plan.yearlyPriceEur).toBe(plan.monthlyPriceEur * 11); + expect(plan.yearlyPriceEur).toBe(3289); + }); + + it('should bill the yearly price over 11 months for GOLD plan', () => { + const plan = SubscriptionPlan.gold(); + expect(plan.monthlyPriceEur).toBe(799); + expect(plan.yearlyPriceEur).toBe(plan.monthlyPriceEur * 11); + expect(plan.yearlyPriceEur).toBe(8789); + }); + + it('should limit BRONZE to 5 bookings per year', () => { + expect(SubscriptionPlan.bronze().maxShipmentsPerYear).toBe(5); + }); + + it('should expose per-booking fees per plan', () => { + expect(SubscriptionPlan.bronze().bookingFeeEur).toBe(15); + expect(SubscriptionPlan.silver().bookingFeeEur).toBe(10); + expect(SubscriptionPlan.gold().bookingFeeEur).toBe(5); + expect(SubscriptionPlan.platinium().bookingFeeEur).toBe(-1); + expect(SubscriptionPlan.platinium().hasCustomBookingFee()).toBe(true); + expect(SubscriptionPlan.gold().hasCustomBookingFee()).toBe(false); }); it('should return features for GOLD plan', () => { diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts index 5ffa990..7e90892 100644 --- a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts @@ -2,10 +2,16 @@ * Subscription Plan Value Object * * Represents the different subscription plans available for organizations. - * Each plan has a maximum number of licenses, shipment limits, commission rates, + * Each plan has a maximum number of licenses, shipment limits, a per-booking fee, * feature flags, and support levels. * - * Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom) + * Plans: BRONZE (free), SILVER (299EUR/mo), GOLD (799EUR/mo), PLATINIUM (custom) + * + * Annual billing: one month is deducted, so the yearly price is billed over + * 11 months (yearlyPriceEur = monthlyPriceEur * 11). + * + * Each plan also has a per-booking fee (bookingFeeEur). For BRONZE it applies to + * bookings beyond the yearly limit ("scaling"); for PLATINIUM it is custom (-1). */ import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo'; @@ -29,9 +35,9 @@ interface PlanDetails { readonly name: string; readonly maxLicenses: number; // -1 means unlimited readonly monthlyPriceEur: number; - readonly yearlyPriceEur: number; + readonly yearlyPriceEur: number; // monthlyPriceEur * 11 (annual billing = 11 months) readonly maxShipmentsPerYear: number; // -1 means unlimited - readonly commissionRatePercent: number; + readonly bookingFeeEur: number; // per-booking fee; -1 means custom (on quote) readonly statusBadge: StatusBadge; readonly supportLevel: SupportLevel; readonly planFeatures: readonly PlanFeature[]; @@ -44,20 +50,25 @@ const PLAN_DETAILS: Record = { maxLicenses: 1, monthlyPriceEur: 0, yearlyPriceEur: 0, - maxShipmentsPerYear: 12, - commissionRatePercent: 5, + maxShipmentsPerYear: 5, + bookingFeeEur: 15, statusBadge: 'none', supportLevel: 'none', planFeatures: PLAN_FEATURES.BRONZE, - features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'], + features: [ + '1 utilisateur', + '5 bookings par an', + 'Recherche de tarifs basique', + 'Frais de booking pour scaler : 15 €', + ], }, SILVER: { name: 'Silver', maxLicenses: 5, - monthlyPriceEur: 249, - yearlyPriceEur: 2739, + monthlyPriceEur: 299, + yearlyPriceEur: 3289, maxShipmentsPerYear: -1, - commissionRatePercent: 3, + bookingFeeEur: 10, statusBadge: 'silver', supportLevel: 'email', planFeatures: PLAN_FEATURES.SILVER, @@ -69,15 +80,16 @@ const PLAN_DETAILS: Record = { 'Gestion des utilisateurs', 'Import CSV', 'Support par email', + 'Forfait par booking : 10 €', ], }, GOLD: { name: 'Gold', maxLicenses: 20, - monthlyPriceEur: 899, - yearlyPriceEur: 9889, + monthlyPriceEur: 799, + yearlyPriceEur: 8789, maxShipmentsPerYear: -1, - commissionRatePercent: 2, + bookingFeeEur: 5, statusBadge: 'gold', supportLevel: 'direct', planFeatures: PLAN_FEATURES.GOLD, @@ -87,6 +99,7 @@ const PLAN_DETAILS: Record = { 'Toutes les fonctionnalités Silver', 'Intégration API', 'Assistance commerciale directe', + 'Forfait par booking : 5 €', ], }, PLATINIUM: { @@ -95,7 +108,7 @@ const PLAN_DETAILS: Record = { monthlyPriceEur: 0, // custom pricing yearlyPriceEur: 0, // custom pricing maxShipmentsPerYear: -1, - commissionRatePercent: 1, + bookingFeeEur: -1, // custom / on quote statusBadge: 'platinium', supportLevel: 'dedicated_kam', planFeatures: PLAN_FEATURES.PLATINIUM, @@ -105,6 +118,7 @@ const PLAN_DETAILS: Record = { 'Key Account Manager dédié', 'Interface personnalisable', 'Contrats tarifaires cadre', + 'Forfait par booking : sur mesure', ], }, }; @@ -209,8 +223,12 @@ export class SubscriptionPlan { return PLAN_DETAILS[this.plan].maxShipmentsPerYear; } - get commissionRatePercent(): number { - return PLAN_DETAILS[this.plan].commissionRatePercent; + get bookingFeeEur(): number { + return PLAN_DETAILS[this.plan].bookingFeeEur; + } + + hasCustomBookingFee(): boolean { + return PLAN_DETAILS[this.plan].bookingFeeEur === -1; } get statusBadge(): StatusBadge { 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 index 36cb24d..efddfb4 100644 --- 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 @@ -20,7 +20,7 @@ export class BlogPostOrmEntity { content: string; @Column('varchar', { length: 500, nullable: true }) - cover_image_url?: string; + cover_image_url?: string | null; @Column('varchar', { length: 50 }) category: string; @@ -52,6 +52,23 @@ export class BlogPostOrmEntity { @Column('jsonb', { default: [] }) secondary_keywords: string[]; + // GEO (Generative Engine Optimisation) fields + @Column('text', { nullable: true }) + ai_summary?: string | null; + + @Column('jsonb', { default: [] }) + faq: { question: string; answer: string }[]; + + @Column('jsonb', { default: [] }) + key_takeaways: string[]; + + @Column('jsonb', { default: [] }) + ai_entities: string[]; + + // Soft delete (trash) + @Column('timestamp', { nullable: true }) + deleted_at?: Date | null; + @CreateDateColumn() created_at: Date; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts index 19ae980..ee8de24 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts @@ -169,6 +169,23 @@ export class CsvBookingOrmEntity { }) commissionAmountEur: number | null; + // Transport cost breakdown (informational — paid to the carrier, not Xpeditis). + // Freight and FOB may be quoted in different currencies. + @Column({ name: 'freight_total', type: 'decimal', precision: 12, scale: 2, nullable: true }) + freightTotal: number | null; + + @Column({ name: 'freight_currency', type: 'varchar', length: 3, nullable: true }) + freightCurrency: string | null; + + @Column({ name: 'fob_total', type: 'decimal', precision: 12, scale: 2, nullable: true }) + fobTotal: number | null; + + @Column({ name: 'fob_currency', type: 'varchar', length: 3, nullable: true }) + fobCurrency: string | null; + + @Column({ name: 'options', type: 'jsonb', default: {} }) + options: Record; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) createdAt: Date; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts index 85217ed..7f42516 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts @@ -45,7 +45,12 @@ export class CsvBookingMapper { ormEntity.bookingNumber ?? undefined, ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined, ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined, - ormEntity.stripePaymentIntentId ?? undefined + ormEntity.stripePaymentIntentId ?? undefined, + ormEntity.freightTotal != null ? Number(ormEntity.freightTotal) : undefined, + ormEntity.freightCurrency ?? undefined, + ormEntity.fobTotal != null ? Number(ormEntity.fobTotal) : undefined, + ormEntity.fobCurrency ?? undefined, + ormEntity.options ?? {} ); } @@ -79,6 +84,11 @@ export class CsvBookingMapper { stripePaymentIntentId: domain.stripePaymentIntentId ?? null, commissionRate: domain.commissionRate ?? null, commissionAmountEur: domain.commissionAmountEur ?? null, + freightTotal: domain.freightTotal ?? null, + freightCurrency: domain.freightCurrency ?? null, + fobTotal: domain.fobTotal ?? null, + fobCurrency: domain.fobCurrency ?? null, + options: domain.options ?? {}, }; } @@ -87,6 +97,25 @@ export class CsvBookingMapper { */ static toOrmUpdate(domain: CsvBooking): Partial { return { + // Carrier + route + cargo + price are editable before payment + // (editDetails / editFromRate), so they must be persisted on update. + carrierName: domain.carrierName, + carrierEmail: domain.carrierEmail, + origin: domain.origin.getValue(), + destination: domain.destination.getValue(), + containerType: domain.containerType, + transitDays: domain.transitDays, + volumeCBM: domain.volumeCBM, + weightKG: domain.weightKG, + palletCount: domain.palletCount, + priceUSD: domain.priceUSD, + priceEUR: domain.priceEUR, + primaryCurrency: domain.primaryCurrency, + freightTotal: domain.freightTotal ?? null, + freightCurrency: domain.freightCurrency ?? null, + fobTotal: domain.fobTotal ?? null, + fobCurrency: domain.fobCurrency ?? null, + options: domain.options ?? {}, status: domain.status as CsvBookingOrmEntity['status'], respondedAt: domain.respondedAt, notes: domain.notes, diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddFreightFobToCsvBookings.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddFreightFobToCsvBookings.ts new file mode 100644 index 0000000..5469524 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddFreightFobToCsvBookings.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the transport cost breakdown (freight + FOB) to csv_bookings. + * + * These amounts are informational: they are paid to the carrier, not collected + * by Xpeditis (which only charges the flat per-booking fee). Freight and FOB may + * be quoted in different currencies, so each amount keeps its own currency. + */ +export class AddFreightFobToCsvBookings1740000000003 implements MigrationInterface { + name = 'AddFreightFobToCsvBookings1740000000003'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ADD COLUMN IF NOT EXISTS "freight_total" DECIMAL(12,2), + ADD COLUMN IF NOT EXISTS "freight_currency" VARCHAR(3), + ADD COLUMN IF NOT EXISTS "fob_total" DECIMAL(12,2), + ADD COLUMN IF NOT EXISTS "fob_currency" VARCHAR(3) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "csv_bookings" + DROP COLUMN IF EXISTS "fob_currency", + DROP COLUMN IF EXISTS "fob_total", + DROP COLUMN IF EXISTS "freight_currency", + DROP COLUMN IF EXISTS "freight_total" + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1748000000000-AddGeoAndTrashToBlogPosts.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1748000000000-AddGeoAndTrashToBlogPosts.ts new file mode 100644 index 0000000..df02b71 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1748000000000-AddGeoAndTrashToBlogPosts.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddGeoAndTrashToBlogPosts1748000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('blog_posts', [ + // GEO (Generative Engine Optimisation) fields + new TableColumn({ + name: 'ai_summary', + type: 'text', + isNullable: true, + }), + new TableColumn({ + name: 'faq', + type: 'jsonb', + isNullable: false, + default: "'[]'", + }), + new TableColumn({ + name: 'key_takeaways', + type: 'jsonb', + isNullable: false, + default: "'[]'", + }), + new TableColumn({ + name: 'ai_entities', + type: 'jsonb', + isNullable: false, + default: "'[]'", + }), + // Soft delete (trash) + new TableColumn({ + name: 'deleted_at', + type: 'timestamp', + isNullable: true, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumns('blog_posts', [ + 'ai_summary', + 'faq', + 'key_takeaways', + 'ai_entities', + 'deleted_at', + ]); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1749000000000-AddOptionsToCsvBookings.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1749000000000-AddOptionsToCsvBookings.ts new file mode 100644 index 0000000..8a50074 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1749000000000-AddOptionsToCsvBookings.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddOptionsToCsvBookings1749000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'csv_bookings', + new TableColumn({ + name: 'options', + type: 'jsonb', + isNullable: false, + default: "'{}'", + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('csv_bookings', 'options'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts index f60e0b5..ef99d3a 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts @@ -29,4 +29,24 @@ export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort { .andWhere('csv_booking.created_at < :end', { end: startOfNextYear }) .getCount(); } + + async countPaidShipmentsForOrganizationInYear( + organizationId: string, + year: number + ): Promise { + const startOfYear = new Date(year, 0, 1); + const startOfNextYear = new Date(year + 1, 0, 1); + + // "Paid" = payment completed / declared / accepted. Unpaid drafts + // (PENDING_PAYMENT), rejected and cancelled bookings do not count. + const PAID_STATUSES = ['PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED']; + + return this.csvBookingRepository + .createQueryBuilder('csv_booking') + .where('csv_booking.organization_id = :organizationId', { organizationId }) + .andWhere('csv_booking.created_at >= :start', { start: startOfYear }) + .andWhere('csv_booking.created_at < :end', { end: startOfNextYear }) + .andWhere('csv_booking.status IN (:...statuses)', { statuses: PAID_STATUSES }) + .getCount(); + } } 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 index 7cac371..5825065 100644 --- 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 @@ -31,6 +31,12 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository { async findByFilters(filters: BlogPostFilters): Promise { const query = this.ormRepository.createQueryBuilder('post'); + if (filters.trashed) { + query.andWhere('post.deleted_at IS NOT NULL'); + } else { + query.andWhere('post.deleted_at IS NULL'); + } + if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) { query.andWhere( "(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))" @@ -65,6 +71,12 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository { async count(filters: BlogPostFilters): Promise { const query = this.ormRepository.createQueryBuilder('post'); + if (filters.trashed) { + query.andWhere('post.deleted_at IS NOT NULL'); + } else { + query.andWhere('post.deleted_at IS NULL'); + } + if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) { query.andWhere( "(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))" @@ -117,6 +129,11 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository { metaDescription: orm.meta_description, primaryKeyword: orm.primary_keyword, secondaryKeywords: orm.secondary_keywords ?? [], + aiSummary: orm.ai_summary, + faq: orm.faq ?? [], + keyTakeaways: orm.key_takeaways ?? [], + aiEntities: orm.ai_entities ?? [], + deletedAt: orm.deleted_at, createdAt: orm.created_at, updatedAt: orm.updated_at, }); @@ -140,6 +157,11 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository { orm.meta_description = post.metaDescription; orm.primary_keyword = post.primaryKeyword; orm.secondary_keywords = post.secondaryKeywords; + orm.ai_summary = post.aiSummary; + orm.faq = post.faq; + orm.key_takeaways = post.keyTakeaways; + orm.ai_entities = post.aiEntities; + orm.deleted_at = post.deletedAt; orm.created_at = post.createdAt; orm.updated_at = post.updatedAt; return orm; diff --git a/apps/backend/src/infrastructure/security/security.config.ts b/apps/backend/src/infrastructure/security/security.config.ts index e8908ef..4a59042 100644 --- a/apps/backend/src/infrastructure/security/security.config.ts +++ b/apps/backend/src/infrastructure/security/security.config.ts @@ -192,7 +192,8 @@ export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: bool // sites (cross-origin), otherwise the browser drops the auth cookies set in // the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS). // Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups. - const sameSite = (process.env.COOKIE_SAMESITE?.toLowerCase() as 'lax' | 'strict' | 'none') || 'lax'; + const sameSite = + (process.env.COOKIE_SAMESITE?.toLowerCase() as 'lax' | 'strict' | 'none') || 'lax'; const secure = process.env.NODE_ENV === 'production' || sameSite === 'none'; return { diff --git a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts index 4cd3665..9cfa590 100644 --- a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts +++ b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts @@ -138,7 +138,7 @@ export class StripeAdapter implements StripePort { currency: input.currency, unit_amount: input.amountCents, product_data: { - name: 'Commission Xpeditis', + name: 'Frais de booking Xpeditis', description: input.bookingDescription, }, }, diff --git a/apps/frontend/app/[locale]/admin/blog/page.tsx b/apps/frontend/app/[locale]/admin/blog/page.tsx index 0e40772..7973ffc 100644 --- a/apps/frontend/app/[locale]/admin/blog/page.tsx +++ b/apps/frontend/app/[locale]/admin/blog/page.tsx @@ -6,11 +6,14 @@ import { createBlogPost, updateBlogPost, deleteBlogPost, + restoreBlogPost, + permanentlyDeleteBlogPost, + duplicateBlogPost, type CreateBlogPostRequest, type UpdateBlogPostRequest, } from '@/lib/api/admin'; import { upload } from '@/lib/api/client'; -import type { BlogPost, BlogPostCategory, BlogPostStatus } from '@/lib/api/blog'; +import type { BlogPost, BlogPostCategory, BlogPostStatus, BlogFaqItem } from '@/lib/api/blog'; import { PageHeader } from '@/components/ui/PageHeader'; import { RichTextEditor } from '@/components/blog/RichTextEditor'; import { @@ -29,6 +32,10 @@ import { Clock, ChevronDown, ChevronUp, + ArrowLeft, + Copy, + RotateCcw, + Sparkles, } from 'lucide-react'; import { Link } from '@/i18n/navigation'; @@ -70,6 +77,11 @@ interface FormData extends CreateBlogPostRequest { metaDescription: string; primaryKeyword: string; secondaryKeywordsInput: string; + // GEO / AI optimisation + aiSummary: string; + faqItems: BlogFaqItem[]; + keyTakeawaysInput: string; + aiEntitiesInput: string; } const EMPTY_FORM: FormData = { @@ -85,6 +97,10 @@ const EMPTY_FORM: FormData = { metaDescription: '', primaryKeyword: '', secondaryKeywordsInput: '', + aiSummary: '', + faqItems: [], + keyTakeawaysInput: '', + aiEntitiesInput: '', }; function generateSlug(title: string): string { @@ -144,16 +160,23 @@ export default function AdminBlogPage() { const [formData, setFormData] = useState(EMPTY_FORM); const [scheduledAt, setScheduledAt] = useState(''); const [seoOpen, setSeoOpen] = useState(false); + const [geoOpen, setGeoOpen] = useState(false); + const [isDraggingCover, setIsDraggingCover] = useState(false); + const [viewFilter, setViewFilter] = useState<'all' | 'published' | 'draft' | 'scheduled' | 'trash'>( + 'all' + ); + const [actioningId, setActioningId] = useState(null); const coverInputRef = useRef(null); useEffect(() => { fetchPosts(); - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewFilter]); const fetchPosts = async () => { try { setLoading(true); - const res = await getAllBlogPosts(); + const res = await getAllBlogPosts({ trashed: viewFilter === 'trash' }); setPosts(res.posts); setError(null); } catch (err: any) { @@ -163,6 +186,12 @@ export default function AdminBlogPage() { } }; + // Client-side status filtering for the non-trash views (posts are already loaded). + const visiblePosts = + viewFilter === 'all' || viewFilter === 'trash' + ? posts + : posts.filter(p => p.status === viewFilter); + const buildPayload = (withStatus?: BlogPostStatus): CreateBlogPostRequest => { const tags = tagsInput ? tagsInput @@ -176,12 +205,29 @@ export default function AdminBlogPage() { .map(t => t.trim()) .filter(Boolean) : []; + const keyTakeaways = formData.keyTakeawaysInput + ? formData.keyTakeawaysInput + .split('\n') + .map(t => t.trim()) + .filter(Boolean) + : []; + const aiEntities = formData.aiEntitiesInput + ? formData.aiEntitiesInput + .split(',') + .map(t => t.trim()) + .filter(Boolean) + : []; + const faq = formData.faqItems + .map(item => ({ question: item.question.trim(), answer: item.answer.trim() })) + .filter(item => item.question && item.answer); return { title: formData.title, slug: formData.slug, excerpt: formData.excerpt, content: formData.content, - coverImageUrl: formData.coverImageUrl || undefined, + // Send explicit null (not undefined) when the cover was removed, so the + // backend clears the column instead of leaving the previous value. + coverImageUrl: formData.coverImageUrl?.trim() ? formData.coverImageUrl : null, category: formData.category, tags, authorName: formData.authorName, @@ -189,6 +235,10 @@ export default function AdminBlogPage() { metaDescription: formData.metaDescription || undefined, primaryKeyword: formData.primaryKeyword || undefined, secondaryKeywords, + aiSummary: formData.aiSummary.trim() ? formData.aiSummary : null, + faq, + keyTakeaways, + aiEntities, scheduledAt: scheduledAt || undefined, ...(withStatus ? { status: withStatus } : {}), }; @@ -259,9 +309,11 @@ export default function AdminBlogPage() { } }; - const handleCoverUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; + const uploadCoverFile = async (file: File) => { + if (!file.type.startsWith('image/')) { + alert('Veuillez sélectionner une image'); + return; + } if (file.size > 5 * 1024 * 1024) { alert('Image trop volumineuse (max 5 Mo)'); return; @@ -271,8 +323,9 @@ export default function AdminBlogPage() { try { const fd = new FormData(); fd.append('image', file); + // Dedicated endpoint: auto-crops to the public 16:9 card ratio. const result = await upload<{ url: string; filename: string }>( - '/api/v1/admin/blog/images', + '/api/v1/admin/blog/cover-images', fd ); const coverUrl = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`; @@ -285,6 +338,73 @@ export default function AdminBlogPage() { } }; + const handleCoverUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) await uploadCoverFile(file); + }; + + const handleCoverDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setIsDraggingCover(false); + const file = e.dataTransfer.files?.[0]; + if (file) await uploadCoverFile(file); + }; + + // FAQ (GEO) helpers + const addFaqItem = () => { + setFormData(prev => ({ ...prev, faqItems: [...prev.faqItems, { question: '', answer: '' }] })); + }; + + const updateFaqItem = (index: number, field: 'question' | 'answer', value: string) => { + setFormData(prev => ({ + ...prev, + faqItems: prev.faqItems.map((item, i) => (i === index ? { ...item, [field]: value } : item)), + })); + }; + + const removeFaqItem = (index: number) => { + setFormData(prev => ({ + ...prev, + faqItems: prev.faqItems.filter((_, i) => i !== index), + })); + }; + + const handleDuplicate = async (post: BlogPost) => { + setActioningId(post.id); + try { + await duplicateBlogPost(post.id); + await fetchPosts(); + } catch (err: any) { + alert(err.message || 'Erreur lors de la duplication'); + } finally { + setActioningId(null); + } + }; + + const handleRestore = async (post: BlogPost) => { + setActioningId(post.id); + try { + await restoreBlogPost(post.id); + await fetchPosts(); + } catch (err: any) { + alert(err.message || 'Erreur lors de la restauration'); + } finally { + setActioningId(null); + } + }; + + const handlePermanentDelete = async () => { + if (!selectedPost) return; + try { + await permanentlyDeleteBlogPost(selectedPost.id); + await fetchPosts(); + setShowDeleteConfirm(false); + setSelectedPost(null); + } catch (err: any) { + alert(err.message || 'Erreur lors de la suppression définitive'); + } + }; + const openCreate = () => { setFormData(EMPTY_FORM); setTagsInput(''); @@ -292,6 +412,7 @@ export default function AdminBlogPage() { setEditStatus('draft'); setSelectedPost(null); setSeoOpen(false); + setGeoOpen(false); setShowCreateModal(true); }; @@ -310,15 +431,29 @@ export default function AdminBlogPage() { metaDescription: post.metaDescription ?? '', primaryKeyword: post.primaryKeyword ?? '', secondaryKeywordsInput: (post.secondaryKeywords ?? []).join(', '), + aiSummary: post.aiSummary ?? '', + faqItems: (post.faq ?? []).map(item => ({ ...item })), + keyTakeawaysInput: (post.keyTakeaways ?? []).join('\n'), + aiEntitiesInput: (post.aiEntities ?? []).join(', '), }); setTagsInput(post.tags.join(', ')); setScheduledAt(post.status === 'scheduled' ? toLocalDatetimeValue(post.publishedAt) : ''); setEditStatus(post.status); setSeoOpen(!!(post.metaTitle || post.metaDescription || post.primaryKeyword)); + setGeoOpen( + !!( + post.aiSummary || + (post.faq && post.faq.length) || + (post.keyTakeaways && post.keyTakeaways.length) || + (post.aiEntities && post.aiEntities.length) + ) + ); setShowEditModal(true); }; - const closeModal = () => { + // Pure UI reset — does not touch browser history. Used by the popstate + // handler (the browser already popped our entry at that point). + const resetEditorState = () => { setShowCreateModal(false); setShowEditModal(false); setSelectedPost(null); @@ -326,8 +461,31 @@ export default function AdminBlogPage() { setTagsInput(''); setScheduledAt(''); setSeoOpen(false); + setGeoOpen(false); }; + // Explicit close (X / Retour / after save): reset UI and pop the history + // entry we pushed when opening, so the browser Back button stays on /admin/blog. + const closeModal = () => { + resetEditorState(); + if (typeof window !== 'undefined' && window.history.state?.xpBlogEditor) { + window.history.back(); + } + }; + + // Keep the browser Back button inside the blog editor: pushing a history entry + // when the editor opens means Back closes the editor instead of navigating away + // (e.g. to the System Logs tab). + useEffect(() => { + const editorOpen = showCreateModal || showEditModal; + if (!editorOpen) return; + window.history.pushState({ xpBlogEditor: true }, ''); + const handlePop = () => resetEditorState(); + window.addEventListener('popstate', handlePop); + return () => window.removeEventListener('popstate', handlePop); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showCreateModal, showEditModal]); + const handleTitleChange = (title: string) => { setFormData(prev => ({ ...prev, @@ -380,10 +538,36 @@ export default function AdminBlogPage() { )} + {/* Status filters */} +
+ {( + [ + { value: 'all', label: 'Tous' }, + { value: 'published', label: 'Publiés' }, + { value: 'draft', label: 'Brouillons' }, + { value: 'scheduled', label: 'Planifiés' }, + { value: 'trash', label: 'Corbeille' }, + ] as const + ).map(tab => ( + + ))} +
+ {loading ? (
Chargement des articles...
) : ( -
+
@@ -408,14 +592,16 @@ export default function AdminBlogPage() { - {posts.length === 0 ? ( + {visiblePosts.length === 0 ? ( ) : ( - posts.map(post => ( + visiblePosts.map(post => ( @@ -535,9 +762,19 @@ export default function AdminBlogPage() {
{/* Header */}
-

- {showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`} -

+
+ +

+ {showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`} +

+
{showEditModal && (
- Aucun article. Créez votre premier article ! + {viewFilter === 'trash' + ? 'La corbeille est vide.' + : 'Aucun article. Créez votre premier article !'}
@@ -471,55 +657,96 @@ export default function AdminBlogPage() {
- {(post.status === 'published' || post.status === 'scheduled') && ( - + {viewFilter === 'trash' ? ( + <> - + + + ) : ( + <> + {(post.status === 'published' || post.status === 'scheduled') && ( + + + + )} + + + + + + )} - - - -