diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index db0bfa2..8c9a5dc 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -11,14 +11,17 @@ "@aws-sdk/client-s3": "^3.906.0", "@aws-sdk/lib-storage": "^3.906.0", "@aws-sdk/s3-request-presigner": "^3.906.0", + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^10.2.10", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.2.10", "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.2.10", + "@nestjs/platform-socket.io": "^10.4.20", "@nestjs/swagger": "^7.1.16", "@nestjs/typeorm": "^10.0.1", + "@nestjs/websockets": "^10.4.20", "@types/mjml": "^4.7.4", "@types/nodemailer": "^7.0.2", "@types/opossum": "^8.1.9", @@ -28,6 +31,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "exceljs": "^4.4.0", "handlebars": "^4.7.8", "helmet": "^7.1.0", "ioredis": "^5.8.1", @@ -47,6 +51,7 @@ "pino-pretty": "^10.3.0", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1", + "socket.io": "^4.8.1", "typeorm": "^0.3.17" }, "devDependencies": { @@ -1926,6 +1931,47 @@ "npm": ">=10" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -2676,6 +2722,17 @@ "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", "license": "MIT" }, + "node_modules/@nestjs/axios": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", + "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "axios": "^1.3.1", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", @@ -2954,6 +3011,25 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-socket.io": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.20.tgz", + "integrity": "sha512-8wqJ7kJnvRC6T1o1U3NNnuzjaMJU43R4hvzKKba7GSdMN6j2Jfzz/vq5gHDx9xbXOAmfsc9bvaIiZegXxvHoJA==", + "license": "MIT", + "dependencies": { + "socket.io": "4.8.1", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -3055,6 +3131,29 @@ "typeorm": "^0.3.0" } }, + "node_modules/@nestjs/websockets": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.20.tgz", + "integrity": "sha512-tafsPPvQfAXc+cfxvuRDzS5V+Ixg8uVJq8xSocU24yVl/Xp6ajmhqiGiaVjYOX8mXY0NV836QwEZxHF7WvKHSw==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-socket.io": "^10.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -3951,6 +4050,12 @@ "node": ">=18.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -4101,6 +4206,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -5124,6 +5238,124 @@ "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", "license": "ISC" }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -5206,6 +5438,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5399,6 +5637,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/base64url": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", @@ -5432,6 +5679,28 @@ "node": ">= 10.0.0" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5448,7 +5717,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -5456,6 +5724,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -5598,7 +5872,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -5619,6 +5892,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -5631,6 +5913,23 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -5749,6 +6048,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6126,6 +6437,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6223,7 +6549,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -6266,6 +6591,31 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -6674,6 +7024,51 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -6781,6 +7176,61 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -7206,6 +7656,44 @@ "node": ">=0.8.x" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -7351,6 +7839,19 @@ "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", "license": "MIT" }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7804,6 +8305,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -7876,6 +8383,78 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/fstream/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/fstream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fstream/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/fstream/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8098,7 +8677,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -8372,6 +8950,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9719,6 +10303,60 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/juice": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/juice/-/juice-10.0.1.tgz", @@ -9788,6 +10426,54 @@ "node": ">=6" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -9818,6 +10504,15 @@ "integrity": "sha512-RN3q3gImZ91BvRDYjWp7ICz3gRn81mW5L4SW+2afzNCC0I/nkXstBgZThQGTE3S/9q5J90FH4dP+TXx8NhdZKg==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -9844,6 +10539,12 @@ "dev": true, "license": "MIT" }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -9882,6 +10583,30 @@ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "license": "MIT" }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -9900,12 +10625,31 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -9924,6 +10668,12 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -9944,6 +10694,18 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -10905,6 +11667,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -11817,6 +12588,12 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", @@ -11996,6 +12773,27 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -12343,6 +13141,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -12510,6 +13320,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -12667,6 +13483,98 @@ "node": "*" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/sonic-boom": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz", @@ -13071,6 +13979,22 @@ "node": ">=10" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -13379,6 +14303,15 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -13901,6 +14834,60 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -14439,6 +15426,33 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -14513,6 +15527,84 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/zip-stream/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } } } } diff --git a/apps/backend/package.json b/apps/backend/package.json index fabad44..27a0f36 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -27,14 +27,17 @@ "@aws-sdk/client-s3": "^3.906.0", "@aws-sdk/lib-storage": "^3.906.0", "@aws-sdk/s3-request-presigner": "^3.906.0", + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^10.2.10", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.2.10", "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.2.10", + "@nestjs/platform-socket.io": "^10.4.20", "@nestjs/swagger": "^7.1.16", "@nestjs/typeorm": "^10.0.1", + "@nestjs/websockets": "^10.4.20", "@types/mjml": "^4.7.4", "@types/nodemailer": "^7.0.2", "@types/opossum": "^8.1.9", @@ -44,6 +47,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "exceljs": "^4.4.0", "handlebars": "^4.7.8", "helmet": "^7.1.0", "ioredis": "^5.8.1", @@ -63,6 +67,7 @@ "pino-pretty": "^10.3.0", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1", + "socket.io": "^4.8.1", "typeorm": "^0.3.17" }, "devDependencies": { diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 0d4049a..99cb59e 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -12,6 +12,9 @@ import { BookingsModule } from './application/bookings/bookings.module'; import { OrganizationsModule } from './application/organizations/organizations.module'; import { UsersModule } from './application/users/users.module'; import { DashboardModule } from './application/dashboard/dashboard.module'; +import { AuditModule } from './application/audit/audit.module'; +import { NotificationsModule } from './application/notifications/notifications.module'; +import { WebhooksModule } from './application/webhooks/webhooks.module'; import { CacheModule } from './infrastructure/cache/cache.module'; import { CarrierModule } from './infrastructure/carriers/carrier.module'; @@ -90,6 +93,9 @@ import { JwtAuthGuard } from './application/guards/jwt-auth.guard'; OrganizationsModule, UsersModule, DashboardModule, + AuditModule, + NotificationsModule, + WebhooksModule, ], controllers: [], providers: [ diff --git a/apps/backend/src/application/audit/audit.module.ts b/apps/backend/src/application/audit/audit.module.ts new file mode 100644 index 0000000..6a22295 --- /dev/null +++ b/apps/backend/src/application/audit/audit.module.ts @@ -0,0 +1,27 @@ +/** + * Audit Module + * + * Provides audit logging functionality + */ + +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditController } from '../controllers/audit.controller'; +import { AuditService } from '../services/audit.service'; +import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity'; +import { TypeOrmAuditLogRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-audit-log.repository'; +import { AUDIT_LOG_REPOSITORY } from '../../domain/ports/out/audit-log.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLogOrmEntity])], + controllers: [AuditController], + providers: [ + AuditService, + { + provide: AUDIT_LOG_REPOSITORY, + useClass: TypeOrmAuditLogRepository, + }, + ], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/apps/backend/src/application/bookings/bookings.module.ts b/apps/backend/src/application/bookings/bookings.module.ts index 360804a..f638282 100644 --- a/apps/backend/src/application/bookings/bookings.module.ts +++ b/apps/backend/src/application/bookings/bookings.module.ts @@ -19,11 +19,16 @@ import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities // Import services and domain import { BookingService } from '../../domain/services/booking.service'; import { BookingAutomationService } from '../services/booking-automation.service'; +import { ExportService } from '../services/export.service'; +import { FuzzySearchService } from '../services/fuzzy-search.service'; // Import infrastructure modules import { EmailModule } from '../../infrastructure/email/email.module'; import { PdfModule } from '../../infrastructure/pdf/pdf.module'; import { StorageModule } from '../../infrastructure/storage/storage.module'; +import { AuditModule } from '../audit/audit.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { WebhooksModule } from '../webhooks/webhooks.module'; /** * Bookings Module @@ -46,11 +51,16 @@ import { StorageModule } from '../../infrastructure/storage/storage.module'; EmailModule, PdfModule, StorageModule, + AuditModule, + NotificationsModule, + WebhooksModule, ], controllers: [BookingsController], providers: [ BookingService, BookingAutomationService, + ExportService, + FuzzySearchService, { provide: BOOKING_REPOSITORY, useClass: TypeOrmBookingRepository, diff --git a/apps/backend/src/application/controllers/audit.controller.ts b/apps/backend/src/application/controllers/audit.controller.ts new file mode 100644 index 0000000..587537a --- /dev/null +++ b/apps/backend/src/application/controllers/audit.controller.ts @@ -0,0 +1,218 @@ +/** + * Audit Log Controller + * + * Provides endpoints for querying audit logs + */ + +import { + Controller, + Get, + Param, + Query, + UseGuards, + ParseIntPipe, + DefaultValuePipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { AuditService } from '../services/audit.service'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; +import { Roles } from '../decorators/roles.decorator'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { AuditLog, AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity'; + +class AuditLogResponseDto { + id: string; + action: string; + status: string; + userId: string; + userEmail: string; + organizationId: string; + resourceType?: string; + resourceId?: string; + resourceName?: string; + metadata?: Record; + ipAddress?: string; + userAgent?: string; + errorMessage?: string; + timestamp: string; +} + +class AuditLogQueryDto { + userId?: string; + action?: AuditAction[]; + status?: AuditStatus[]; + resourceType?: string; + resourceId?: string; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; +} + +@ApiTags('Audit Logs') +@ApiBearerAuth() +@Controller('api/v1/audit-logs') +@UseGuards(JwtAuthGuard, RolesGuard) +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + /** + * Get audit logs with filters + * Only admins and managers can view audit logs + */ + @Get() + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Get audit logs with filters' }) + @ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' }) + @ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' }) + @ApiQuery({ name: 'action', required: false, description: 'Filter by action (comma-separated)', isArray: true }) + @ApiQuery({ name: 'status', required: false, description: 'Filter by status (comma-separated)', isArray: true }) + @ApiQuery({ name: 'resourceType', required: false, description: 'Filter by resource type' }) + @ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' }) + @ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' }) + @ApiQuery({ name: 'endDate', required: false, description: 'Filter by end date (ISO 8601)' }) + @ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' }) + @ApiQuery({ name: 'limit', required: false, description: 'Items per page (default: 50)' }) + async getAuditLogs( + @CurrentUser() user: UserPayload, + @Query('userId') userId?: string, + @Query('action') action?: string, + @Query('status') status?: string, + @Query('resourceType') resourceType?: string, + @Query('resourceId') resourceId?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, + ): Promise<{ logs: AuditLogResponseDto[]; total: number; page: number; pageSize: number }> { + page = page || 1; + limit = limit || 50; + const filters: any = { + organizationId: user.organizationId, + userId, + action: action ? action.split(',') : undefined, + status: status ? status.split(',') : undefined, + resourceType, + resourceId, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + offset: (page - 1) * limit, + limit, + }; + + const { logs, total } = await this.auditService.getAuditLogs(filters); + + return { + logs: logs.map((log) => this.mapToDto(log)), + total, + page, + pageSize: limit, + }; + } + + /** + * Get specific audit log by ID + */ + @Get(':id') + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Get audit log by ID' }) + @ApiResponse({ status: 200, description: 'Audit log retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Audit log not found' }) + async getAuditLogById( + @Param('id') id: string, + @CurrentUser() user: UserPayload, + ): Promise { + const log = await this.auditService.getAuditLogs({ + organizationId: user.organizationId, + limit: 1, + }); + + if (!log.logs.length) { + throw new Error('Audit log not found'); + } + + return this.mapToDto(log.logs[0]); + } + + /** + * Get audit trail for a specific resource + */ + @Get('resource/:type/:id') + @Roles('admin', 'manager', 'user') + @ApiOperation({ summary: 'Get audit trail for a specific resource' }) + @ApiResponse({ status: 200, description: 'Audit trail retrieved successfully' }) + async getResourceAuditTrail( + @Param('type') resourceType: string, + @Param('id') resourceId: string, + @CurrentUser() user: UserPayload, + ): Promise { + const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId); + + // Filter by organization for security + const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId); + + return filteredLogs.map((log) => this.mapToDto(log)); + } + + /** + * Get recent activity for current organization + */ + @Get('organization/activity') + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Get recent organization activity' }) + @ApiResponse({ status: 200, description: 'Organization activity retrieved successfully' }) + @ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' }) + async getOrganizationActivity( + @CurrentUser() user: UserPayload, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, + ): Promise { + limit = limit || 50; + const logs = await this.auditService.getOrganizationActivity(user.organizationId, limit); + return logs.map((log) => this.mapToDto(log)); + } + + /** + * Get user activity history + */ + @Get('user/:userId/activity') + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Get user activity history' }) + @ApiResponse({ status: 200, description: 'User activity retrieved successfully' }) + @ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' }) + async getUserActivity( + @CurrentUser() user: UserPayload, + @Param('userId') userId: string, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, + ): Promise { + limit = limit || 50; + const logs = await this.auditService.getUserActivity(userId, limit); + + // Filter by organization for security + const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId); + + return filteredLogs.map((log) => this.mapToDto(log)); + } + + /** + * Map domain entity to DTO + */ + private mapToDto(log: AuditLog): AuditLogResponseDto { + return { + id: log.id, + action: log.action, + status: log.status, + userId: log.userId, + userEmail: log.userEmail, + organizationId: log.organizationId, + resourceType: log.resourceType, + resourceId: log.resourceId, + resourceName: log.resourceName, + metadata: log.metadata, + ipAddress: log.ipAddress, + userAgent: log.userAgent, + errorMessage: log.errorMessage, + timestamp: log.timestamp.toISOString(), + }; + } +} diff --git a/apps/backend/src/application/controllers/bookings.controller.ts b/apps/backend/src/application/controllers/bookings.controller.ts index b2b2e26..77d8e54 100644 --- a/apps/backend/src/application/controllers/bookings.controller.ts +++ b/apps/backend/src/application/controllers/bookings.controller.ts @@ -15,6 +15,8 @@ import { ParseIntPipe, DefaultValuePipe, UseGuards, + Res, + StreamableFile, } from '@nestjs/common'; import { ApiTags, @@ -26,12 +28,16 @@ import { ApiQuery, ApiParam, ApiBearerAuth, + ApiProduces, } from '@nestjs/swagger'; +import { Response } from 'express'; import { CreateBookingRequestDto, BookingResponseDto, BookingListResponseDto, } from '../dto'; +import { BookingFilterDto } from '../dto/booking-filter.dto'; +import { BookingExportDto, ExportFormat } from '../dto/booking-export.dto'; import { BookingMapper } from '../mappers'; import { BookingService } from '../../domain/services/booking.service'; import { BookingRepository } from '../../domain/ports/out/booking.repository'; @@ -39,6 +45,14 @@ import { RateQuoteRepository } from '../../domain/ports/out/rate-quote.repositor import { BookingNumber } from '../../domain/value-objects/booking-number.vo'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { ExportService } from '../services/export.service'; +import { FuzzySearchService } from '../services/fuzzy-search.service'; +import { AuditService } from '../services/audit.service'; +import { AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity'; +import { NotificationService } from '../services/notification.service'; +import { NotificationsGateway } from '../gateways/notifications.gateway'; +import { WebhookService } from '../services/webhook.service'; +import { WebhookEvent } from '../../domain/entities/webhook.entity'; @ApiTags('Bookings') @Controller('api/v1/bookings') @@ -51,6 +65,12 @@ export class BookingsController { private readonly bookingService: BookingService, private readonly bookingRepository: BookingRepository, private readonly rateQuoteRepository: RateQuoteRepository, + private readonly exportService: ExportService, + private readonly fuzzySearchService: FuzzySearchService, + private readonly auditService: AuditService, + private readonly notificationService: NotificationService, + private readonly notificationsGateway: NotificationsGateway, + private readonly webhookService: WebhookService, ) {} @Post() @@ -111,12 +131,84 @@ export class BookingsController { `Booking created successfully: ${booking.bookingNumber.value} (${booking.id})`, ); + // Audit log: Booking created + await this.auditService.logSuccess( + AuditAction.BOOKING_CREATED, + user.id, + user.email, + user.organizationId, + { + resourceType: 'booking', + resourceId: booking.id, + resourceName: booking.bookingNumber.value, + metadata: { + rateQuoteId: dto.rateQuoteId, + status: booking.status.value, + carrier: rateQuote.carrierName, + }, + }, + ); + + // Send real-time notification + try { + const notification = await this.notificationService.notifyBookingCreated( + user.id, + user.organizationId, + booking.bookingNumber.value, + booking.id, + ); + await this.notificationsGateway.sendNotificationToUser(user.id, notification); + } catch (error: any) { + // Don't fail the booking creation if notification fails + this.logger.error(`Failed to send notification: ${error?.message}`); + } + + // Trigger webhooks + try { + await this.webhookService.triggerWebhooks( + WebhookEvent.BOOKING_CREATED, + user.organizationId, + { + bookingId: booking.id, + bookingNumber: booking.bookingNumber.value, + status: booking.status.value, + shipper: booking.shipper, + consignee: booking.consignee, + carrier: rateQuote.carrierName, + origin: rateQuote.origin, + destination: rateQuote.destination, + etd: rateQuote.etd?.toISOString(), + eta: rateQuote.eta?.toISOString(), + createdAt: booking.createdAt.toISOString(), + }, + ); + } catch (error: any) { + // Don't fail the booking creation if webhook fails + this.logger.error(`Failed to trigger webhooks: ${error?.message}`); + } + return response; } catch (error: any) { this.logger.error( `Booking creation failed: ${error?.message || 'Unknown error'}`, error?.stack, ); + + // Audit log: Booking creation failed + await this.auditService.logFailure( + AuditAction.BOOKING_CREATED, + user.id, + user.email, + user.organizationId, + error?.message || 'Unknown error', + { + resourceType: 'booking', + metadata: { + rateQuoteId: dto.rateQuoteId, + }, + }, + ); + throw error; } } @@ -312,4 +404,289 @@ export class BookingsController { totalPages, }; } + + @Get('search/fuzzy') + @ApiOperation({ + summary: 'Fuzzy search bookings', + description: + 'Search bookings using fuzzy matching. Tolerant to typos and partial matches. Searches across booking number, shipper, and consignee names.', + }) + @ApiQuery({ + name: 'q', + required: true, + description: 'Search query (minimum 2 characters)', + example: 'WCM-2025', + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Maximum number of results', + example: 20, + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Search results retrieved successfully', + type: [BookingResponseDto], + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + async fuzzySearch( + @Query('q') searchTerm: string, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log(`[User: ${user.email}] Fuzzy search: "${searchTerm}"`); + + if (!searchTerm || searchTerm.length < 2) { + return []; + } + + // Perform fuzzy search + const bookingOrms = await this.fuzzySearchService.search( + searchTerm, + user.organizationId, + limit, + ); + + // Map ORM entities to domain and fetch rate quotes + const bookingsWithQuotes = await Promise.all( + bookingOrms.map(async (bookingOrm) => { + const booking = await this.bookingRepository.findById(bookingOrm.id); + const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId); + return { booking: booking!, rateQuote: rateQuote! }; + }), + ); + + // Convert to DTOs + const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) => + BookingMapper.toDto(booking, rateQuote), + ); + + this.logger.log(`Fuzzy search returned ${bookingDtos.length} results`); + + return bookingDtos; + } + + @Get('advanced/search') + @ApiOperation({ + summary: 'Advanced booking search with filtering', + description: + 'Search bookings with advanced filtering options including status, date ranges, carrier, ports, shipper/consignee. Supports sorting and pagination.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Filtered bookings retrieved successfully', + type: BookingListResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + async advancedSearch( + @Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto, + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log( + `[User: ${user.email}] Advanced search with filters: ${JSON.stringify(filter)}`, + ); + + // Fetch all bookings for organization + let bookings = await this.bookingRepository.findByOrganization(user.organizationId); + + // Apply filters + bookings = this.applyFilters(bookings, filter); + + // Sort bookings + bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!); + + // Total count before pagination + const total = bookings.length; + + // Paginate + const startIndex = ((filter.page || 1) - 1) * (filter.pageSize || 20); + const endIndex = startIndex + (filter.pageSize || 20); + const paginatedBookings = bookings.slice(startIndex, endIndex); + + // Fetch rate quotes + const bookingsWithQuotes = await Promise.all( + paginatedBookings.map(async (booking) => { + const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); + return { booking, rateQuote: rateQuote! }; + }), + ); + + // Convert to DTOs + const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); + + const totalPages = Math.ceil(total / (filter.pageSize || 20)); + + return { + bookings: bookingDtos, + total, + page: filter.page || 1, + pageSize: filter.pageSize || 20, + totalPages, + }; + } + + @Post('export') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Export bookings to CSV/Excel/JSON', + description: + 'Export bookings with optional filtering. Supports CSV, Excel (xlsx), and JSON formats.', + }) + @ApiProduces('text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/json') + @ApiResponse({ + status: HttpStatus.OK, + description: 'Export file generated successfully', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + async exportBookings( + @Body(new ValidationPipe({ transform: true })) exportDto: BookingExportDto, + @Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto, + @CurrentUser() user: UserPayload, + @Res({ passthrough: true }) res: Response, + ): Promise { + this.logger.log( + `[User: ${user.email}] Exporting bookings to ${exportDto.format}`, + ); + + let bookings: any[]; + + // If specific booking IDs provided, use those + if (exportDto.bookingIds && exportDto.bookingIds.length > 0) { + bookings = await Promise.all( + exportDto.bookingIds.map((id) => this.bookingRepository.findById(id)), + ); + bookings = bookings.filter((b) => b !== null && b.organizationId === user.organizationId); + } else { + // Otherwise, use filter criteria + bookings = await this.bookingRepository.findByOrganization(user.organizationId); + bookings = this.applyFilters(bookings, filter); + } + + // Fetch rate quotes + const bookingsWithQuotes = await Promise.all( + bookings.map(async (booking) => { + const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); + return { booking, rateQuote: rateQuote! }; + }), + ); + + // Generate export file + const exportResult = await this.exportService.exportBookings( + bookingsWithQuotes, + exportDto.format, + exportDto.fields, + ); + + // Set response headers + res.set({ + 'Content-Type': exportResult.contentType, + 'Content-Disposition': `attachment; filename="${exportResult.filename}"`, + }); + + // Audit log: Data exported + await this.auditService.logSuccess( + AuditAction.DATA_EXPORTED, + user.id, + user.email, + user.organizationId, + { + resourceType: 'booking', + metadata: { + format: exportDto.format, + bookingCount: bookings.length, + fields: exportDto.fields?.join(', ') || 'all', + filename: exportResult.filename, + }, + }, + ); + + return new StreamableFile(exportResult.buffer); + } + + /** + * Apply filters to bookings array + */ + private applyFilters(bookings: any[], filter: BookingFilterDto): any[] { + let filtered = bookings; + + // Filter by status + if (filter.status && filter.status.length > 0) { + filtered = filtered.filter((b) => filter.status!.includes(b.status.value)); + } + + // Filter by search (booking number partial match) + if (filter.search) { + const searchLower = filter.search.toLowerCase(); + filtered = filtered.filter((b) => + b.bookingNumber.value.toLowerCase().includes(searchLower), + ); + } + + // Filter by shipper + if (filter.shipper) { + const shipperLower = filter.shipper.toLowerCase(); + filtered = filtered.filter((b) => + b.shipper.name.toLowerCase().includes(shipperLower), + ); + } + + // Filter by consignee + if (filter.consignee) { + const consigneeLower = filter.consignee.toLowerCase(); + filtered = filtered.filter((b) => + b.consignee.name.toLowerCase().includes(consigneeLower), + ); + } + + // Filter by creation date range + if (filter.createdFrom) { + const fromDate = new Date(filter.createdFrom); + filtered = filtered.filter((b) => b.createdAt >= fromDate); + } + if (filter.createdTo) { + const toDate = new Date(filter.createdTo); + filtered = filtered.filter((b) => b.createdAt <= toDate); + } + + return filtered; + } + + /** + * Sort bookings array + */ + private sortBookings(bookings: any[], sortBy: string, sortOrder: string): any[] { + return [...bookings].sort((a, b) => { + let aValue: any; + let bValue: any; + + switch (sortBy) { + case 'bookingNumber': + aValue = a.bookingNumber.value; + bValue = b.bookingNumber.value; + break; + case 'status': + aValue = a.status.value; + bValue = b.status.value; + break; + case 'createdAt': + default: + aValue = a.createdAt; + bValue = b.createdAt; + break; + } + + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } } diff --git a/apps/backend/src/application/controllers/notifications.controller.ts b/apps/backend/src/application/controllers/notifications.controller.ts new file mode 100644 index 0000000..87d1e6a --- /dev/null +++ b/apps/backend/src/application/controllers/notifications.controller.ts @@ -0,0 +1,209 @@ +/** + * Notifications Controller + * + * REST API endpoints for managing notifications + */ + +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Query, + UseGuards, + ParseIntPipe, + DefaultValuePipe, + NotFoundException, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { NotificationService } from '../services/notification.service'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { Notification } from '../../domain/entities/notification.entity'; + +class NotificationResponseDto { + id: string; + type: string; + priority: string; + title: string; + message: string; + metadata?: Record; + read: boolean; + readAt?: string; + actionUrl?: string; + createdAt: string; +} + +@ApiTags('Notifications') +@ApiBearerAuth() +@Controller('api/v1/notifications') +@UseGuards(JwtAuthGuard) +export class NotificationsController { + constructor(private readonly notificationService: NotificationService) {} + + /** + * Get user's notifications + */ + @Get() + @ApiOperation({ summary: 'Get user notifications' }) + @ApiResponse({ status: 200, description: 'Notifications retrieved successfully' }) + @ApiQuery({ name: 'read', required: false, description: 'Filter by read status' }) + @ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' }) + @ApiQuery({ name: 'limit', required: false, description: 'Items per page (default: 20)' }) + async getNotifications( + @CurrentUser() user: UserPayload, + @Query('read') read?: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number, + ): Promise<{ + notifications: NotificationResponseDto[]; + total: number; + page: number; + pageSize: number; + }> { + page = page || 1; + limit = limit || 20; + + const filters: any = { + userId: user.id, + read: read !== undefined ? read === 'true' : undefined, + offset: (page - 1) * limit, + limit, + }; + + const { notifications, total } = await this.notificationService.getNotifications(filters); + + return { + notifications: notifications.map((n) => this.mapToDto(n)), + total, + page, + pageSize: limit, + }; + } + + /** + * Get unread notifications + */ + @Get('unread') + @ApiOperation({ summary: 'Get unread notifications' }) + @ApiResponse({ status: 200, description: 'Unread notifications retrieved successfully' }) + @ApiQuery({ name: 'limit', required: false, description: 'Number of notifications (default: 50)' }) + async getUnreadNotifications( + @CurrentUser() user: UserPayload, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, + ): Promise { + limit = limit || 50; + const notifications = await this.notificationService.getUnreadNotifications(user.id, limit); + return notifications.map((n) => this.mapToDto(n)); + } + + /** + * Get unread count + */ + @Get('unread/count') + @ApiOperation({ summary: 'Get unread notifications count' }) + @ApiResponse({ status: 200, description: 'Unread count retrieved successfully' }) + async getUnreadCount(@CurrentUser() user: UserPayload): Promise<{ count: number }> { + const count = await this.notificationService.getUnreadCount(user.id); + return { count }; + } + + /** + * Get notification by ID + */ + @Get(':id') + @ApiOperation({ summary: 'Get notification by ID' }) + @ApiResponse({ status: 200, description: 'Notification retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Notification not found' }) + async getNotificationById( + @CurrentUser() user: UserPayload, + @Param('id') id: string, + ): Promise { + const notification = await this.notificationService.getNotificationById(id); + + if (!notification || notification.userId !== user.id) { + throw new NotFoundException('Notification not found'); + } + + return this.mapToDto(notification); + } + + /** + * Mark notification as read + */ + @Patch(':id/read') + @ApiOperation({ summary: 'Mark notification as read' }) + @ApiResponse({ status: 200, description: 'Notification marked as read' }) + @ApiResponse({ status: 404, description: 'Notification not found' }) + async markAsRead( + @CurrentUser() user: UserPayload, + @Param('id') id: string, + ): Promise<{ success: boolean }> { + const notification = await this.notificationService.getNotificationById(id); + + if (!notification || notification.userId !== user.id) { + throw new NotFoundException('Notification not found'); + } + + await this.notificationService.markAsRead(id); + return { success: true }; + } + + /** + * Mark all notifications as read + */ + @Post('read-all') + @ApiOperation({ summary: 'Mark all notifications as read' }) + @ApiResponse({ status: 200, description: 'All notifications marked as read' }) + async markAllAsRead(@CurrentUser() user: UserPayload): Promise<{ success: boolean }> { + await this.notificationService.markAllAsRead(user.id); + return { success: true }; + } + + /** + * Delete notification + */ + @Delete(':id') + @ApiOperation({ summary: 'Delete notification' }) + @ApiResponse({ status: 200, description: 'Notification deleted' }) + @ApiResponse({ status: 404, description: 'Notification not found' }) + async deleteNotification( + @CurrentUser() user: UserPayload, + @Param('id') id: string, + ): Promise<{ success: boolean }> { + const notification = await this.notificationService.getNotificationById(id); + + if (!notification || notification.userId !== user.id) { + throw new NotFoundException('Notification not found'); + } + + await this.notificationService.deleteNotification(id); + return { success: true }; + } + + /** + * Map notification entity to DTO + */ + private mapToDto(notification: Notification): NotificationResponseDto { + return { + id: notification.id, + type: notification.type, + priority: notification.priority, + title: notification.title, + message: notification.message, + metadata: notification.metadata, + read: notification.read, + readAt: notification.readAt?.toISOString(), + actionUrl: notification.actionUrl, + createdAt: notification.createdAt.toISOString(), + }; + } +} diff --git a/apps/backend/src/application/controllers/webhooks.controller.ts b/apps/backend/src/application/controllers/webhooks.controller.ts new file mode 100644 index 0000000..07348be --- /dev/null +++ b/apps/backend/src/application/controllers/webhooks.controller.ts @@ -0,0 +1,258 @@ +/** + * Webhooks Controller + * + * REST API endpoints for managing webhooks + */ + +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { WebhookService, CreateWebhookInput, UpdateWebhookInput } from '../services/webhook.service'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; +import { Roles } from '../decorators/roles.decorator'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { Webhook, WebhookEvent } from '../../domain/entities/webhook.entity'; + +class CreateWebhookDto { + url: string; + events: WebhookEvent[]; + description?: string; + headers?: Record; +} + +class UpdateWebhookDto { + url?: string; + events?: WebhookEvent[]; + description?: string; + headers?: Record; +} + +class WebhookResponseDto { + id: string; + url: string; + events: WebhookEvent[]; + status: string; + description?: string; + headers?: Record; + retryCount: number; + lastTriggeredAt?: string; + failureCount: number; + createdAt: string; + updatedAt: string; +} + +@ApiTags('Webhooks') +@ApiBearerAuth() +@Controller('api/v1/webhooks') +@UseGuards(JwtAuthGuard, RolesGuard) +export class WebhooksController { + constructor(private readonly webhookService: WebhookService) {} + + /** + * Create a new webhook + * Only admins and managers can create webhooks + */ + @Post() + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Create a new webhook' }) + @ApiResponse({ status: 201, description: 'Webhook created successfully' }) + async createWebhook( + @Body() dto: CreateWebhookDto, + @CurrentUser() user: UserPayload, + ): Promise { + const input: CreateWebhookInput = { + organizationId: user.organizationId, + url: dto.url, + events: dto.events, + description: dto.description, + headers: dto.headers, + }; + + const webhook = await this.webhookService.createWebhook(input); + return this.mapToDto(webhook); + } + + /** + * Get all webhooks for organization + */ + @Get() + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Get all webhooks for organization' }) + @ApiResponse({ status: 200, description: 'Webhooks retrieved successfully' }) + async getWebhooks(@CurrentUser() user: UserPayload): Promise { + const webhooks = await this.webhookService.getWebhooksByOrganization( + user.organizationId, + ); + return webhooks.map((w) => this.mapToDto(w)); + } + + /** + * Get webhook by ID + */ + @Get(':id') + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Get webhook by ID' }) + @ApiResponse({ status: 200, description: 'Webhook retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Webhook not found' }) + async getWebhookById( + @Param('id') id: string, + @CurrentUser() user: UserPayload, + ): Promise { + const webhook = await this.webhookService.getWebhookById(id); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + // Verify webhook belongs to user's organization + if (webhook.organizationId !== user.organizationId) { + throw new ForbiddenException('Access denied'); + } + + return this.mapToDto(webhook); + } + + /** + * Update webhook + */ + @Patch(':id') + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Update webhook' }) + @ApiResponse({ status: 200, description: 'Webhook updated successfully' }) + @ApiResponse({ status: 404, description: 'Webhook not found' }) + async updateWebhook( + @Param('id') id: string, + @Body() dto: UpdateWebhookDto, + @CurrentUser() user: UserPayload, + ): Promise { + const webhook = await this.webhookService.getWebhookById(id); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + // Verify webhook belongs to user's organization + if (webhook.organizationId !== user.organizationId) { + throw new ForbiddenException('Access denied'); + } + + const updatedWebhook = await this.webhookService.updateWebhook(id, dto); + return this.mapToDto(updatedWebhook); + } + + /** + * Activate webhook + */ + @Post(':id/activate') + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Activate webhook' }) + @ApiResponse({ status: 200, description: 'Webhook activated successfully' }) + @ApiResponse({ status: 404, description: 'Webhook not found' }) + async activateWebhook( + @Param('id') id: string, + @CurrentUser() user: UserPayload, + ): Promise<{ success: boolean }> { + const webhook = await this.webhookService.getWebhookById(id); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + // Verify webhook belongs to user's organization + if (webhook.organizationId !== user.organizationId) { + throw new ForbiddenException('Access denied'); + } + + await this.webhookService.activateWebhook(id); + return { success: true }; + } + + /** + * Deactivate webhook + */ + @Post(':id/deactivate') + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Deactivate webhook' }) + @ApiResponse({ status: 200, description: 'Webhook deactivated successfully' }) + @ApiResponse({ status: 404, description: 'Webhook not found' }) + async deactivateWebhook( + @Param('id') id: string, + @CurrentUser() user: UserPayload, + ): Promise<{ success: boolean }> { + const webhook = await this.webhookService.getWebhookById(id); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + // Verify webhook belongs to user's organization + if (webhook.organizationId !== user.organizationId) { + throw new ForbiddenException('Access denied'); + } + + await this.webhookService.deactivateWebhook(id); + return { success: true }; + } + + /** + * Delete webhook + */ + @Delete(':id') + @Roles('admin', 'manager') + @ApiOperation({ summary: 'Delete webhook' }) + @ApiResponse({ status: 200, description: 'Webhook deleted successfully' }) + @ApiResponse({ status: 404, description: 'Webhook not found' }) + async deleteWebhook( + @Param('id') id: string, + @CurrentUser() user: UserPayload, + ): Promise<{ success: boolean }> { + const webhook = await this.webhookService.getWebhookById(id); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + // Verify webhook belongs to user's organization + if (webhook.organizationId !== user.organizationId) { + throw new ForbiddenException('Access denied'); + } + + await this.webhookService.deleteWebhook(id); + return { success: true }; + } + + /** + * Map webhook entity to DTO (without exposing secret) + */ + private mapToDto(webhook: Webhook): WebhookResponseDto { + return { + id: webhook.id, + url: webhook.url, + events: webhook.events, + status: webhook.status, + description: webhook.description, + headers: webhook.headers, + retryCount: webhook.retryCount, + lastTriggeredAt: webhook.lastTriggeredAt?.toISOString(), + failureCount: webhook.failureCount, + createdAt: webhook.createdAt.toISOString(), + updatedAt: webhook.updatedAt.toISOString(), + }; + } +} diff --git a/apps/backend/src/application/dto/booking-export.dto.ts b/apps/backend/src/application/dto/booking-export.dto.ts new file mode 100644 index 0000000..e22737c --- /dev/null +++ b/apps/backend/src/application/dto/booking-export.dto.ts @@ -0,0 +1,68 @@ +/** + * Booking Export DTO + * + * Defines export format options + */ + +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsArray, IsString } from 'class-validator'; + +export enum ExportFormat { + CSV = 'csv', + EXCEL = 'excel', + JSON = 'json', +} + +export enum ExportField { + BOOKING_NUMBER = 'bookingNumber', + STATUS = 'status', + CREATED_AT = 'createdAt', + CARRIER = 'carrier', + ORIGIN = 'origin', + DESTINATION = 'destination', + ETD = 'etd', + ETA = 'eta', + SHIPPER = 'shipper', + CONSIGNEE = 'consignee', + CONTAINER_TYPE = 'containerType', + CONTAINER_COUNT = 'containerCount', + TOTAL_TEUS = 'totalTEUs', + PRICE = 'price', +} + +export class BookingExportDto { + @ApiProperty({ + description: 'Export format', + enum: ExportFormat, + example: ExportFormat.CSV, + }) + @IsEnum(ExportFormat) + format: ExportFormat; + + @ApiPropertyOptional({ + description: 'Fields to include in export (if omitted, all fields included)', + enum: ExportField, + isArray: true, + example: [ + ExportField.BOOKING_NUMBER, + ExportField.STATUS, + ExportField.CARRIER, + ExportField.ORIGIN, + ExportField.DESTINATION, + ], + }) + @IsOptional() + @IsArray() + @IsEnum(ExportField, { each: true }) + fields?: ExportField[]; + + @ApiPropertyOptional({ + description: 'Booking IDs to export (if omitted, exports filtered bookings)', + isArray: true, + example: ['550e8400-e29b-41d4-a716-446655440000'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + bookingIds?: string[]; +} diff --git a/apps/backend/src/application/dto/booking-filter.dto.ts b/apps/backend/src/application/dto/booking-filter.dto.ts new file mode 100644 index 0000000..2c91f9a --- /dev/null +++ b/apps/backend/src/application/dto/booking-filter.dto.ts @@ -0,0 +1,175 @@ +/** + * Advanced Booking Filter DTO + * + * Supports comprehensive filtering for booking searches + */ + +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsOptional, + IsString, + IsArray, + IsDateString, + IsEnum, + IsInt, + Min, + Max, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export enum BookingStatusFilter { + DRAFT = 'draft', + PENDING_CONFIRMATION = 'pending_confirmation', + CONFIRMED = 'confirmed', + IN_TRANSIT = 'in_transit', + DELIVERED = 'delivered', + CANCELLED = 'cancelled', +} + +export enum BookingSortField { + CREATED_AT = 'createdAt', + BOOKING_NUMBER = 'bookingNumber', + STATUS = 'status', + ETD = 'etd', + ETA = 'eta', +} + +export enum SortOrder { + ASC = 'asc', + DESC = 'desc', +} + +export class BookingFilterDto { + @ApiPropertyOptional({ + description: 'Page number (1-based)', + example: 1, + minimum: 1, + }) + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number = 1; + + @ApiPropertyOptional({ + description: 'Number of items per page', + example: 20, + minimum: 1, + maximum: 100, + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + pageSize?: number = 20; + + @ApiPropertyOptional({ + description: 'Filter by booking status (multiple)', + enum: BookingStatusFilter, + isArray: true, + example: ['confirmed', 'in_transit'], + }) + @IsOptional() + @IsArray() + @IsEnum(BookingStatusFilter, { each: true }) + status?: BookingStatusFilter[]; + + @ApiPropertyOptional({ + description: 'Search by booking number (partial match)', + example: 'WCM-2025', + }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ + description: 'Filter by carrier name or code', + example: 'Maersk', + }) + @IsOptional() + @IsString() + carrier?: string; + + @ApiPropertyOptional({ + description: 'Filter by origin port code', + example: 'NLRTM', + }) + @IsOptional() + @IsString() + originPort?: string; + + @ApiPropertyOptional({ + description: 'Filter by destination port code', + example: 'CNSHA', + }) + @IsOptional() + @IsString() + destinationPort?: string; + + @ApiPropertyOptional({ + description: 'Filter by shipper name (partial match)', + example: 'Acme Corp', + }) + @IsOptional() + @IsString() + shipper?: string; + + @ApiPropertyOptional({ + description: 'Filter by consignee name (partial match)', + example: 'XYZ Ltd', + }) + @IsOptional() + @IsString() + consignee?: string; + + @ApiPropertyOptional({ + description: 'Filter by creation date from (ISO 8601)', + example: '2025-01-01T00:00:00.000Z', + }) + @IsOptional() + @IsDateString() + createdFrom?: string; + + @ApiPropertyOptional({ + description: 'Filter by creation date to (ISO 8601)', + example: '2025-12-31T23:59:59.999Z', + }) + @IsOptional() + @IsDateString() + createdTo?: string; + + @ApiPropertyOptional({ + description: 'Filter by ETD from (ISO 8601)', + example: '2025-06-01T00:00:00.000Z', + }) + @IsOptional() + @IsDateString() + etdFrom?: string; + + @ApiPropertyOptional({ + description: 'Filter by ETD to (ISO 8601)', + example: '2025-06-30T23:59:59.999Z', + }) + @IsOptional() + @IsDateString() + etdTo?: string; + + @ApiPropertyOptional({ + description: 'Sort field', + enum: BookingSortField, + example: BookingSortField.CREATED_AT, + }) + @IsOptional() + @IsEnum(BookingSortField) + sortBy?: BookingSortField = BookingSortField.CREATED_AT; + + @ApiPropertyOptional({ + description: 'Sort order', + enum: SortOrder, + example: SortOrder.DESC, + }) + @IsOptional() + @IsEnum(SortOrder) + sortOrder?: SortOrder = SortOrder.DESC; +} diff --git a/apps/backend/src/application/dto/index.ts b/apps/backend/src/application/dto/index.ts index f97a07c..5340fdf 100644 --- a/apps/backend/src/application/dto/index.ts +++ b/apps/backend/src/application/dto/index.ts @@ -5,3 +5,5 @@ export * from './rate-search-response.dto'; // Booking DTOs export * from './create-booking-request.dto'; export * from './booking-response.dto'; +export * from './booking-filter.dto'; +export * from './booking-export.dto'; diff --git a/apps/backend/src/application/gateways/notifications.gateway.ts b/apps/backend/src/application/gateways/notifications.gateway.ts new file mode 100644 index 0000000..843d13c --- /dev/null +++ b/apps/backend/src/application/gateways/notifications.gateway.ts @@ -0,0 +1,243 @@ +/** + * Notifications WebSocket Gateway + * + * Handles real-time notification delivery via WebSocket + */ + +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, + ConnectedSocket, + MessageBody, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { Logger, UseGuards } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { NotificationService } from '../services/notification.service'; +import { Notification } from '../../domain/entities/notification.entity'; + +/** + * WebSocket authentication guard + */ +@UseGuards() +@WebSocketGateway({ + cors: { + origin: process.env.FRONTEND_URL || 'http://localhost:3000', + credentials: true, + }, + namespace: '/notifications', +}) +export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(NotificationsGateway.name); + private userSockets: Map> = new Map(); // userId -> Set of socket IDs + + constructor( + private readonly jwtService: JwtService, + private readonly notificationService: NotificationService, + ) {} + + /** + * Handle client connection + */ + async handleConnection(client: Socket) { + try { + // Extract JWT token from handshake + const token = this.extractToken(client); + if (!token) { + this.logger.warn(`Client ${client.id} connection rejected: No token provided`); + client.disconnect(); + return; + } + + // Verify JWT token + const payload = await this.jwtService.verifyAsync(token); + const userId = payload.sub; + + // Store socket connection for user + if (!this.userSockets.has(userId)) { + this.userSockets.set(userId, new Set()); + } + this.userSockets.get(userId)!.add(client.id); + + // Store user ID in socket data for later use + client.data.userId = userId; + client.data.organizationId = payload.organizationId; + + // Join user-specific room + client.join(`user:${userId}`); + + this.logger.log(`Client ${client.id} connected for user ${userId}`); + + // Send unread count on connection + const unreadCount = await this.notificationService.getUnreadCount(userId); + client.emit('unread_count', { count: unreadCount }); + + // Send recent notifications on connection + const recentNotifications = await this.notificationService.getRecentNotifications(userId, 10); + client.emit('recent_notifications', { + notifications: recentNotifications.map((n) => this.mapNotificationToDto(n)), + }); + } catch (error: any) { + this.logger.error( + `Error during client connection: ${error?.message || 'Unknown error'}`, + error?.stack, + ); + client.disconnect(); + } + } + + /** + * Handle client disconnection + */ + handleDisconnect(client: Socket) { + const userId = client.data.userId; + if (userId && this.userSockets.has(userId)) { + this.userSockets.get(userId)!.delete(client.id); + if (this.userSockets.get(userId)!.size === 0) { + this.userSockets.delete(userId); + } + } + this.logger.log(`Client ${client.id} disconnected`); + } + + /** + * Handle mark notification as read + */ + @SubscribeMessage('mark_as_read') + async handleMarkAsRead( + @ConnectedSocket() client: Socket, + @MessageBody() data: { notificationId: string }, + ) { + try { + const userId = client.data.userId; + await this.notificationService.markAsRead(data.notificationId); + + // Send updated unread count + const unreadCount = await this.notificationService.getUnreadCount(userId); + this.emitToUser(userId, 'unread_count', { count: unreadCount }); + + return { success: true }; + } catch (error: any) { + this.logger.error(`Error marking notification as read: ${error?.message}`); + return { success: false, error: error?.message }; + } + } + + /** + * Handle mark all notifications as read + */ + @SubscribeMessage('mark_all_as_read') + async handleMarkAllAsRead(@ConnectedSocket() client: Socket) { + try { + const userId = client.data.userId; + await this.notificationService.markAllAsRead(userId); + + // Send updated unread count (should be 0) + this.emitToUser(userId, 'unread_count', { count: 0 }); + + return { success: true }; + } catch (error: any) { + this.logger.error(`Error marking all notifications as read: ${error?.message}`); + return { success: false, error: error?.message }; + } + } + + /** + * Handle get unread count + */ + @SubscribeMessage('get_unread_count') + async handleGetUnreadCount(@ConnectedSocket() client: Socket) { + try { + const userId = client.data.userId; + const unreadCount = await this.notificationService.getUnreadCount(userId); + return { count: unreadCount }; + } catch (error: any) { + this.logger.error(`Error getting unread count: ${error?.message}`); + return { count: 0 }; + } + } + + /** + * Send notification to a specific user + */ + async sendNotificationToUser(userId: string, notification: Notification) { + const notificationDto = this.mapNotificationToDto(notification); + + // Emit to all connected sockets for this user + this.emitToUser(userId, 'new_notification', { notification: notificationDto }); + + // Update unread count + const unreadCount = await this.notificationService.getUnreadCount(userId); + this.emitToUser(userId, 'unread_count', { count: unreadCount }); + + this.logger.log(`Notification sent to user ${userId}: ${notification.title}`); + } + + /** + * Broadcast notification to organization + */ + async broadcastToOrganization(organizationId: string, notification: Notification) { + const notificationDto = this.mapNotificationToDto(notification); + this.server.to(`org:${organizationId}`).emit('new_notification', { + notification: notificationDto, + }); + + this.logger.log(`Notification broadcasted to organization ${organizationId}`); + } + + /** + * Helper: Emit event to all sockets of a user + */ + private emitToUser(userId: string, event: string, data: any) { + this.server.to(`user:${userId}`).emit(event, data); + } + + /** + * Helper: Extract JWT token from socket handshake + */ + private extractToken(client: Socket): string | null { + // Check Authorization header + const authHeader = client.handshake.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + // Check query parameter + const token = client.handshake.query.token; + if (typeof token === 'string') { + return token; + } + + // Check auth object (socket.io-client way) + const auth = client.handshake.auth; + if (auth && typeof auth.token === 'string') { + return auth.token; + } + + return null; + } + + /** + * Helper: Map notification entity to DTO + */ + private mapNotificationToDto(notification: Notification) { + return { + id: notification.id, + type: notification.type, + priority: notification.priority, + title: notification.title, + message: notification.message, + metadata: notification.metadata, + read: notification.read, + readAt: notification.readAt?.toISOString(), + actionUrl: notification.actionUrl, + createdAt: notification.createdAt.toISOString(), + }; + } +} diff --git a/apps/backend/src/application/notifications/notifications.module.ts b/apps/backend/src/application/notifications/notifications.module.ts new file mode 100644 index 0000000..3e4b16f --- /dev/null +++ b/apps/backend/src/application/notifications/notifications.module.ts @@ -0,0 +1,43 @@ +/** + * Notifications Module + * + * Provides notification functionality with WebSocket support + */ + +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { NotificationsController } from '../controllers/notifications.controller'; +import { NotificationsGateway } from '../gateways/notifications.gateway'; +import { NotificationService } from '../services/notification.service'; +import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity'; +import { TypeOrmNotificationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-notification.repository'; +import { NOTIFICATION_REPOSITORY } from '../../domain/ports/out/notification.repository'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([NotificationOrmEntity]), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_ACCESS_EXPIRATION', '15m'), + }, + }), + inject: [ConfigService], + }), + ], + controllers: [NotificationsController], + providers: [ + NotificationsGateway, + NotificationService, + { + provide: NOTIFICATION_REPOSITORY, + useClass: TypeOrmNotificationRepository, + }, + ], + exports: [NotificationService, NotificationsGateway], +}) +export class NotificationsModule {} diff --git a/apps/backend/src/application/services/audit.service.ts b/apps/backend/src/application/services/audit.service.ts new file mode 100644 index 0000000..33330e5 --- /dev/null +++ b/apps/backend/src/application/services/audit.service.ts @@ -0,0 +1,165 @@ +/** + * Audit Service + * + * Provides centralized audit logging functionality + * Tracks all important actions for security and compliance + */ + +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { + AuditLog, + AuditAction, + AuditStatus, +} from '../../domain/entities/audit-log.entity'; +import { + AuditLogRepository, + AUDIT_LOG_REPOSITORY, + AuditLogFilters, +} from '../../domain/ports/out/audit-log.repository'; + +export interface LogAuditInput { + action: AuditAction; + status: AuditStatus; + userId: string; + userEmail: string; + organizationId: string; + resourceType?: string; + resourceId?: string; + resourceName?: string; + metadata?: Record; + ipAddress?: string; + userAgent?: string; + errorMessage?: string; +} + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + + constructor( + @Inject(AUDIT_LOG_REPOSITORY) + private readonly auditLogRepository: AuditLogRepository, + ) {} + + /** + * Log an audit event + */ + async log(input: LogAuditInput): Promise { + try { + const auditLog = AuditLog.create({ + id: uuidv4(), + ...input, + }); + + await this.auditLogRepository.save(auditLog); + + this.logger.log( + `Audit log created: ${input.action} by ${input.userEmail} (${input.status})`, + ); + } catch (error: any) { + // Never throw on audit logging failure - log the error and continue + this.logger.error( + `Failed to create audit log: ${error?.message || 'Unknown error'}`, + error?.stack, + ); + } + } + + /** + * Log successful action + */ + async logSuccess( + action: AuditAction, + userId: string, + userEmail: string, + organizationId: string, + options?: { + resourceType?: string; + resourceId?: string; + resourceName?: string; + metadata?: Record; + ipAddress?: string; + userAgent?: string; + }, + ): Promise { + await this.log({ + action, + status: AuditStatus.SUCCESS, + userId, + userEmail, + organizationId, + ...options, + }); + } + + /** + * Log failed action + */ + async logFailure( + action: AuditAction, + userId: string, + userEmail: string, + organizationId: string, + errorMessage: string, + options?: { + resourceType?: string; + resourceId?: string; + metadata?: Record; + ipAddress?: string; + userAgent?: string; + }, + ): Promise { + await this.log({ + action, + status: AuditStatus.FAILURE, + userId, + userEmail, + organizationId, + errorMessage, + ...options, + }); + } + + /** + * Get audit logs with filters + */ + async getAuditLogs(filters: AuditLogFilters): Promise<{ + logs: AuditLog[]; + total: number; + }> { + const [logs, total] = await Promise.all([ + this.auditLogRepository.findByFilters(filters), + this.auditLogRepository.count(filters), + ]); + + return { logs, total }; + } + + /** + * Get audit trail for a specific resource + */ + async getResourceAuditTrail( + resourceType: string, + resourceId: string, + ): Promise { + return this.auditLogRepository.findByResource(resourceType, resourceId); + } + + /** + * Get recent activity for an organization + */ + async getOrganizationActivity( + organizationId: string, + limit: number = 50, + ): Promise { + return this.auditLogRepository.findRecentByOrganization(organizationId, limit); + } + + /** + * Get user activity history + */ + async getUserActivity(userId: string, limit: number = 50): Promise { + return this.auditLogRepository.findByUser(userId, limit); + } +} diff --git a/apps/backend/src/application/services/export.service.ts b/apps/backend/src/application/services/export.service.ts new file mode 100644 index 0000000..3d04cfe --- /dev/null +++ b/apps/backend/src/application/services/export.service.ts @@ -0,0 +1,265 @@ +/** + * Export Service + * + * Handles booking data export to various formats (CSV, Excel, JSON) + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { Booking } from '../../domain/entities/booking.entity'; +import { RateQuote } from '../../domain/entities/rate-quote.entity'; +import { ExportFormat, ExportField } from '../dto/booking-export.dto'; +import * as ExcelJS from 'exceljs'; + +interface BookingExportData { + booking: Booking; + rateQuote: RateQuote; +} + +@Injectable() +export class ExportService { + private readonly logger = new Logger(ExportService.name); + + /** + * Export bookings to specified format + */ + async exportBookings( + data: BookingExportData[], + format: ExportFormat, + fields?: ExportField[], + ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { + this.logger.log( + `Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`, + ); + + switch (format) { + case ExportFormat.CSV: + return this.exportToCSV(data, fields); + case ExportFormat.EXCEL: + return this.exportToExcel(data, fields); + case ExportFormat.JSON: + return this.exportToJSON(data, fields); + default: + throw new Error(`Unsupported export format: ${format}`); + } + } + + /** + * Export to CSV format + */ + private async exportToCSV( + data: BookingExportData[], + fields?: ExportField[], + ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { + const selectedFields = fields || Object.values(ExportField); + const rows = data.map((item) => this.extractFields(item, selectedFields)); + + // Build CSV header + const header = selectedFields.map((field) => this.getFieldLabel(field)).join(','); + + // Build CSV rows + const csvRows = rows.map((row) => + selectedFields.map((field) => this.escapeCSVValue(row[field] || '')).join(','), + ); + + const csv = [header, ...csvRows].join('\n'); + const buffer = Buffer.from(csv, 'utf-8'); + + const timestamp = new Date().toISOString().split('T')[0]; + const filename = `bookings_export_${timestamp}.csv`; + + return { + buffer, + contentType: 'text/csv', + filename, + }; + } + + /** + * Export to Excel format + */ + private async exportToExcel( + data: BookingExportData[], + fields?: ExportField[], + ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { + const selectedFields = fields || Object.values(ExportField); + const rows = data.map((item) => this.extractFields(item, selectedFields)); + + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Xpeditis'; + workbook.created = new Date(); + + const worksheet = workbook.addWorksheet('Bookings'); + + // Add header row with styling + const headerRow = worksheet.addRow( + selectedFields.map((field) => this.getFieldLabel(field)), + ); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' }, + }; + + // Add data rows + rows.forEach((row) => { + const values = selectedFields.map((field) => row[field] || ''); + worksheet.addRow(values); + }); + + // Auto-fit columns + worksheet.columns.forEach((column) => { + let maxLength = 10; + column.eachCell?.({ includeEmpty: false }, (cell) => { + const columnLength = cell.value ? String(cell.value).length : 10; + if (columnLength > maxLength) { + maxLength = columnLength; + } + }); + column.width = Math.min(maxLength + 2, 50); + }); + + const buffer = await workbook.xlsx.writeBuffer(); + + const timestamp = new Date().toISOString().split('T')[0]; + const filename = `bookings_export_${timestamp}.xlsx`; + + return { + buffer: Buffer.from(buffer), + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + filename, + }; + } + + /** + * Export to JSON format + */ + private async exportToJSON( + data: BookingExportData[], + fields?: ExportField[], + ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { + const selectedFields = fields || Object.values(ExportField); + const rows = data.map((item) => this.extractFields(item, selectedFields)); + + const json = JSON.stringify( + { + exportedAt: new Date().toISOString(), + totalBookings: rows.length, + bookings: rows, + }, + null, + 2, + ); + + const buffer = Buffer.from(json, 'utf-8'); + + const timestamp = new Date().toISOString().split('T')[0]; + const filename = `bookings_export_${timestamp}.json`; + + return { + buffer, + contentType: 'application/json', + filename, + }; + } + + /** + * Extract specified fields from booking data + */ + private extractFields( + data: BookingExportData, + fields: ExportField[], + ): Record { + const { booking, rateQuote } = data; + const result: Record = {}; + + fields.forEach((field) => { + switch (field) { + case ExportField.BOOKING_NUMBER: + result[field] = booking.bookingNumber.value; + break; + case ExportField.STATUS: + result[field] = booking.status.value; + break; + case ExportField.CREATED_AT: + result[field] = booking.createdAt.toISOString(); + break; + case ExportField.CARRIER: + result[field] = rateQuote.carrierName; + break; + case ExportField.ORIGIN: + result[field] = `${rateQuote.origin.name} (${rateQuote.origin.code})`; + break; + case ExportField.DESTINATION: + result[field] = `${rateQuote.destination.name} (${rateQuote.destination.code})`; + break; + case ExportField.ETD: + result[field] = rateQuote.etd.toISOString(); + break; + case ExportField.ETA: + result[field] = rateQuote.eta.toISOString(); + break; + case ExportField.SHIPPER: + result[field] = booking.shipper.name; + break; + case ExportField.CONSIGNEE: + result[field] = booking.consignee.name; + break; + case ExportField.CONTAINER_TYPE: + result[field] = booking.containers.map((c) => c.type).join(', '); + break; + case ExportField.CONTAINER_COUNT: + result[field] = booking.containers.length; + break; + case ExportField.TOTAL_TEUS: + result[field] = booking.containers.reduce((total, c) => { + return total + (c.type.startsWith('20') ? 1 : 2); + }, 0); + break; + case ExportField.PRICE: + result[field] = `${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`; + break; + } + }); + + return result; + } + + /** + * Get human-readable field label + */ + private getFieldLabel(field: ExportField): string { + const labels: Record = { + [ExportField.BOOKING_NUMBER]: 'Booking Number', + [ExportField.STATUS]: 'Status', + [ExportField.CREATED_AT]: 'Created At', + [ExportField.CARRIER]: 'Carrier', + [ExportField.ORIGIN]: 'Origin', + [ExportField.DESTINATION]: 'Destination', + [ExportField.ETD]: 'ETD', + [ExportField.ETA]: 'ETA', + [ExportField.SHIPPER]: 'Shipper', + [ExportField.CONSIGNEE]: 'Consignee', + [ExportField.CONTAINER_TYPE]: 'Container Type', + [ExportField.CONTAINER_COUNT]: 'Container Count', + [ExportField.TOTAL_TEUS]: 'Total TEUs', + [ExportField.PRICE]: 'Price', + }; + return labels[field]; + } + + /** + * Escape CSV value (handle commas, quotes, newlines) + */ + private escapeCSVValue(value: string): string { + const stringValue = String(value); + if ( + stringValue.includes(',') || + stringValue.includes('"') || + stringValue.includes('\n') + ) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + return stringValue; + } +} diff --git a/apps/backend/src/application/services/fuzzy-search.service.ts b/apps/backend/src/application/services/fuzzy-search.service.ts new file mode 100644 index 0000000..962ec96 --- /dev/null +++ b/apps/backend/src/application/services/fuzzy-search.service.ts @@ -0,0 +1,143 @@ +/** + * Fuzzy Search Service + * + * Provides fuzzy search capabilities for bookings using PostgreSQL full-text search + * and Levenshtein distance for typo tolerance + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity'; + +@Injectable() +export class FuzzySearchService { + private readonly logger = new Logger(FuzzySearchService.name); + + constructor( + @InjectRepository(BookingOrmEntity) + private readonly bookingOrmRepository: Repository, + ) {} + + /** + * Fuzzy search for bookings by booking number, shipper, or consignee + * Uses PostgreSQL full-text search with trigram similarity + */ + async fuzzySearchBookings( + searchTerm: string, + organizationId: string, + limit: number = 20, + ): Promise { + if (!searchTerm || searchTerm.length < 2) { + return []; + } + + this.logger.log( + `Fuzzy search for "${searchTerm}" in organization ${organizationId}`, + ); + + // Use PostgreSQL full-text search with similarity + // This requires pg_trgm extension to be enabled + const results = await this.bookingOrmRepository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.containers', 'containers') + .where('booking.organization_id = :organizationId', { organizationId }) + .andWhere( + `( + similarity(booking.booking_number, :searchTerm) > 0.3 + OR booking.booking_number ILIKE :likeTerm + OR similarity(booking.shipper_name, :searchTerm) > 0.3 + OR booking.shipper_name ILIKE :likeTerm + OR similarity(booking.consignee_name, :searchTerm) > 0.3 + OR booking.consignee_name ILIKE :likeTerm + )`, + { + searchTerm, + likeTerm: `%${searchTerm}%`, + }, + ) + .orderBy( + `GREATEST( + similarity(booking.booking_number, :searchTerm), + similarity(booking.shipper_name, :searchTerm), + similarity(booking.consignee_name, :searchTerm) + )`, + 'DESC', + ) + .setParameter('searchTerm', searchTerm) + .limit(limit) + .getMany(); + + this.logger.log(`Found ${results.length} results for fuzzy search`); + + return results; + } + + /** + * Search for bookings using PostgreSQL full-text search with ts_vector + * This provides better performance for large datasets + */ + async fullTextSearch( + searchTerm: string, + organizationId: string, + limit: number = 20, + ): Promise { + if (!searchTerm || searchTerm.length < 2) { + return []; + } + + this.logger.log( + `Full-text search for "${searchTerm}" in organization ${organizationId}`, + ); + + // Convert search term to tsquery format + const tsquery = searchTerm + .split(/\s+/) + .filter((term) => term.length > 0) + .map((term) => `${term}:*`) + .join(' & '); + + const results = await this.bookingOrmRepository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.containers', 'containers') + .where('booking.organization_id = :organizationId', { organizationId }) + .andWhere( + `( + to_tsvector('english', booking.booking_number) @@ to_tsquery('english', :tsquery) + OR to_tsvector('english', booking.shipper_name) @@ to_tsquery('english', :tsquery) + OR to_tsvector('english', booking.consignee_name) @@ to_tsquery('english', :tsquery) + OR booking.booking_number ILIKE :likeTerm + )`, + { + tsquery, + likeTerm: `%${searchTerm}%`, + }, + ) + .orderBy('booking.created_at', 'DESC') + .limit(limit) + .getMany(); + + this.logger.log(`Found ${results.length} results for full-text search`); + + return results; + } + + /** + * Combined search that tries fuzzy search first, falls back to full-text if no results + */ + async search( + searchTerm: string, + organizationId: string, + limit: number = 20, + ): Promise { + // Try fuzzy search first (more tolerant to typos) + let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit); + + // If no results, try full-text search + if (results.length === 0) { + results = await this.fullTextSearch(searchTerm, organizationId, limit); + } + + return results; + } +} diff --git a/apps/backend/src/application/services/notification.service.ts b/apps/backend/src/application/services/notification.service.ts new file mode 100644 index 0000000..8b74c7a --- /dev/null +++ b/apps/backend/src/application/services/notification.service.ts @@ -0,0 +1,218 @@ +/** + * Notification Service + * + * Handles creating and sending notifications to users + */ + +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { + Notification, + NotificationType, + NotificationPriority, +} from '../../domain/entities/notification.entity'; +import { + NotificationRepository, + NOTIFICATION_REPOSITORY, + NotificationFilters, +} from '../../domain/ports/out/notification.repository'; + +export interface CreateNotificationInput { + userId: string; + organizationId: string; + type: NotificationType; + priority: NotificationPriority; + title: string; + message: string; + metadata?: Record; + actionUrl?: string; +} + +@Injectable() +export class NotificationService { + private readonly logger = new Logger(NotificationService.name); + + constructor( + @Inject(NOTIFICATION_REPOSITORY) + private readonly notificationRepository: NotificationRepository, + ) {} + + /** + * Create and send a notification + */ + async createNotification(input: CreateNotificationInput): Promise { + try { + const notification = Notification.create({ + id: uuidv4(), + ...input, + }); + + await this.notificationRepository.save(notification); + + this.logger.log( + `Notification created: ${input.type} for user ${input.userId} - ${input.title}`, + ); + + return notification; + } catch (error: any) { + this.logger.error( + `Failed to create notification: ${error?.message || 'Unknown error'}`, + error?.stack, + ); + throw error; + } + } + + /** + * Get notifications with filters + */ + async getNotifications(filters: NotificationFilters): Promise<{ + notifications: Notification[]; + total: number; + }> { + const [notifications, total] = await Promise.all([ + this.notificationRepository.findByFilters(filters), + this.notificationRepository.count(filters), + ]); + + return { notifications, total }; + } + + /** + * Get notification by ID + */ + async getNotificationById(id: string): Promise { + return this.notificationRepository.findById(id); + } + + /** + * Get unread notifications for a user + */ + async getUnreadNotifications(userId: string, limit: number = 50): Promise { + return this.notificationRepository.findUnreadByUser(userId, limit); + } + + /** + * Get unread count for a user + */ + async getUnreadCount(userId: string): Promise { + return this.notificationRepository.countUnreadByUser(userId); + } + + /** + * Get recent notifications for a user + */ + async getRecentNotifications(userId: string, limit: number = 50): Promise { + return this.notificationRepository.findRecentByUser(userId, limit); + } + + /** + * Mark notification as read + */ + async markAsRead(id: string): Promise { + await this.notificationRepository.markAsRead(id); + this.logger.log(`Notification marked as read: ${id}`); + } + + /** + * Mark all notifications as read for a user + */ + async markAllAsRead(userId: string): Promise { + await this.notificationRepository.markAllAsReadForUser(userId); + this.logger.log(`All notifications marked as read for user: ${userId}`); + } + + /** + * Delete notification + */ + async deleteNotification(id: string): Promise { + await this.notificationRepository.delete(id); + this.logger.log(`Notification deleted: ${id}`); + } + + /** + * Cleanup old read notifications + */ + async cleanupOldNotifications(olderThanDays: number = 30): Promise { + const deleted = await this.notificationRepository.deleteOldReadNotifications(olderThanDays); + this.logger.log(`Cleaned up ${deleted} old read notifications`); + return deleted; + } + + /** + * Helper methods for creating specific notification types + */ + + async notifyBookingCreated( + userId: string, + organizationId: string, + bookingNumber: string, + bookingId: string, + ): Promise { + return this.createNotification({ + userId, + organizationId, + type: NotificationType.BOOKING_CREATED, + priority: NotificationPriority.MEDIUM, + title: 'Booking Created', + message: `Your booking ${bookingNumber} has been created successfully.`, + metadata: { bookingId, bookingNumber }, + actionUrl: `/bookings/${bookingId}`, + }); + } + + async notifyBookingUpdated( + userId: string, + organizationId: string, + bookingNumber: string, + bookingId: string, + status: string, + ): Promise { + return this.createNotification({ + userId, + organizationId, + type: NotificationType.BOOKING_UPDATED, + priority: NotificationPriority.MEDIUM, + title: 'Booking Updated', + message: `Booking ${bookingNumber} status changed to ${status}.`, + metadata: { bookingId, bookingNumber, status }, + actionUrl: `/bookings/${bookingId}`, + }); + } + + async notifyBookingConfirmed( + userId: string, + organizationId: string, + bookingNumber: string, + bookingId: string, + ): Promise { + return this.createNotification({ + userId, + organizationId, + type: NotificationType.BOOKING_CONFIRMED, + priority: NotificationPriority.HIGH, + title: 'Booking Confirmed', + message: `Your booking ${bookingNumber} has been confirmed by the carrier.`, + metadata: { bookingId, bookingNumber }, + actionUrl: `/bookings/${bookingId}`, + }); + } + + async notifyDocumentUploaded( + userId: string, + organizationId: string, + documentName: string, + bookingId: string, + ): Promise { + return this.createNotification({ + userId, + organizationId, + type: NotificationType.DOCUMENT_UPLOADED, + priority: NotificationPriority.LOW, + title: 'Document Uploaded', + message: `Document "${documentName}" has been uploaded for your booking.`, + metadata: { documentName, bookingId }, + actionUrl: `/bookings/${bookingId}`, + }); + } +} diff --git a/apps/backend/src/application/services/webhook.service.ts b/apps/backend/src/application/services/webhook.service.ts new file mode 100644 index 0000000..b6a499b --- /dev/null +++ b/apps/backend/src/application/services/webhook.service.ts @@ -0,0 +1,294 @@ +/** + * Webhook Service + * + * Handles webhook management and triggering + */ + +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { v4 as uuidv4 } from 'uuid'; +import * as crypto from 'crypto'; +import { firstValueFrom } from 'rxjs'; +import { + Webhook, + WebhookEvent, + WebhookStatus, +} from '../../domain/entities/webhook.entity'; +import { + WebhookRepository, + WEBHOOK_REPOSITORY, + WebhookFilters, +} from '../../domain/ports/out/webhook.repository'; + +export interface CreateWebhookInput { + organizationId: string; + url: string; + events: WebhookEvent[]; + description?: string; + headers?: Record; +} + +export interface UpdateWebhookInput { + url?: string; + events?: WebhookEvent[]; + description?: string; + headers?: Record; +} + +export interface WebhookPayload { + event: WebhookEvent; + timestamp: string; + data: any; + organizationId: string; +} + +@Injectable() +export class WebhookService { + private readonly logger = new Logger(WebhookService.name); + private readonly MAX_RETRIES = 3; + private readonly RETRY_DELAY_MS = 5000; + + constructor( + @Inject(WEBHOOK_REPOSITORY) + private readonly webhookRepository: WebhookRepository, + private readonly httpService: HttpService, + ) {} + + /** + * Create a new webhook + */ + async createWebhook(input: CreateWebhookInput): Promise { + const secret = this.generateSecret(); + + const webhook = Webhook.create({ + id: uuidv4(), + organizationId: input.organizationId, + url: input.url, + events: input.events, + secret, + description: input.description, + headers: input.headers, + }); + + await this.webhookRepository.save(webhook); + + this.logger.log( + `Webhook created: ${webhook.id} for organization ${input.organizationId}`, + ); + + return webhook; + } + + /** + * Get webhook by ID + */ + async getWebhookById(id: string): Promise { + return this.webhookRepository.findById(id); + } + + /** + * Get webhooks by organization + */ + async getWebhooksByOrganization(organizationId: string): Promise { + return this.webhookRepository.findByOrganization(organizationId); + } + + /** + * Get webhooks with filters + */ + async getWebhooks(filters: WebhookFilters): Promise { + return this.webhookRepository.findByFilters(filters); + } + + /** + * Update webhook + */ + async updateWebhook(id: string, updates: UpdateWebhookInput): Promise { + const webhook = await this.webhookRepository.findById(id); + if (!webhook) { + throw new Error('Webhook not found'); + } + + const updatedWebhook = webhook.update(updates); + await this.webhookRepository.save(updatedWebhook); + + this.logger.log(`Webhook updated: ${id}`); + + return updatedWebhook; + } + + /** + * Activate webhook + */ + async activateWebhook(id: string): Promise { + const webhook = await this.webhookRepository.findById(id); + if (!webhook) { + throw new Error('Webhook not found'); + } + + const activatedWebhook = webhook.activate(); + await this.webhookRepository.save(activatedWebhook); + + this.logger.log(`Webhook activated: ${id}`); + } + + /** + * Deactivate webhook + */ + async deactivateWebhook(id: string): Promise { + const webhook = await this.webhookRepository.findById(id); + if (!webhook) { + throw new Error('Webhook not found'); + } + + const deactivatedWebhook = webhook.deactivate(); + await this.webhookRepository.save(deactivatedWebhook); + + this.logger.log(`Webhook deactivated: ${id}`); + } + + /** + * Delete webhook + */ + async deleteWebhook(id: string): Promise { + await this.webhookRepository.delete(id); + this.logger.log(`Webhook deleted: ${id}`); + } + + /** + * Trigger webhooks for an event + */ + async triggerWebhooks( + event: WebhookEvent, + organizationId: string, + data: any, + ): Promise { + try { + const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId); + + if (webhooks.length === 0) { + this.logger.debug(`No active webhooks found for event: ${event}`); + return; + } + + const payload: WebhookPayload = { + event, + timestamp: new Date().toISOString(), + data, + organizationId, + }; + + // Trigger all webhooks in parallel + await Promise.allSettled( + webhooks.map((webhook) => this.triggerWebhook(webhook, payload)), + ); + + this.logger.log( + `Triggered ${webhooks.length} webhooks for event: ${event}`, + ); + } catch (error: any) { + this.logger.error( + `Error triggering webhooks: ${error?.message || 'Unknown error'}`, + error?.stack, + ); + } + } + + /** + * Trigger a single webhook with retries + */ + private async triggerWebhook( + webhook: Webhook, + payload: WebhookPayload, + ): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) { + try { + if (attempt > 0) { + await this.delay(this.RETRY_DELAY_MS * attempt); + } + + // Generate signature + const signature = this.generateSignature(payload, webhook.secret); + + // Prepare headers + const headers = { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signature, + 'X-Webhook-Event': payload.event, + 'X-Webhook-Timestamp': payload.timestamp, + ...webhook.headers, + }; + + // Send HTTP request + const response = await firstValueFrom( + this.httpService.post(webhook.url, payload, { + headers, + timeout: 10000, // 10 seconds + }), + ); + + if (response && response.status >= 200 && response.status < 300) { + // Success - record trigger + const updatedWebhook = webhook.recordTrigger(); + await this.webhookRepository.save(updatedWebhook); + + this.logger.log( + `Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`, + ); + return; + } + + lastError = new Error(`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`); + } catch (error: any) { + lastError = error; + this.logger.warn( + `Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`, + ); + } + } + + // All retries failed - mark webhook as failed + const failedWebhook = webhook.markAsFailed(); + await this.webhookRepository.save(failedWebhook); + + this.logger.error( + `Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`, + ); + } + + /** + * Generate webhook secret + */ + private generateSecret(): string { + return crypto.randomBytes(32).toString('hex'); + } + + /** + * Generate HMAC signature for webhook payload + */ + private generateSignature(payload: any, secret: string): string { + const hmac = crypto.createHmac('sha256', secret); + hmac.update(JSON.stringify(payload)); + return hmac.digest('hex'); + } + + /** + * Verify webhook signature + */ + verifySignature(payload: any, signature: string, secret: string): boolean { + const expectedSignature = this.generateSignature(payload, secret); + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature), + ); + } + + /** + * Delay helper for retries + */ + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/apps/backend/src/application/webhooks/webhooks.module.ts b/apps/backend/src/application/webhooks/webhooks.module.ts new file mode 100644 index 0000000..b28d410 --- /dev/null +++ b/apps/backend/src/application/webhooks/webhooks.module.ts @@ -0,0 +1,34 @@ +/** + * Webhooks Module + * + * Provides webhook functionality for external integrations + */ + +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HttpModule } from '@nestjs/axios'; +import { WebhooksController } from '../controllers/webhooks.controller'; +import { WebhookService } from '../services/webhook.service'; +import { WebhookOrmEntity } from '../../infrastructure/persistence/typeorm/entities/webhook.orm-entity'; +import { TypeOrmWebhookRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-webhook.repository'; +import { WEBHOOK_REPOSITORY } from '../../domain/ports/out/webhook.repository'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([WebhookOrmEntity]), + HttpModule.register({ + timeout: 10000, + maxRedirects: 5, + }), + ], + controllers: [WebhooksController], + providers: [ + WebhookService, + { + provide: WEBHOOK_REPOSITORY, + useClass: TypeOrmWebhookRepository, + }, + ], + exports: [WebhookService], +}) +export class WebhooksModule {} diff --git a/apps/backend/src/domain/entities/audit-log.entity.ts b/apps/backend/src/domain/entities/audit-log.entity.ts new file mode 100644 index 0000000..792a593 --- /dev/null +++ b/apps/backend/src/domain/entities/audit-log.entity.ts @@ -0,0 +1,174 @@ +/** + * AuditLog Entity + * + * Tracks all important actions in the system for security and compliance + * + * Business Rules: + * - Every sensitive action must be logged + * - Audit logs are immutable (cannot be edited or deleted) + * - Must capture user, action, resource, and timestamp + * - Support filtering and searching for compliance audits + */ + +export enum AuditAction { + // Booking actions + BOOKING_CREATED = 'booking_created', + BOOKING_UPDATED = 'booking_updated', + BOOKING_CANCELLED = 'booking_cancelled', + BOOKING_STATUS_CHANGED = 'booking_status_changed', + + // User actions + USER_LOGIN = 'user_login', + USER_LOGOUT = 'user_logout', + USER_CREATED = 'user_created', + USER_UPDATED = 'user_updated', + USER_DELETED = 'user_deleted', + USER_ROLE_CHANGED = 'user_role_changed', + + // Organization actions + ORGANIZATION_CREATED = 'organization_created', + ORGANIZATION_UPDATED = 'organization_updated', + + // Document actions + DOCUMENT_UPLOADED = 'document_uploaded', + DOCUMENT_DOWNLOADED = 'document_downloaded', + DOCUMENT_DELETED = 'document_deleted', + + // Rate actions + RATE_SEARCHED = 'rate_searched', + + // Export actions + DATA_EXPORTED = 'data_exported', + + // Settings actions + SETTINGS_UPDATED = 'settings_updated', +} + +export enum AuditStatus { + SUCCESS = 'success', + FAILURE = 'failure', + WARNING = 'warning', +} + +export interface AuditLogProps { + id: string; + action: AuditAction; + status: AuditStatus; + userId: string; + userEmail: string; + organizationId: string; + resourceType?: string; // e.g., 'booking', 'user', 'document' + resourceId?: string; + resourceName?: string; + metadata?: Record; // Additional context + ipAddress?: string; + userAgent?: string; + errorMessage?: string; + timestamp: Date; +} + +export class AuditLog { + private readonly props: AuditLogProps; + + private constructor(props: AuditLogProps) { + this.props = props; + } + + /** + * Factory method to create a new audit log entry + */ + static create(props: Omit & { id: string }): AuditLog { + return new AuditLog({ + ...props, + timestamp: new Date(), + }); + } + + /** + * Factory method to reconstitute from persistence + */ + static fromPersistence(props: AuditLogProps): AuditLog { + return new AuditLog(props); + } + + // Getters + get id(): string { + return this.props.id; + } + + get action(): AuditAction { + return this.props.action; + } + + get status(): AuditStatus { + return this.props.status; + } + + get userId(): string { + return this.props.userId; + } + + get userEmail(): string { + return this.props.userEmail; + } + + get organizationId(): string { + return this.props.organizationId; + } + + get resourceType(): string | undefined { + return this.props.resourceType; + } + + get resourceId(): string | undefined { + return this.props.resourceId; + } + + get resourceName(): string | undefined { + return this.props.resourceName; + } + + get metadata(): Record | undefined { + return this.props.metadata; + } + + get ipAddress(): string | undefined { + return this.props.ipAddress; + } + + get userAgent(): string | undefined { + return this.props.userAgent; + } + + get errorMessage(): string | undefined { + return this.props.errorMessage; + } + + get timestamp(): Date { + return this.props.timestamp; + } + + /** + * Check if action was successful + */ + isSuccessful(): boolean { + return this.props.status === AuditStatus.SUCCESS; + } + + /** + * Check if action failed + */ + isFailed(): boolean { + return this.props.status === AuditStatus.FAILURE; + } + + /** + * Convert to plain object + */ + toObject(): AuditLogProps { + return { + ...this.props, + metadata: this.props.metadata ? { ...this.props.metadata } : undefined, + }; + } +} diff --git a/apps/backend/src/domain/entities/notification.entity.ts b/apps/backend/src/domain/entities/notification.entity.ts new file mode 100644 index 0000000..be47cb6 --- /dev/null +++ b/apps/backend/src/domain/entities/notification.entity.ts @@ -0,0 +1,140 @@ +/** + * Notification Entity + * + * Represents a notification sent to a user + */ + +export enum NotificationType { + BOOKING_CREATED = 'booking_created', + BOOKING_UPDATED = 'booking_updated', + BOOKING_CANCELLED = 'booking_cancelled', + BOOKING_CONFIRMED = 'booking_confirmed', + RATE_QUOTE_EXPIRING = 'rate_quote_expiring', + DOCUMENT_UPLOADED = 'document_uploaded', + SYSTEM_ANNOUNCEMENT = 'system_announcement', + USER_INVITED = 'user_invited', + ORGANIZATION_UPDATE = 'organization_update', +} + +export enum NotificationPriority { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + URGENT = 'urgent', +} + +interface NotificationProps { + id: string; + userId: string; + organizationId: string; + type: NotificationType; + priority: NotificationPriority; + title: string; + message: string; + metadata?: Record; + read: boolean; + readAt?: Date; + actionUrl?: string; + createdAt: Date; +} + +export class Notification { + private constructor(private readonly props: NotificationProps) {} + + static create( + props: Omit & { id: string }, + ): Notification { + return new Notification({ + ...props, + read: false, + createdAt: new Date(), + }); + } + + static fromPersistence(props: NotificationProps): Notification { + return new Notification(props); + } + + get id(): string { + return this.props.id; + } + + get userId(): string { + return this.props.userId; + } + + get organizationId(): string { + return this.props.organizationId; + } + + get type(): NotificationType { + return this.props.type; + } + + get priority(): NotificationPriority { + return this.props.priority; + } + + get title(): string { + return this.props.title; + } + + get message(): string { + return this.props.message; + } + + get metadata(): Record | undefined { + return this.props.metadata; + } + + get read(): boolean { + return this.props.read; + } + + get readAt(): Date | undefined { + return this.props.readAt; + } + + get actionUrl(): string | undefined { + return this.props.actionUrl; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + /** + * Mark notification as read + */ + markAsRead(): Notification { + return new Notification({ + ...this.props, + read: true, + readAt: new Date(), + }); + } + + /** + * Check if notification is unread + */ + isUnread(): boolean { + return !this.props.read; + } + + /** + * Check if notification is high priority + */ + isHighPriority(): boolean { + return ( + this.props.priority === NotificationPriority.HIGH || + this.props.priority === NotificationPriority.URGENT + ); + } + + /** + * Convert to plain object + */ + toObject(): NotificationProps { + return { ...this.props }; + } +} diff --git a/apps/backend/src/domain/entities/webhook.entity.ts b/apps/backend/src/domain/entities/webhook.entity.ts new file mode 100644 index 0000000..10ea7ed --- /dev/null +++ b/apps/backend/src/domain/entities/webhook.entity.ts @@ -0,0 +1,195 @@ +/** + * Webhook Entity + * + * Represents a webhook subscription for external integrations + */ + +export enum WebhookEvent { + BOOKING_CREATED = 'booking.created', + BOOKING_UPDATED = 'booking.updated', + BOOKING_CANCELLED = 'booking.cancelled', + BOOKING_CONFIRMED = 'booking.confirmed', + RATE_QUOTE_CREATED = 'rate_quote.created', + DOCUMENT_UPLOADED = 'document.uploaded', + ORGANIZATION_UPDATED = 'organization.updated', + USER_CREATED = 'user.created', +} + +export enum WebhookStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + FAILED = 'failed', +} + +interface WebhookProps { + id: string; + organizationId: string; + url: string; + events: WebhookEvent[]; + secret: string; + status: WebhookStatus; + description?: string; + headers?: Record; + retryCount: number; + lastTriggeredAt?: Date; + failureCount: number; + createdAt: Date; + updatedAt: Date; +} + +export class Webhook { + private constructor(private readonly props: WebhookProps) {} + + static create( + props: Omit & { id: string }, + ): Webhook { + return new Webhook({ + ...props, + status: WebhookStatus.ACTIVE, + retryCount: 0, + failureCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + static fromPersistence(props: WebhookProps): Webhook { + return new Webhook(props); + } + + get id(): string { + return this.props.id; + } + + get organizationId(): string { + return this.props.organizationId; + } + + get url(): string { + return this.props.url; + } + + get events(): WebhookEvent[] { + return this.props.events; + } + + get secret(): string { + return this.props.secret; + } + + get status(): WebhookStatus { + return this.props.status; + } + + get description(): string | undefined { + return this.props.description; + } + + get headers(): Record | undefined { + return this.props.headers; + } + + get retryCount(): number { + return this.props.retryCount; + } + + get lastTriggeredAt(): Date | undefined { + return this.props.lastTriggeredAt; + } + + get failureCount(): number { + return this.props.failureCount; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + /** + * Check if webhook is active + */ + isActive(): boolean { + return this.props.status === WebhookStatus.ACTIVE; + } + + /** + * Check if webhook subscribes to an event + */ + subscribesToEvent(event: WebhookEvent): boolean { + return this.props.events.includes(event); + } + + /** + * Activate webhook + */ + activate(): Webhook { + return new Webhook({ + ...this.props, + status: WebhookStatus.ACTIVE, + updatedAt: new Date(), + }); + } + + /** + * Deactivate webhook + */ + deactivate(): Webhook { + return new Webhook({ + ...this.props, + status: WebhookStatus.INACTIVE, + updatedAt: new Date(), + }); + } + + /** + * Mark webhook as failed + */ + markAsFailed(): Webhook { + return new Webhook({ + ...this.props, + status: WebhookStatus.FAILED, + failureCount: this.props.failureCount + 1, + updatedAt: new Date(), + }); + } + + /** + * Record successful trigger + */ + recordTrigger(): Webhook { + return new Webhook({ + ...this.props, + lastTriggeredAt: new Date(), + retryCount: this.props.retryCount + 1, + failureCount: 0, // Reset failure count on success + updatedAt: new Date(), + }); + } + + /** + * Update webhook + */ + update(updates: { + url?: string; + events?: WebhookEvent[]; + description?: string; + headers?: Record; + }): Webhook { + return new Webhook({ + ...this.props, + ...updates, + updatedAt: new Date(), + }); + } + + /** + * Convert to plain object + */ + toObject(): WebhookProps { + return { ...this.props }; + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/audit-log.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/audit-log.orm-entity.ts new file mode 100644 index 0000000..2c3fa7a --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/audit-log.orm-entity.ts @@ -0,0 +1,88 @@ +import { Entity, Column, PrimaryColumn, Index, CreateDateColumn } from 'typeorm'; + +@Entity('audit_logs') +@Index(['organization_id', 'timestamp']) +@Index(['user_id', 'timestamp']) +@Index(['resource_type', 'resource_id']) +@Index(['action']) +export class AuditLogOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column({ + type: 'varchar', + length: 100, + }) + action: string; + + @Column({ + type: 'varchar', + length: 20, + }) + status: string; + + @Column('uuid') + @Index() + user_id: string; + + @Column({ + type: 'varchar', + length: 255, + }) + user_email: string; + + @Column('uuid') + @Index() + organization_id: string; + + @Column({ + type: 'varchar', + length: 100, + nullable: true, + }) + resource_type?: string; + + @Column({ + type: 'uuid', + nullable: true, + }) + resource_id?: string; + + @Column({ + type: 'varchar', + length: 500, + nullable: true, + }) + resource_name?: string; + + @Column({ + type: 'jsonb', + nullable: true, + }) + metadata?: Record; + + @Column({ + type: 'varchar', + length: 45, + nullable: true, + }) + ip_address?: string; + + @Column({ + type: 'text', + nullable: true, + }) + user_agent?: string; + + @Column({ + type: 'text', + nullable: true, + }) + error_message?: string; + + @CreateDateColumn({ + type: 'timestamp with time zone', + }) + @Index() + timestamp: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts new file mode 100644 index 0000000..2c6fea7 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts @@ -0,0 +1,53 @@ +/** + * Notification ORM Entity + */ + +import { + Entity, + PrimaryColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('notifications') +@Index(['user_id', 'read', 'created_at']) +@Index(['organization_id', 'created_at']) +@Index(['user_id', 'created_at']) +export class NotificationOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column('uuid') + user_id: string; + + @Column('uuid') + organization_id: string; + + @Column('varchar', { length: 50 }) + type: string; + + @Column('varchar', { length: 20 }) + priority: string; + + @Column('varchar', { length: 255 }) + title: string; + + @Column('text') + message: string; + + @Column('jsonb', { nullable: true }) + metadata?: Record; + + @Column('boolean', { default: false }) + read: boolean; + + @Column('timestamp', { nullable: true }) + read_at?: Date; + + @Column('varchar', { length: 500, nullable: true }) + action_url?: string; + + @CreateDateColumn() + created_at: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/webhook.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/webhook.orm-entity.ts new file mode 100644 index 0000000..476cae8 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/webhook.orm-entity.ts @@ -0,0 +1,55 @@ +/** + * Webhook ORM Entity + */ + +import { + Entity, + PrimaryColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('webhooks') +@Index(['organization_id', 'status']) +export class WebhookOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column('uuid') + organization_id: string; + + @Column('varchar', { length: 500 }) + url: string; + + @Column('simple-array') + events: string[]; + + @Column('varchar', { length: 255 }) + secret: string; + + @Column('varchar', { length: 20 }) + status: string; + + @Column('text', { nullable: true }) + description?: string; + + @Column('jsonb', { nullable: true }) + headers?: Record; + + @Column('int', { default: 0 }) + retry_count: number; + + @Column('timestamp', { nullable: true }) + last_triggered_at?: Date; + + @Column('int', { default: 0 }) + failure_count: number; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000000000-EnableFuzzySearch.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000000000-EnableFuzzySearch.ts new file mode 100644 index 0000000..51f82e9 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000000000-EnableFuzzySearch.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class EnableFuzzySearch1700000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Enable pg_trgm extension for trigram similarity search + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm;`); + + // Create GIN indexes for full-text search on bookings + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_booking_number_trgm + ON bookings USING gin(booking_number gin_trgm_ops); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_shipper_name_trgm + ON bookings USING gin(shipper_name gin_trgm_ops); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_consignee_name_trgm + ON bookings USING gin(consignee_name gin_trgm_ops); + `); + + // Create full-text search indexes using ts_vector + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_booking_number_fts + ON bookings USING gin(to_tsvector('english', booking_number)); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_shipper_name_fts + ON bookings USING gin(to_tsvector('english', shipper_name)); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_consignee_name_fts + ON bookings USING gin(to_tsvector('english', consignee_name)); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop indexes + await queryRunner.query(`DROP INDEX IF EXISTS idx_booking_number_trgm;`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_shipper_name_trgm;`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_consignee_name_trgm;`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_booking_number_fts;`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_shipper_name_fts;`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_consignee_name_fts;`); + + // Note: We don't drop the pg_trgm extension as other parts of the system might use it + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000001000-CreateAuditLogsTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000001000-CreateAuditLogsTable.ts new file mode 100644 index 0000000..024e235 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000001000-CreateAuditLogsTable.ts @@ -0,0 +1,137 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateAuditLogsTable1700000001000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'audit_logs', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + }, + { + name: 'action', + type: 'varchar', + length: '100', + isNullable: false, + }, + { + name: 'status', + type: 'varchar', + length: '20', + isNullable: false, + }, + { + name: 'user_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'user_email', + type: 'varchar', + length: '255', + isNullable: false, + }, + { + name: 'organization_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'resource_type', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'resource_id', + type: 'varchar', + length: '255', + isNullable: true, + }, + { + name: 'resource_name', + type: 'varchar', + length: '255', + isNullable: true, + }, + { + name: 'metadata', + type: 'jsonb', + isNullable: true, + }, + { + name: 'ip_address', + type: 'varchar', + length: '45', + isNullable: true, + }, + { + name: 'user_agent', + type: 'text', + isNullable: true, + }, + { + name: 'error_message', + type: 'text', + isNullable: true, + }, + { + name: 'timestamp', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true, + ); + + // Create indexes for efficient querying + await queryRunner.createIndex( + 'audit_logs', + new TableIndex({ + name: 'idx_audit_logs_organization_timestamp', + columnNames: ['organization_id', 'timestamp'], + }), + ); + + await queryRunner.createIndex( + 'audit_logs', + new TableIndex({ + name: 'idx_audit_logs_user_timestamp', + columnNames: ['user_id', 'timestamp'], + }), + ); + + await queryRunner.createIndex( + 'audit_logs', + new TableIndex({ + name: 'idx_audit_logs_resource', + columnNames: ['resource_type', 'resource_id'], + }), + ); + + await queryRunner.createIndex( + 'audit_logs', + new TableIndex({ + name: 'idx_audit_logs_action', + columnNames: ['action'], + }), + ); + + await queryRunner.createIndex( + 'audit_logs', + new TableIndex({ + name: 'idx_audit_logs_timestamp', + columnNames: ['timestamp'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('audit_logs'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000002000-CreateNotificationsTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000002000-CreateNotificationsTable.ts new file mode 100644 index 0000000..0f278c0 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000002000-CreateNotificationsTable.ts @@ -0,0 +1,109 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateNotificationsTable1700000002000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'notifications', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + }, + { + name: 'user_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'organization_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'type', + type: 'varchar', + length: '50', + isNullable: false, + }, + { + name: 'priority', + type: 'varchar', + length: '20', + isNullable: false, + }, + { + name: 'title', + type: 'varchar', + length: '255', + isNullable: false, + }, + { + name: 'message', + type: 'text', + isNullable: false, + }, + { + name: 'metadata', + type: 'jsonb', + isNullable: true, + }, + { + name: 'read', + type: 'boolean', + default: false, + isNullable: false, + }, + { + name: 'read_at', + type: 'timestamp', + isNullable: true, + }, + { + name: 'action_url', + type: 'varchar', + length: '500', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true, + ); + + // Create indexes for efficient querying + await queryRunner.createIndex( + 'notifications', + new TableIndex({ + name: 'idx_notifications_user_read_created', + columnNames: ['user_id', 'read', 'created_at'], + }), + ); + + await queryRunner.createIndex( + 'notifications', + new TableIndex({ + name: 'idx_notifications_organization_created', + columnNames: ['organization_id', 'created_at'], + }), + ); + + await queryRunner.createIndex( + 'notifications', + new TableIndex({ + name: 'idx_notifications_user_created', + columnNames: ['user_id', 'created_at'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('notifications'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000003000-CreateWebhooksTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000003000-CreateWebhooksTable.ts new file mode 100644 index 0000000..0df0396 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000003000-CreateWebhooksTable.ts @@ -0,0 +1,99 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateWebhooksTable1700000003000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'webhooks', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + }, + { + name: 'organization_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'url', + type: 'varchar', + length: '500', + isNullable: false, + }, + { + name: 'events', + type: 'text', + isNullable: false, + }, + { + name: 'secret', + type: 'varchar', + length: '255', + isNullable: false, + }, + { + name: 'status', + type: 'varchar', + length: '20', + isNullable: false, + }, + { + name: 'description', + type: 'text', + isNullable: true, + }, + { + name: 'headers', + type: 'jsonb', + isNullable: true, + }, + { + name: 'retry_count', + type: 'int', + default: 0, + isNullable: false, + }, + { + name: 'last_triggered_at', + type: 'timestamp', + isNullable: true, + }, + { + name: 'failure_count', + type: 'int', + default: 0, + isNullable: false, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + { + name: 'updated_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true, + ); + + // Create index for efficient querying + await queryRunner.createIndex( + 'webhooks', + new TableIndex({ + name: 'idx_webhooks_organization_status', + columnNames: ['organization_id', 'status'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('webhooks'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-audit-log.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-audit-log.repository.ts new file mode 100644 index 0000000..dabf25d --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-audit-log.repository.ts @@ -0,0 +1,208 @@ +/** + * TypeORM Audit Log Repository Implementation + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { + AuditLogRepository, + AuditLogFilters, +} from '../../../../domain/ports/out/audit-log.repository'; +import { AuditLog, AuditStatus, AuditAction } from '../../../../domain/entities/audit-log.entity'; +import { AuditLogOrmEntity } from '../entities/audit-log.orm-entity'; + +@Injectable() +export class TypeOrmAuditLogRepository implements AuditLogRepository { + constructor( + @InjectRepository(AuditLogOrmEntity) + private readonly ormRepository: Repository, + ) {} + + async save(auditLog: AuditLog): Promise { + const ormEntity = this.toOrm(auditLog); + await this.ormRepository.save(ormEntity); + } + + async findById(id: string): Promise { + const ormEntity = await this.ormRepository.findOne({ where: { id } }); + return ormEntity ? this.toDomain(ormEntity) : null; + } + + async findByFilters(filters: AuditLogFilters): Promise { + const query = this.ormRepository.createQueryBuilder('audit_log'); + + if (filters.userId) { + query.andWhere('audit_log.user_id = :userId', { userId: filters.userId }); + } + + if (filters.organizationId) { + query.andWhere('audit_log.organization_id = :organizationId', { + organizationId: filters.organizationId, + }); + } + + if (filters.action && filters.action.length > 0) { + query.andWhere('audit_log.action IN (:...actions)', { actions: filters.action }); + } + + if (filters.resourceType) { + query.andWhere('audit_log.resource_type = :resourceType', { + resourceType: filters.resourceType, + }); + } + + if (filters.resourceId) { + query.andWhere('audit_log.resource_id = :resourceId', { + resourceId: filters.resourceId, + }); + } + + if (filters.dateFrom) { + query.andWhere('audit_log.timestamp >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + query.andWhere('audit_log.timestamp <= :dateTo', { dateTo: filters.dateTo }); + } + + query.orderBy('audit_log.timestamp', 'DESC'); + + if (filters.limit) { + query.limit(filters.limit); + } + + if (filters.offset) { + query.offset(filters.offset); + } + + const ormEntities = await query.getMany(); + return ormEntities.map((e) => this.toDomain(e)); + } + + async count(filters: AuditLogFilters): Promise { + const query = this.ormRepository.createQueryBuilder('audit_log'); + + if (filters.userId) { + query.andWhere('audit_log.user_id = :userId', { userId: filters.userId }); + } + + if (filters.organizationId) { + query.andWhere('audit_log.organization_id = :organizationId', { + organizationId: filters.organizationId, + }); + } + + if (filters.action && filters.action.length > 0) { + query.andWhere('audit_log.action IN (:...actions)', { actions: filters.action }); + } + + if (filters.resourceType) { + query.andWhere('audit_log.resource_type = :resourceType', { + resourceType: filters.resourceType, + }); + } + + if (filters.resourceId) { + query.andWhere('audit_log.resource_id = :resourceId', { + resourceId: filters.resourceId, + }); + } + + if (filters.dateFrom) { + query.andWhere('audit_log.timestamp >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + query.andWhere('audit_log.timestamp <= :dateTo', { dateTo: filters.dateTo }); + } + + return query.getCount(); + } + + async findByResource(resourceType: string, resourceId: string): Promise { + const ormEntities = await this.ormRepository.find({ + where: { + resource_type: resourceType, + resource_id: resourceId, + }, + order: { + timestamp: 'DESC', + }, + }); + + return ormEntities.map((e) => this.toDomain(e)); + } + + async findRecentByOrganization(organizationId: string, limit: number): Promise { + const ormEntities = await this.ormRepository.find({ + where: { + organization_id: organizationId, + }, + order: { + timestamp: 'DESC', + }, + take: limit, + }); + + return ormEntities.map((e) => this.toDomain(e)); + } + + async findByUser(userId: string, limit: number): Promise { + const ormEntities = await this.ormRepository.find({ + where: { + user_id: userId, + }, + order: { + timestamp: 'DESC', + }, + take: limit, + }); + + return ormEntities.map((e) => this.toDomain(e)); + } + + /** + * Map ORM entity to domain entity + */ + private toDomain(ormEntity: AuditLogOrmEntity): AuditLog { + return AuditLog.fromPersistence({ + id: ormEntity.id, + action: ormEntity.action as AuditAction, + status: ormEntity.status as AuditStatus, + userId: ormEntity.user_id, + userEmail: ormEntity.user_email, + organizationId: ormEntity.organization_id, + resourceType: ormEntity.resource_type, + resourceId: ormEntity.resource_id, + resourceName: ormEntity.resource_name, + metadata: ormEntity.metadata, + ipAddress: ormEntity.ip_address, + userAgent: ormEntity.user_agent, + errorMessage: ormEntity.error_message, + timestamp: ormEntity.timestamp, + }); + } + + /** + * Map domain entity to ORM entity + */ + private toOrm(auditLog: AuditLog): AuditLogOrmEntity { + const ormEntity = new AuditLogOrmEntity(); + ormEntity.id = auditLog.id; + ormEntity.action = auditLog.action; + ormEntity.status = auditLog.status; + ormEntity.user_id = auditLog.userId; + ormEntity.user_email = auditLog.userEmail; + ormEntity.organization_id = auditLog.organizationId; + ormEntity.resource_type = auditLog.resourceType; + ormEntity.resource_id = auditLog.resourceId; + ormEntity.resource_name = auditLog.resourceName; + ormEntity.metadata = auditLog.metadata; + ormEntity.ip_address = auditLog.ipAddress; + ormEntity.user_agent = auditLog.userAgent; + ormEntity.error_message = auditLog.errorMessage; + ormEntity.timestamp = auditLog.timestamp; + return ormEntity; + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts new file mode 100644 index 0000000..95ea7b5 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts @@ -0,0 +1,219 @@ +/** + * TypeORM Notification Repository Implementation + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { + NotificationRepository, + NotificationFilters, +} from '../../../../domain/ports/out/notification.repository'; +import { Notification } from '../../../../domain/entities/notification.entity'; +import { NotificationOrmEntity } from '../entities/notification.orm-entity'; + +@Injectable() +export class TypeOrmNotificationRepository implements NotificationRepository { + constructor( + @InjectRepository(NotificationOrmEntity) + private readonly ormRepository: Repository, + ) {} + + async save(notification: Notification): Promise { + const ormEntity = this.toOrm(notification); + await this.ormRepository.save(ormEntity); + } + + async findById(id: string): Promise { + const ormEntity = await this.ormRepository.findOne({ where: { id } }); + return ormEntity ? this.toDomain(ormEntity) : null; + } + + async findByFilters(filters: NotificationFilters): Promise { + const query = this.ormRepository.createQueryBuilder('notification'); + + if (filters.userId) { + query.andWhere('notification.user_id = :userId', { userId: filters.userId }); + } + + if (filters.organizationId) { + query.andWhere('notification.organization_id = :organizationId', { + organizationId: filters.organizationId, + }); + } + + if (filters.type && filters.type.length > 0) { + query.andWhere('notification.type IN (:...types)', { types: filters.type }); + } + + if (filters.read !== undefined) { + query.andWhere('notification.read = :read', { read: filters.read }); + } + + if (filters.priority && filters.priority.length > 0) { + query.andWhere('notification.priority IN (:...priorities)', { + priorities: filters.priority, + }); + } + + if (filters.startDate) { + query.andWhere('notification.created_at >= :startDate', { + startDate: filters.startDate, + }); + } + + if (filters.endDate) { + query.andWhere('notification.created_at <= :endDate', { + endDate: filters.endDate, + }); + } + + query.orderBy('notification.created_at', 'DESC'); + + if (filters.offset) { + query.skip(filters.offset); + } + + if (filters.limit) { + query.take(filters.limit); + } + + const ormEntities = await query.getMany(); + return ormEntities.map((e) => this.toDomain(e)); + } + + async count(filters: NotificationFilters): Promise { + const query = this.ormRepository.createQueryBuilder('notification'); + + if (filters.userId) { + query.andWhere('notification.user_id = :userId', { userId: filters.userId }); + } + + if (filters.organizationId) { + query.andWhere('notification.organization_id = :organizationId', { + organizationId: filters.organizationId, + }); + } + + if (filters.type && filters.type.length > 0) { + query.andWhere('notification.type IN (:...types)', { types: filters.type }); + } + + if (filters.read !== undefined) { + query.andWhere('notification.read = :read', { read: filters.read }); + } + + if (filters.priority && filters.priority.length > 0) { + query.andWhere('notification.priority IN (:...priorities)', { + priorities: filters.priority, + }); + } + + if (filters.startDate) { + query.andWhere('notification.created_at >= :startDate', { + startDate: filters.startDate, + }); + } + + if (filters.endDate) { + query.andWhere('notification.created_at <= :endDate', { + endDate: filters.endDate, + }); + } + + return query.getCount(); + } + + async findUnreadByUser(userId: string, limit: number = 50): Promise { + const ormEntities = await this.ormRepository.find({ + where: { user_id: userId, read: false }, + order: { created_at: 'DESC' }, + take: limit, + }); + + return ormEntities.map((e) => this.toDomain(e)); + } + + async countUnreadByUser(userId: string): Promise { + return this.ormRepository.count({ + where: { user_id: userId, read: false }, + }); + } + + async findRecentByUser(userId: string, limit: number = 50): Promise { + const ormEntities = await this.ormRepository.find({ + where: { user_id: userId }, + order: { created_at: 'DESC' }, + take: limit, + }); + + return ormEntities.map((e) => this.toDomain(e)); + } + + async markAsRead(id: string): Promise { + await this.ormRepository.update(id, { + read: true, + read_at: new Date(), + }); + } + + async markAllAsReadForUser(userId: string): Promise { + await this.ormRepository.update( + { user_id: userId, read: false }, + { + read: true, + read_at: new Date(), + }, + ); + } + + async delete(id: string): Promise { + await this.ormRepository.delete(id); + } + + async deleteOldReadNotifications(olderThanDays: number): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - olderThanDays); + + const result = await this.ormRepository.delete({ + read: true, + read_at: LessThan(cutoffDate), + }); + + return result.affected || 0; + } + + private toDomain(ormEntity: NotificationOrmEntity): Notification { + return Notification.fromPersistence({ + id: ormEntity.id, + userId: ormEntity.user_id, + organizationId: ormEntity.organization_id, + type: ormEntity.type as any, + priority: ormEntity.priority as any, + title: ormEntity.title, + message: ormEntity.message, + metadata: ormEntity.metadata, + read: ormEntity.read, + readAt: ormEntity.read_at, + actionUrl: ormEntity.action_url, + createdAt: ormEntity.created_at, + }); + } + + private toOrm(notification: Notification): NotificationOrmEntity { + const ormEntity = new NotificationOrmEntity(); + ormEntity.id = notification.id; + ormEntity.user_id = notification.userId; + ormEntity.organization_id = notification.organizationId; + ormEntity.type = notification.type; + ormEntity.priority = notification.priority; + ormEntity.title = notification.title; + ormEntity.message = notification.message; + ormEntity.metadata = notification.metadata; + ormEntity.read = notification.read; + ormEntity.read_at = notification.readAt; + ormEntity.action_url = notification.actionUrl; + ormEntity.created_at = notification.createdAt; + return ormEntity; + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-webhook.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-webhook.repository.ts new file mode 100644 index 0000000..6082acc --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-webhook.repository.ts @@ -0,0 +1,120 @@ +/** + * TypeORM Webhook Repository Implementation + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + WebhookRepository, + WebhookFilters, +} from '../../../../domain/ports/out/webhook.repository'; +import { Webhook, WebhookEvent, WebhookStatus } from '../../../../domain/entities/webhook.entity'; +import { WebhookOrmEntity } from '../entities/webhook.orm-entity'; + +@Injectable() +export class TypeOrmWebhookRepository implements WebhookRepository { + constructor( + @InjectRepository(WebhookOrmEntity) + private readonly ormRepository: Repository, + ) {} + + async save(webhook: Webhook): Promise { + const ormEntity = this.toOrm(webhook); + await this.ormRepository.save(ormEntity); + } + + async findById(id: string): Promise { + const ormEntity = await this.ormRepository.findOne({ where: { id } }); + return ormEntity ? this.toDomain(ormEntity) : null; + } + + async findByOrganization(organizationId: string): Promise { + const ormEntities = await this.ormRepository.find({ + where: { organization_id: organizationId }, + order: { created_at: 'DESC' }, + }); + + return ormEntities.map((e) => this.toDomain(e)); + } + + async findActiveByEvent(event: WebhookEvent, organizationId: string): Promise { + const ormEntities = await this.ormRepository + .createQueryBuilder('webhook') + .where('webhook.organization_id = :organizationId', { organizationId }) + .andWhere('webhook.status = :status', { status: WebhookStatus.ACTIVE }) + .andWhere(':event = ANY(webhook.events)', { event }) + .getMany(); + + return ormEntities.map((e) => this.toDomain(e)); + } + + async findByFilters(filters: WebhookFilters): Promise { + const query = this.ormRepository.createQueryBuilder('webhook'); + + if (filters.organizationId) { + query.andWhere('webhook.organization_id = :organizationId', { + organizationId: filters.organizationId, + }); + } + + if (filters.status && filters.status.length > 0) { + query.andWhere('webhook.status IN (:...statuses)', { statuses: filters.status }); + } + + if (filters.event) { + query.andWhere(':event = ANY(webhook.events)', { event: filters.event }); + } + + query.orderBy('webhook.created_at', 'DESC'); + + const ormEntities = await query.getMany(); + return ormEntities.map((e) => this.toDomain(e)); + } + + async delete(id: string): Promise { + await this.ormRepository.delete(id); + } + + async countByOrganization(organizationId: string): Promise { + return this.ormRepository.count({ + where: { organization_id: organizationId }, + }); + } + + private toDomain(ormEntity: WebhookOrmEntity): Webhook { + return Webhook.fromPersistence({ + id: ormEntity.id, + organizationId: ormEntity.organization_id, + url: ormEntity.url, + events: ormEntity.events as WebhookEvent[], + secret: ormEntity.secret, + status: ormEntity.status as WebhookStatus, + description: ormEntity.description, + headers: ormEntity.headers, + retryCount: ormEntity.retry_count, + lastTriggeredAt: ormEntity.last_triggered_at, + failureCount: ormEntity.failure_count, + createdAt: ormEntity.created_at, + updatedAt: ormEntity.updated_at, + }); + } + + private toOrm(webhook: Webhook): WebhookOrmEntity { + const ormEntity = new WebhookOrmEntity(); + ormEntity.id = webhook.id; + ormEntity.organization_id = webhook.organizationId; + ormEntity.url = webhook.url; + ormEntity.events = webhook.events; + ormEntity.secret = webhook.secret; + ormEntity.status = webhook.status; + ormEntity.description = webhook.description; + ormEntity.headers = webhook.headers; + ormEntity.retry_count = webhook.retryCount; + ormEntity.last_triggered_at = webhook.lastTriggeredAt; + ormEntity.failure_count = webhook.failureCount; + ormEntity.created_at = webhook.createdAt; + ormEntity.updated_at = webhook.updatedAt; + return ormEntity; + } +} diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index da3b042..00650af 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -16,9 +16,13 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "@tanstack/react-query": "^5.90.2", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", "axios": "^1.12.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "date-fns": "^4.1.0", + "file-saver": "^2.0.5", "lucide-react": "^0.294.0", "next": "14.0.4", "react": "^18.2.0", @@ -27,6 +31,7 @@ "recharts": "^3.2.1", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "xlsx": "^0.18.5", "zod": "^3.25.76", "zustand": "^5.0.8" }, @@ -34,6 +39,7 @@ "@playwright/test": "^1.40.1", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", + "@types/file-saver": "^2.0.7", "@types/node": "^20.10.5", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", @@ -2424,6 +2430,66 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", @@ -2636,6 +2702,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3232,6 +3305,15 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -3984,6 +4066,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4123,6 +4218,15 @@ "node": ">= 0.12.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -4183,6 +4287,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4469,6 +4585,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5611,6 +5737,12 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5742,6 +5874,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -10031,6 +10172,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -11238,6 +11391,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -11327,6 +11498,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 07ac191..1c38cd4 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -21,9 +21,13 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "@tanstack/react-query": "^5.90.2", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", "axios": "^1.12.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "date-fns": "^4.1.0", + "file-saver": "^2.0.5", "lucide-react": "^0.294.0", "next": "14.0.4", "react": "^18.2.0", @@ -32,6 +36,7 @@ "recharts": "^3.2.1", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "xlsx": "^0.18.5", "zod": "^3.25.76", "zustand": "^5.0.8" }, @@ -39,6 +44,7 @@ "@playwright/test": "^1.40.1", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", + "@types/file-saver": "^2.0.7", "@types/node": "^20.10.5", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", diff --git a/apps/frontend/src/components/admin/CarrierForm.tsx b/apps/frontend/src/components/admin/CarrierForm.tsx new file mode 100644 index 0000000..a7c8eab --- /dev/null +++ b/apps/frontend/src/components/admin/CarrierForm.tsx @@ -0,0 +1,209 @@ +/** + * Carrier Form Component for Create/Edit + */ + +import React, { useState } from 'react'; +import { Carrier, CarrierStatus, CreateCarrierInput, UpdateCarrierInput } from '@/types/carrier'; + +interface CarrierFormProps { + carrier?: Carrier; + onSubmit: (data: CreateCarrierInput | UpdateCarrierInput) => Promise; + onCancel: () => void; +} + +export const CarrierForm: React.FC = ({ + carrier, + onSubmit, + onCancel, +}) => { + const [formData, setFormData] = useState({ + name: carrier?.name || '', + scac: carrier?.scac || '', + status: carrier?.status || CarrierStatus.ACTIVE, + apiEndpoint: carrier?.apiEndpoint || '', + apiKey: carrier?.apiKey || '', + priority: carrier?.priority?.toString() || '1', + rateLimit: carrier?.rateLimit?.toString() || '100', + timeout: carrier?.timeout?.toString() || '5000', + }); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + setError(null); + + try { + await onSubmit({ + name: formData.name, + scac: formData.scac, + status: formData.status, + apiEndpoint: formData.apiEndpoint || undefined, + apiKey: formData.apiKey || undefined, + priority: parseInt(formData.priority), + rateLimit: parseInt(formData.rateLimit), + timeout: parseInt(formData.timeout), + }); + } catch (err: any) { + setError(err.message || 'An error occurred'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ {error && ( +
+

