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/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index b413978..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, @@ -1001,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 @@ -1021,6 +1084,7 @@ export class AdminController { status, category, search, + trashed: trashed === 'true', limit: Number(limit), offset: Number(offset), }); @@ -1051,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, @@ -1078,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 f2d7afe..663516e 100644 --- a/apps/backend/src/application/controllers/blog.controller.ts +++ b/apps/backend/src/application/controllers/blog.controller.ts @@ -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 b97efdb..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,13 +1,4 @@ -import { - Controller, - Get, - Post, - Param, - Query, - Body, - Res, - StreamableFile, -} 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'; @@ -251,10 +242,7 @@ export class CsvBookingActionsController { ); res.setHeader('Content-Type', mimeType); - res.setHeader( - 'Content-Disposition', - `attachment; filename="${encodeURIComponent(fileName)}"` - ); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); return new StreamableFile(buffer); } } 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 eda2d62..ef3f78f 100644 --- a/apps/backend/src/application/dto/blog-post.dto.ts +++ b/apps/backend/src/application/dto/blog-post.dto.ts @@ -11,12 +11,63 @@ import { 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() @@ -95,7 +146,7 @@ export class CreateBlogPostDto { secondaryKeywords?: string[]; } -export class UpdateBlogPostDto { +export class UpdateBlogPostDto extends BlogGeoFields { @ApiPropertyOptional() @IsOptional() @IsString() @@ -206,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/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/domain/entities/blog-post.entity.ts b/apps/backend/src/domain/entities/blog-post.entity.ts index 3684794..b62b5f7 100644 --- a/apps/backend/src/domain/entities/blog-post.entity.ts +++ b/apps/backend/src/domain/entities/blog-post.entity.ts @@ -7,6 +7,12 @@ 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; @@ -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, @@ -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/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/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts index cffd061..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 @@ -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/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/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/frontend/app/[locale]/admin/blog/page.tsx b/apps/frontend/app/[locale]/admin/blog/page.tsx index 57a8862..fab44c0 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 { @@ -30,6 +33,9 @@ import { ChevronDown, ChevronUp, ArrowLeft, + Copy, + RotateCcw, + Sparkles, } from 'lucide-react'; import { Link } from '@/i18n/navigation'; @@ -71,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 = { @@ -86,6 +97,10 @@ const EMPTY_FORM: FormData = { metaDescription: '', primaryKeyword: '', secondaryKeywordsInput: '', + aiSummary: '', + faqItems: [], + keyTakeawaysInput: '', + aiEntitiesInput: '', }; function generateSlug(title: string): string { @@ -145,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) { @@ -164,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 @@ -177,6 +205,21 @@ 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, @@ -192,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 } : {}), }; @@ -262,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; @@ -274,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}`; @@ -288,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(''); @@ -295,6 +412,7 @@ export default function AdminBlogPage() { setEditStatus('draft'); setSelectedPost(null); setSeoOpen(false); + setGeoOpen(false); setShowCreateModal(true); }; @@ -313,11 +431,23 @@ 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); }; @@ -331,6 +461,7 @@ export default function AdminBlogPage() { setTagsInput(''); setScheduledAt(''); setSeoOpen(false); + setGeoOpen(false); }; // Explicit close (X / Retour / after save): reset UI and pop the history @@ -407,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...
) : ( -
+
@@ -435,14 +592,16 @@ export default function AdminBlogPage() { - {posts.length === 0 ? ( + {visiblePosts.length === 0 ? ( ) : ( - posts.map(post => ( + visiblePosts.map(post => ( @@ -752,6 +952,144 @@ export default function AdminBlogPage() { )} + + {/* GEO / AI Panel */} +
+ + + {geoOpen && ( +
+

+ Ces champs aident les moteurs IA (ChatGPT, Perplexity…) et le référencement + structuré à comprendre et citer votre article. +

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