{error}

+
+ )} + +
+ {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* SCAC */} +
+ + + setFormData({ ...formData, scac: e.target.value.toUpperCase() }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Status */} +
+ + +
+ + {/* Priority */} +
+ + setFormData({ ...formData, priority: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* API Endpoint */} +
+ + + setFormData({ ...formData, apiEndpoint: e.target.value }) + } + placeholder="https://api.carrier.com/v1" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* API Key */} +
+ + setFormData({ ...formData, apiKey: e.target.value })} + placeholder="Enter API key" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Rate Limit */} +
+ + setFormData({ ...formData, rateLimit: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Timeout */} +
+ + setFormData({ ...formData, timeout: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + {/* Actions */} +
+ + +
+
+ ); +}; diff --git a/apps/frontend/src/components/admin/index.ts b/apps/frontend/src/components/admin/index.ts new file mode 100644 index 0000000..2d63352 --- /dev/null +++ b/apps/frontend/src/components/admin/index.ts @@ -0,0 +1,5 @@ +/** + * Admin Components Barrel Export + */ + +export { CarrierForm } from './CarrierForm'; diff --git a/apps/frontend/src/components/bookings/BookingFilters.tsx b/apps/frontend/src/components/bookings/BookingFilters.tsx new file mode 100644 index 0000000..0d4ad26 --- /dev/null +++ b/apps/frontend/src/components/bookings/BookingFilters.tsx @@ -0,0 +1,246 @@ +/** + * Advanced Booking Filters Component + */ + +import React, { useState } from 'react'; +import { BookingFilters as IBookingFilters, BookingStatus } from '@/types/booking'; + +interface BookingFiltersProps { + filters: IBookingFilters; + onFiltersChange: (filters: Partial) => void; + onReset: () => void; +} + +export const BookingFilters: React.FC = ({ + filters, + onFiltersChange, + onReset, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + const handleStatusChange = (status: BookingStatus) => { + const currentStatuses = filters.status || []; + const newStatuses = currentStatuses.includes(status) + ? currentStatuses.filter((s) => s !== status) + : [...currentStatuses, status]; + + onFiltersChange({ status: newStatuses }); + }; + + return ( +
+
+

Filters

+
+ + +
+
+ + {/* Always visible filters */} +
+ {/* Search */} +
+ + onFiltersChange({ search: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Carrier */} +
+ + onFiltersChange({ carrier: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Origin Port */} +
+ + onFiltersChange({ originPort: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + {/* Status filters */} +
+ +
+ {Object.values(BookingStatus).map((status) => ( + + ))} +
+
+ + {/* Expanded filters */} + {isExpanded && ( +
+ {/* Destination Port */} +
+ + onFiltersChange({ destinationPort: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Shipper */} +
+ + onFiltersChange({ shipper: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Consignee */} +
+ + onFiltersChange({ consignee: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Created From */} +
+ + onFiltersChange({ createdFrom: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Created To */} +
+ + onFiltersChange({ createdTo: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* ETD From */} +
+ + onFiltersChange({ etdFrom: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* ETD To */} +
+ + onFiltersChange({ etdTo: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Sort By */} +
+ + +
+
+ )} + + {/* Active filters count */} + {Object.keys(filters).length > 0 && ( +
+ {Object.keys(filters).filter((key) => { + const value = filters[key as keyof IBookingFilters]; + return Array.isArray(value) ? value.length > 0 : Boolean(value); + }).length}{' '} + active filter(s) +
+ )} +
+ ); +}; diff --git a/apps/frontend/src/components/bookings/BookingsTable.tsx b/apps/frontend/src/components/bookings/BookingsTable.tsx new file mode 100644 index 0000000..e9e62ac --- /dev/null +++ b/apps/frontend/src/components/bookings/BookingsTable.tsx @@ -0,0 +1,273 @@ +/** + * Advanced Bookings Table with TanStack Table and Virtual Scrolling + */ + +import React, { useMemo, useRef } from 'react'; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + flexRender, + ColumnDef, + SortingState, +} from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { Booking, BookingStatus } from '@/types/booking'; +import { format } from 'date-fns'; + +interface BookingsTableProps { + bookings: Booking[]; + selectedBookings: Set; + onToggleSelection: (bookingId: string) => void; + onToggleAll: () => void; + onRowClick?: (booking: Booking) => void; +} + +export const BookingsTable: React.FC = ({ + bookings, + selectedBookings, + onToggleSelection, + onToggleAll, + onRowClick, +}) => { + const tableContainerRef = useRef(null); + const [sorting, setSorting] = React.useState([]); + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + 0} + onChange={onToggleAll} + className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500" + /> + ), + cell: ({ row }) => ( + onToggleSelection(row.original.id)} + onClick={(e) => e.stopPropagation()} + className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500" + /> + ), + size: 50, + }, + { + accessorKey: 'bookingNumber', + header: 'Booking #', + cell: (info) => ( + {info.getValue() as string} + ), + size: 150, + }, + { + accessorKey: 'status', + header: 'Status', + cell: (info) => { + const status = info.getValue() as BookingStatus; + return ( + + {status.replace('_', ' ').toUpperCase()} + + ); + }, + size: 120, + }, + { + accessorKey: 'rateQuote.carrierName', + header: 'Carrier', + cell: (info) => info.getValue() as string, + size: 150, + }, + { + accessorKey: 'rateQuote.origin', + header: 'Origin', + cell: (info) => info.getValue() as string, + size: 120, + }, + { + accessorKey: 'rateQuote.destination', + header: 'Destination', + cell: (info) => info.getValue() as string, + size: 120, + }, + { + accessorKey: 'shipper.name', + header: 'Shipper', + cell: (info) => info.getValue() as string, + size: 150, + }, + { + accessorKey: 'consignee.name', + header: 'Consignee', + cell: (info) => info.getValue() as string, + size: 150, + }, + { + accessorKey: 'rateQuote.etd', + header: 'ETD', + cell: (info) => { + const value = info.getValue(); + return value ? format(new Date(value as string), 'MMM dd, yyyy') : '-'; + }, + size: 120, + }, + { + accessorKey: 'rateQuote.eta', + header: 'ETA', + cell: (info) => { + const value = info.getValue(); + return value ? format(new Date(value as string), 'MMM dd, yyyy') : '-'; + }, + size: 120, + }, + { + accessorKey: 'containers', + header: 'Containers', + cell: (info) => { + const containers = info.getValue() as any[]; + return {containers.length}; + }, + size: 100, + }, + { + accessorKey: 'createdAt', + header: 'Created', + cell: (info) => format(new Date(info.getValue() as string), 'MMM dd, yyyy'), + size: 120, + }, + ], + [bookings.length, selectedBookings, onToggleAll, onToggleSelection] + ); + + const table = useReactTable({ + data: bookings, + columns, + state: { + sorting, + }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + const { rows } = table.getRowModel(); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => 60, + overscan: 10, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + + const paddingTop = virtualRows.length > 0 ? virtualRows[0]?.start || 0 : 0; + const paddingBottom = + virtualRows.length > 0 + ? totalSize - (virtualRows[virtualRows.length - 1]?.end || 0) + : 0; + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {paddingTop > 0 && ( + + + )} + {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + return ( + onRowClick?.(row.original)} + className={`hover:bg-gray-50 cursor-pointer ${ + selectedBookings.has(row.original.id) ? 'bg-blue-50' : '' + }`} + style={{ height: `${virtualRow.size}px` }} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + ); + })} + {paddingBottom > 0 && ( + + + )} + +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {header.column.getIsSorted() && ( + + {header.column.getIsSorted() === 'asc' ? '↑' : '↓'} + + )} +
+
+
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+
+
+ ); +}; + +function getStatusColor(status: BookingStatus): string { + switch (status) { + case BookingStatus.DRAFT: + return 'bg-gray-100 text-gray-800'; + case BookingStatus.CONFIRMED: + return 'bg-blue-100 text-blue-800'; + case BookingStatus.IN_PROGRESS: + return 'bg-yellow-100 text-yellow-800'; + case BookingStatus.COMPLETED: + return 'bg-green-100 text-green-800'; + case BookingStatus.CANCELLED: + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } +} diff --git a/apps/frontend/src/components/bookings/BulkActions.tsx b/apps/frontend/src/components/bookings/BulkActions.tsx new file mode 100644 index 0000000..baeb245 --- /dev/null +++ b/apps/frontend/src/components/bookings/BulkActions.tsx @@ -0,0 +1,102 @@ +/** + * Bulk Actions Component for Bookings + */ + +import React, { useState } from 'react'; +import { ExportFormat, ExportOptions } from '@/types/booking'; + +interface BulkActionsProps { + selectedCount: number; + onExport: (options: ExportOptions) => Promise; + onClearSelection: () => void; +} + +export const BulkActions: React.FC = ({ + selectedCount, + onExport, + onClearSelection, +}) => { + const [showExportMenu, setShowExportMenu] = useState(false); + const [exporting, setExporting] = useState(false); + + const handleExport = async (format: ExportFormat) => { + setExporting(true); + try { + await onExport({ format }); + setShowExportMenu(false); + } catch (error: any) { + alert(`Export failed: ${error.message}`); + } finally { + setExporting(false); + } + }; + + if (selectedCount === 0) return null; + + return ( +
+
+
+ + {selectedCount} booking{selectedCount !== 1 ? 's' : ''} selected + + +
+ +
+ {/* Export dropdown */} +
+ + + {showExportMenu && !exporting && ( +
+
+ + + +
+
+ )} +
+ + {/* Bulk update button */} + +
+
+
+ ); +}; diff --git a/apps/frontend/src/components/bookings/index.ts b/apps/frontend/src/components/bookings/index.ts new file mode 100644 index 0000000..3180f79 --- /dev/null +++ b/apps/frontend/src/components/bookings/index.ts @@ -0,0 +1,7 @@ +/** + * Bookings Components Barrel Export + */ + +export { BookingFilters } from './BookingFilters'; +export { BookingsTable } from './BookingsTable'; +export { BulkActions } from './BulkActions'; diff --git a/apps/frontend/src/hooks/useBookings.ts b/apps/frontend/src/hooks/useBookings.ts new file mode 100644 index 0000000..6cb8cba --- /dev/null +++ b/apps/frontend/src/hooks/useBookings.ts @@ -0,0 +1,152 @@ +/** + * Custom hook for managing bookings + */ + +import { useState, useEffect, useCallback } from 'react'; +import { Booking, BookingFilters, BookingListResponse, ExportOptions } from '@/types/booking'; + +export function useBookings(initialFilters?: BookingFilters) { + const [bookings, setBookings] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(initialFilters || {}); + const [selectedBookings, setSelectedBookings] = useState>(new Set()); + + const fetchBookings = useCallback(async () => { + setLoading(true); + setError(null); + + try { + // Build query parameters + const queryParams = new URLSearchParams(); + + if (filters.status && filters.status.length > 0) { + queryParams.append('status', filters.status.join(',')); + } + if (filters.search) queryParams.append('search', filters.search); + if (filters.carrier) queryParams.append('carrier', filters.carrier); + if (filters.originPort) queryParams.append('originPort', filters.originPort); + if (filters.destinationPort) queryParams.append('destinationPort', filters.destinationPort); + if (filters.shipper) queryParams.append('shipper', filters.shipper); + if (filters.consignee) queryParams.append('consignee', filters.consignee); + if (filters.createdFrom) queryParams.append('createdFrom', filters.createdFrom); + if (filters.createdTo) queryParams.append('createdTo', filters.createdTo); + if (filters.etdFrom) queryParams.append('etdFrom', filters.etdFrom); + if (filters.etdTo) queryParams.append('etdTo', filters.etdTo); + if (filters.sortBy) queryParams.append('sortBy', filters.sortBy); + if (filters.sortOrder) queryParams.append('sortOrder', filters.sortOrder); + + queryParams.append('page', String(filters.page || 1)); + queryParams.append('pageSize', String(filters.pageSize || 20)); + + const response = await fetch( + `/api/v1/bookings/advanced/search?${queryParams.toString()}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch bookings'); + } + + const data: BookingListResponse = await response.json(); + setBookings(data.bookings); + setTotal(data.total); + } catch (err: any) { + setError(err.message || 'An error occurred'); + } finally { + setLoading(false); + } + }, [filters]); + + useEffect(() => { + fetchBookings(); + }, [fetchBookings]); + + const updateFilters = useCallback((newFilters: Partial) => { + setFilters((prev) => ({ ...prev, ...newFilters })); + }, []); + + const resetFilters = useCallback(() => { + setFilters({}); + setSelectedBookings(new Set()); + }, []); + + const toggleBookingSelection = useCallback((bookingId: string) => { + setSelectedBookings((prev) => { + const newSet = new Set(prev); + if (newSet.has(bookingId)) { + newSet.delete(bookingId); + } else { + newSet.add(bookingId); + } + return newSet; + }); + }, []); + + const toggleAllBookings = useCallback(() => { + if (selectedBookings.size === bookings.length) { + setSelectedBookings(new Set()); + } else { + setSelectedBookings(new Set(bookings.map((b) => b.id))); + } + }, [bookings, selectedBookings]); + + const clearSelection = useCallback(() => { + setSelectedBookings(new Set()); + }, []); + + const exportBookings = useCallback(async (options: ExportOptions) => { + try { + const response = await fetch('/api/v1/bookings/export', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + body: JSON.stringify({ + format: options.format, + fields: options.fields, + bookingIds: options.bookingIds || Array.from(selectedBookings), + }), + }); + + if (!response.ok) { + throw new Error('Export failed'); + } + + // Download file + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bookings-export.${options.format}`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (err: any) { + throw new Error(err.message || 'Export failed'); + } + }, [selectedBookings]); + + return { + bookings, + total, + loading, + error, + filters, + selectedBookings, + updateFilters, + resetFilters, + toggleBookingSelection, + toggleAllBookings, + clearSelection, + exportBookings, + refetch: fetchBookings, + }; +} diff --git a/apps/frontend/src/pages/BookingsManagement.tsx b/apps/frontend/src/pages/BookingsManagement.tsx new file mode 100644 index 0000000..f9c6ab3 --- /dev/null +++ b/apps/frontend/src/pages/BookingsManagement.tsx @@ -0,0 +1,136 @@ +/** + * Advanced Bookings Management Page + */ + +import React from 'react'; +import { useBookings } from '@/hooks/useBookings'; +import { BookingFilters } from '@/components/bookings/BookingFilters'; +import { BookingsTable } from '@/components/bookings/BookingsTable'; +import { BulkActions } from '@/components/bookings/BulkActions'; + +export const BookingsManagement: React.FC = () => { + const { + bookings, + total, + loading, + error, + filters, + selectedBookings, + updateFilters, + resetFilters, + toggleBookingSelection, + toggleAllBookings, + clearSelection, + exportBookings, + } = useBookings({ page: 1, pageSize: 50 }); + + const handleRowClick = (booking: any) => { + // Navigate to booking details + window.location.href = `/bookings/${booking.id}`; + }; + + return ( +
+
+ {/* Header */} +
+

Bookings Management

+

+ Manage and filter your bookings with advanced search and bulk actions +

+
+ + {/* Filters */} + + + {/* Bulk Actions */} + + + {/* Results Summary */} +
+
+ Showing {bookings.length} of {total} bookings +
+ {loading && ( +
Loading...
+ )} +
+ + {/* Error State */} + {error && ( +
+

{error}

+
+ )} + + {/* Table */} + {!loading && bookings.length === 0 ? ( +
+ + + +

No bookings found

+

+ Try adjusting your filters or create a new booking +

+
+ ) : ( + + )} + + {/* Pagination */} + {total > (filters.pageSize || 50) && ( +
+ + + Page {filters.page || 1} of {Math.ceil(total / (filters.pageSize || 50))} + + +
+ )} +
+
+ ); +}; diff --git a/apps/frontend/src/pages/CarrierManagement.tsx b/apps/frontend/src/pages/CarrierManagement.tsx new file mode 100644 index 0000000..33bb004 --- /dev/null +++ b/apps/frontend/src/pages/CarrierManagement.tsx @@ -0,0 +1,268 @@ +/** + * Carrier Management Page + */ + +import React, { useState, useEffect } from 'react'; +import { Carrier, CarrierStatus, CreateCarrierInput, UpdateCarrierInput } from '@/types/carrier'; +import { CarrierForm } from '@/components/admin/CarrierForm'; + +export const CarrierManagement: React.FC = () => { + const [carriers, setCarriers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [editingCarrier, setEditingCarrier] = useState(null); + + useEffect(() => { + fetchCarriers(); + }, []); + + const fetchCarriers = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch('/api/v1/carriers', { + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + }); + + if (!response.ok) throw new Error('Failed to fetch carriers'); + + const data = await response.json(); + setCarriers(data); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleCreate = async (data: CreateCarrierInput) => { + const response = await fetch('/api/v1/carriers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + body: JSON.stringify(data), + }); + + if (!response.ok) throw new Error('Failed to create carrier'); + + await fetchCarriers(); + setShowForm(false); + }; + + const handleUpdate = async (data: UpdateCarrierInput) => { + if (!editingCarrier) return; + + const response = await fetch(`/api/v1/carriers/${editingCarrier.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + body: JSON.stringify(data), + }); + + if (!response.ok) throw new Error('Failed to update carrier'); + + await fetchCarriers(); + setEditingCarrier(null); + setShowForm(false); + }; + + const handleDelete = async (carrierId: string) => { + if (!confirm('Are you sure you want to delete this carrier?')) return; + + try { + const response = await fetch(`/api/v1/carriers/${carrierId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + }); + + if (!response.ok) throw new Error('Failed to delete carrier'); + + await fetchCarriers(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } + }; + + const handleToggleStatus = async (carrier: Carrier) => { + const newStatus = + carrier.status === CarrierStatus.ACTIVE + ? CarrierStatus.INACTIVE + : CarrierStatus.ACTIVE; + + try { + await handleUpdate({ status: newStatus }); + } catch (err: any) { + alert(`Error: ${err.message}`); + } + }; + + return ( +
+
+ {/* Header */} +
+
+

+ Carrier Management +

+

+ Manage carrier integrations and configurations +

+
+ +
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Form Modal */} + {showForm && ( +
+
+
+

+ {editingCarrier ? 'Edit Carrier' : 'Add New Carrier'} +

+ { + setShowForm(false); + setEditingCarrier(null); + }} + /> +
+
+
+ )} + + {/* Carriers Grid */} + {loading ? ( +
+
Loading carriers...
+
+ ) : carriers.length === 0 ? ( +
+ + + +

+ No carriers configured +

+

+ Get started by adding your first carrier integration +

+
+ ) : ( +
+ {carriers.map((carrier) => ( +
+ {/* Header */} +
+
+

+ {carrier.name} +

+

SCAC: {carrier.scac}

+
+ + {carrier.status.toUpperCase()} + +
+ + {/* Details */} +
+
+ Priority: + {carrier.priority} +
+
+ Rate Limit: + + {carrier.rateLimit || 'N/A'} req/min + +
+
+ Timeout: + + {carrier.timeout || 'N/A'} ms + +
+
+ + {/* Actions */} +
+ + + +
+
+ ))} +
+ )} +
+
+ ); +}; diff --git a/apps/frontend/src/pages/CarrierMonitoring.tsx b/apps/frontend/src/pages/CarrierMonitoring.tsx new file mode 100644 index 0000000..b8761a6 --- /dev/null +++ b/apps/frontend/src/pages/CarrierMonitoring.tsx @@ -0,0 +1,332 @@ +/** + * Carrier Monitoring Dashboard + */ + +import React, { useState, useEffect } from 'react'; +import { CarrierStats, CarrierHealthCheck } from '@/types/carrier'; + +export const CarrierMonitoring: React.FC = () => { + const [stats, setStats] = useState([]); + const [health, setHealth] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [timeRange, setTimeRange] = useState('24h'); + + useEffect(() => { + fetchMonitoringData(); + // Refresh every 30 seconds + const interval = setInterval(fetchMonitoringData, 30000); + return () => clearInterval(interval); + }, [timeRange]); + + const fetchMonitoringData = async () => { + setLoading(true); + setError(null); + + try { + const [statsRes, healthRes] = await Promise.all([ + fetch(`/api/v1/carriers/stats?timeRange=${timeRange}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + }), + fetch('/api/v1/carriers/health', { + headers: { + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }, + }), + ]); + + if (!statsRes.ok || !healthRes.ok) { + throw new Error('Failed to fetch monitoring data'); + } + + const [statsData, healthData] = await Promise.all([ + statsRes.json(), + healthRes.json(), + ]); + + setStats(statsData); + setHealth(healthData); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const getHealthStatus = (carrierId: string): CarrierHealthCheck | undefined => { + return health.find((h) => h.carrierId === carrierId); + }; + + const getHealthColor = (status: string) => { + switch (status) { + case 'healthy': + return 'bg-green-100 text-green-800'; + case 'degraded': + return 'bg-yellow-100 text-yellow-800'; + case 'down': + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + // Calculate overall stats + const totalRequests = stats.reduce((sum, s) => sum + s.totalRequests, 0); + const totalSuccessful = stats.reduce((sum, s) => sum + s.successfulRequests, 0); + const totalFailed = stats.reduce((sum, s) => sum + s.failedRequests, 0); + const overallSuccessRate = totalRequests > 0 ? (totalSuccessful / totalRequests) * 100 : 0; + const avgResponseTime = stats.length > 0 + ? stats.reduce((sum, s) => sum + s.averageResponseTime, 0) / stats.length + : 0; + + return ( +
+
+ {/* Header */} +
+
+

+ Carrier Monitoring +

+

+ Real-time monitoring of carrier API performance and health +

+
+
+ + +
+
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Overall Stats */} +
+
+
+ Total Requests +
+
+ {totalRequests.toLocaleString()} +
+
+ +
+
+ Success Rate +
+
+ {overallSuccessRate.toFixed(1)}% +
+
+ +
+
+ Failed Requests +
+
+ {totalFailed.toLocaleString()} +
+
+ +
+
+ Avg Response Time +
+
+ {avgResponseTime.toFixed(0)}ms +
+
+
+ + {/* Carrier Stats Table */} +
+
+

+ Carrier Performance +

+
+ + {stats.length === 0 ? ( +
+

No monitoring data available

+
+ ) : ( +
+ + + + + + + + + + + + + + + {stats.map((stat) => { + const healthStatus = getHealthStatus(stat.carrierId); + const successRate = (stat.successfulRequests / stat.totalRequests) * 100; + + return ( + + + + + + + + + + + ); + })} + +
+ Carrier + + Health + + Requests + + Success Rate + + Error Rate + + Avg Response + + Availability + + Last Request +
+
+ {stat.carrierName} +
+
+ {healthStatus && ( + + {healthStatus.status.toUpperCase()} + + )} + + {stat.totalRequests.toLocaleString()} + + = 95 + ? 'text-green-600' + : successRate >= 80 + ? 'text-yellow-600' + : 'text-red-600' + }`} + > + {successRate.toFixed(1)}% + + + + {stat.errorRate.toFixed(1)}% + + + {stat.averageResponseTime.toFixed(0)}ms + + = 99 + ? 'text-green-600' + : stat.availability >= 95 + ? 'text-yellow-600' + : 'text-red-600' + }`} + > + {stat.availability.toFixed(2)}% + + + {stat.lastRequestAt + ? new Date(stat.lastRequestAt).toLocaleString() + : 'Never'} +
+
+ )} +
+ + {/* Health Alerts */} + {health.some((h) => h.errors.length > 0) && ( +
+
+
+

+ Active Alerts +

+
+
+ {health + .filter((h) => h.errors.length > 0) + .map((healthCheck) => ( +
+
+
+ {stats.find((s) => s.carrierId === healthCheck.carrierId) + ?.carrierName || 'Unknown Carrier'} +
+ + {healthCheck.status.toUpperCase()} + +
+
    + {healthCheck.errors.map((error, index) => ( +
  • + • {error} +
  • + ))} +
+
+ ))} +
+
+
+ )} +
+
+ ); +}; diff --git a/apps/frontend/src/types/booking.ts b/apps/frontend/src/types/booking.ts new file mode 100644 index 0000000..dae0fa9 --- /dev/null +++ b/apps/frontend/src/types/booking.ts @@ -0,0 +1,100 @@ +/** + * Booking Types + */ + +export enum BookingStatus { + DRAFT = 'draft', + CONFIRMED = 'confirmed', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +export enum ContainerType { + DRY_20 = '20ft', + DRY_40 = '40ft', + HIGH_CUBE_40 = '40ft HC', + REEFER_20 = '20ft Reefer', + REEFER_40 = '40ft Reefer', +} + +export interface Address { + name: string; + street: string; + city: string; + state?: string; + postalCode: string; + country: string; +} + +export interface Container { + id: string; + type: ContainerType; + containerNumber?: string; + sealNumber?: string; + vgm?: number; + temperature?: number; +} + +export interface RateQuote { + id: string; + carrierName: string; + carrierScac: string; + origin: string; + destination: string; + priceValue: number; + priceCurrency: string; + etd?: string; + eta?: string; + transitDays?: number; + validUntil?: string; +} + +export interface Booking { + id: string; + bookingNumber: string; + status: BookingStatus; + shipper: Address; + consignee: Address; + containers: Container[]; + rateQuote: RateQuote; + createdAt: string; + updatedAt: string; +} + +export interface BookingFilters { + status?: BookingStatus[]; + search?: string; + carrier?: string; + originPort?: string; + destinationPort?: string; + shipper?: string; + consignee?: string; + createdFrom?: string; + createdTo?: string; + etdFrom?: string; + etdTo?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + page?: number; + pageSize?: number; +} + +export interface BookingListResponse { + bookings: Booking[]; + total: number; + page: number; + pageSize: number; +} + +export enum ExportFormat { + CSV = 'csv', + EXCEL = 'excel', + JSON = 'json', +} + +export interface ExportOptions { + format: ExportFormat; + fields?: string[]; + bookingIds?: string[]; +} diff --git a/apps/frontend/src/types/carrier.ts b/apps/frontend/src/types/carrier.ts new file mode 100644 index 0000000..b8b6a46 --- /dev/null +++ b/apps/frontend/src/types/carrier.ts @@ -0,0 +1,64 @@ +/** + * Carrier Types + */ + +export enum CarrierStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + MAINTENANCE = 'maintenance', +} + +export interface Carrier { + id: string; + name: string; + scac: string; + status: CarrierStatus; + apiEndpoint?: string; + apiKey?: string; + priority: number; + rateLimit?: number; + timeout?: number; + createdAt: string; + updatedAt: string; +} + +export interface CarrierStats { + carrierId: string; + carrierName: string; + totalRequests: number; + successfulRequests: number; + failedRequests: number; + averageResponseTime: number; + lastRequestAt?: string; + errorRate: number; + availability: number; +} + +export interface CarrierHealthCheck { + carrierId: string; + status: 'healthy' | 'degraded' | 'down'; + responseTime: number; + lastCheck: string; + errors: string[]; +} + +export interface CreateCarrierInput { + name: string; + scac: string; + apiEndpoint?: string; + apiKey?: string; + priority?: number; + rateLimit?: number; + timeout?: number; +} + +export interface UpdateCarrierInput { + name?: string; + scac?: string; + status?: CarrierStatus; + apiEndpoint?: string; + apiKey?: string; + priority?: number; + rateLimit?: number; + timeout?: number; +} diff --git a/apps/frontend/src/utils/export.ts b/apps/frontend/src/utils/export.ts new file mode 100644 index 0000000..579d843 --- /dev/null +++ b/apps/frontend/src/utils/export.ts @@ -0,0 +1,158 @@ +/** + * Client-side export utilities + */ + +import * as XLSX from 'xlsx'; +import { saveAs } from 'file-saver'; +import { Booking } from '@/types/booking'; + +export interface ExportField { + key: string; + label: string; + formatter?: (value: any) => string; +} + +const DEFAULT_BOOKING_FIELDS: ExportField[] = [ + { key: 'bookingNumber', label: 'Booking Number' }, + { key: 'status', label: 'Status' }, + { key: 'rateQuote.carrierName', label: 'Carrier' }, + { key: 'rateQuote.origin', label: 'Origin' }, + { key: 'rateQuote.destination', label: 'Destination' }, + { key: 'shipper.name', label: 'Shipper' }, + { key: 'consignee.name', label: 'Consignee' }, + { + key: 'rateQuote.etd', + label: 'ETD', + formatter: (value) => (value ? new Date(value).toLocaleDateString() : ''), + }, + { + key: 'rateQuote.eta', + label: 'ETA', + formatter: (value) => (value ? new Date(value).toLocaleDateString() : ''), + }, + { + key: 'containers', + label: 'Containers', + formatter: (value) => (Array.isArray(value) ? value.length.toString() : '0'), + }, + { + key: 'createdAt', + label: 'Created', + formatter: (value) => new Date(value).toLocaleDateString(), + }, +]; + +/** + * Get nested object value by key path + */ +function getNestedValue(obj: any, path: string): any { + return path.split('.').reduce((current, key) => current?.[key], obj); +} + +/** + * Export bookings to CSV + */ +export function exportToCSV( + data: Booking[], + fields: ExportField[] = DEFAULT_BOOKING_FIELDS, + filename: string = 'bookings-export.csv' +): void { + // Create CSV header + const header = fields.map((f) => f.label).join(','); + + // Create CSV rows + const rows = data.map((booking) => { + return fields + .map((field) => { + const value = getNestedValue(booking, field.key); + const formatted = field.formatter ? field.formatter(value) : value; + // Escape quotes and wrap in quotes if contains comma + const escaped = String(formatted || '') + .replace(/"/g, '""'); + return `"${escaped}"`; + }) + .join(','); + }); + + // Combine header and rows + const csv = [header, ...rows].join('\n'); + + // Create blob and download + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + saveAs(blob, filename); +} + +/** + * Export bookings to Excel + */ +export function exportToExcel( + data: Booking[], + fields: ExportField[] = DEFAULT_BOOKING_FIELDS, + filename: string = 'bookings-export.xlsx' +): void { + // Create worksheet data + const wsData = [ + // Header row + fields.map((f) => f.label), + // Data rows + ...data.map((booking) => + fields.map((field) => { + const value = getNestedValue(booking, field.key); + return field.formatter ? field.formatter(value) : value || ''; + }) + ), + ]; + + // Create worksheet + const ws = XLSX.utils.aoa_to_sheet(wsData); + + // Set column widths + ws['!cols'] = fields.map(() => ({ wch: 20 })); + + // Create workbook + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Bookings'); + + // Generate Excel file + const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); + const blob = new Blob([excelBuffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + saveAs(blob, filename); +} + +/** + * Export bookings to JSON + */ +export function exportToJSON( + data: Booking[], + filename: string = 'bookings-export.json' +): void { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json;charset=utf-8;' }); + saveAs(blob, filename); +} + +/** + * Export bookings based on format + */ +export function exportBookings( + data: Booking[], + format: 'csv' | 'excel' | 'json', + fields?: ExportField[], + filename?: string +): void { + switch (format) { + case 'csv': + exportToCSV(data, fields, filename); + break; + case 'excel': + exportToExcel(data, fields, filename); + break; + case 'json': + exportToJSON(data, filename); + break; + default: + throw new Error(`Unsupported export format: ${format}`); + } +}