diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 6b0fc7b..5cc1a46 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -1,7 +1,7 @@ # Meshkee CMS API — Project Context > Living reference for developers and AI assistants working on this codebase. -> Last updated: August 14, 2026 +> Last updated: August 18, 2026 ## What This Project Is @@ -504,6 +504,8 @@ PATCH /businesses/:businessId/store-items/variants/:variantId } ``` +**Bulk Excel (dashboard):** `GET /businesses/:businessId/store-items/export` downloads one row per variant (`variant_id`, names, variation, `price`, `stock`). `POST /businesses/:businessId/store-items/import` (multipart `file`) updates only price and stock. Empty price = call for price; empty stock = unlimited. Does not add or remove variants. + ### Technical forms (product categories) - One form per product category diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 2ce670a..ff5558e 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -31,7 +31,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` 2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials -3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings) +3. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings) 4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). 5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` - For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl` diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index 81e8026..6234119 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -1197,6 +1197,7 @@ "name": "Get published product by slug", "request": { "method": "GET", + "description": "Product details plus relatedProducts (same category then same brand; in-stock first within each group).", "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}" } }, diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index c0e3a88..74351f8 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -449,7 +449,7 @@ ], "responses": { "200": { - "description": "{ product }" + "description": "{ product, relatedProducts } — relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product." } } } diff --git a/package-lock.json b/package-lock.json index 6fdfe56..fe5e2c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "exceljs": "^4.4.0", "ioredis": "^5.4.1", "mysql2": "^3.23.2", "passport": "^0.7.0", @@ -538,6 +539,47 @@ "tslib": "^2.4.0" } }, + "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/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -2410,6 +2452,96 @@ "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/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "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/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", @@ -2445,6 +2577,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/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -2464,7 +2602,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -2508,11 +2645,32 @@ "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/bl": { "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", @@ -2520,6 +2678,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": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2611,7 +2775,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", @@ -2632,6 +2795,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", @@ -2644,6 +2816,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", @@ -2766,6 +2955,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", @@ -2997,6 +3198,21 @@ "node": ">= 6" } }, + "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", @@ -3081,6 +3297,12 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -3125,6 +3347,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-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -3149,6 +3396,12 @@ "url": "https://ko-fi.com/intcreator" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3309,6 +3562,45 @@ "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/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/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3367,6 +3659,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", @@ -3524,6 +3825,26 @@ "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/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -3597,6 +3918,19 @@ "node": ">=8.0.0" } }, + "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", @@ -3719,6 +4053,12 @@ "node": ">= 0.8" } }, + "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", @@ -3771,6 +4111,68 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "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/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "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/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "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", @@ -3951,7 +4353,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/has-flag": { @@ -4063,6 +4464,12 @@ ], "license": "BSD-3-Clause" }, + "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", @@ -4179,6 +4586,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/iterare": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", @@ -4328,6 +4741,48 @@ "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/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/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4349,12 +4804,63 @@ "safe-buffer": "^5.0.1" } }, + "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/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/libphonenumber-js": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.8.tgz", "integrity": "sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==", "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/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4362,6 +4868,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/load-esm": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", @@ -4401,6 +4913,36 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "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", @@ -4413,12 +4955,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", @@ -4437,12 +4998,30 @@ "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.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "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", @@ -4642,7 +5221,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4898,6 +5476,15 @@ "node": ">=6" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -5025,6 +5612,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5235,6 +5828,12 @@ } } }, + "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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5344,6 +5943,36 @@ "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/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5514,6 +6143,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", @@ -5630,6 +6271,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "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", @@ -5960,6 +6607,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", @@ -6115,6 +6778,15 @@ "node": ">=18" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -6148,6 +6820,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/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -6329,6 +7010,54 @@ "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/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.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6385,6 +7114,16 @@ "node": ">= 0.4.0" } }, + "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==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -6585,6 +7324,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "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/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -6623,6 +7368,62 @@ "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/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "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" + } } } } diff --git a/package.json b/package.json index f79bcb9..26c4759 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "exceljs": "^4.4.0", "ioredis": "^5.4.1", "mysql2": "^3.23.2", "passport": "^0.7.0", diff --git a/src/common/interceptors/bigint-serializer.interceptor.ts b/src/common/interceptors/bigint-serializer.interceptor.ts index 5d3a6bd..82e81a8 100644 --- a/src/common/interceptors/bigint-serializer.interceptor.ts +++ b/src/common/interceptors/bigint-serializer.interceptor.ts @@ -3,6 +3,7 @@ import { ExecutionContext, Injectable, NestInterceptor, + StreamableFile, } from '@nestjs/common'; import { Observable, map } from 'rxjs'; @@ -15,6 +16,14 @@ function serializeBigInt(value: unknown): unknown { return value.toISOString(); } + if (value instanceof StreamableFile) { + return value; + } + + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + return value; + } + if (Array.isArray(value)) { return value.map(serializeBigInt); } diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index 4c6f044..e4eac09 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -18,6 +18,8 @@ type CustomerListRow = { cellNumber: string; firstName: string | null; lastName: string | null; + firstNameEn: string | null; + lastNameEn: string | null; email: string | null; createdAt: Date; isEnabled: boolean; @@ -63,7 +65,10 @@ export class CustomersService { AND ( u.first_name ILIKE ${nameLike} OR u.last_name ILIKE ${nameLike} + OR u.first_name_en ILIKE ${nameLike} + OR u.last_name_en ILIKE ${nameLike} OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike} + OR (COALESCE(u.first_name_en, '') || ' ' || COALESCE(u.last_name_en, '')) ILIKE ${nameLike} ) ` : Prisma.empty} ${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty} @@ -76,6 +81,8 @@ export class CustomersService { u.cell_number AS "cellNumber", u.first_name AS "firstName", u.last_name AS "lastName", + u.first_name_en AS "firstNameEn", + u.last_name_en AS "lastNameEn", u.email AS "email", COALESCE(bc.created_at, bu.created_at) AS "createdAt", COALESCE(bc.is_enabled, TRUE) AS "isEnabled", @@ -109,6 +116,8 @@ export class CustomersService { cellNumber: user.cellNumber, firstName: user.firstName, lastName: user.lastName, + firstNameEn: user.firstNameEn, + lastNameEn: user.lastNameEn, email: user.email, label: this.formatLabel(user), createdAt: user.createdAt.toISOString(), @@ -335,6 +344,8 @@ export class CustomersService { cellNumber: user.cellNumber, firstName: user.firstName, lastName: user.lastName, + firstNameEn: user.firstNameEn, + lastNameEn: user.lastNameEn, email: user.email, label: this.formatLabel(user), createdAt: membership.link.createdAt.toISOString(), @@ -375,6 +386,8 @@ export class CustomersService { const hasProfileUpdate = dto.firstName !== undefined || dto.lastName !== undefined || + dto.firstNameEn !== undefined || + dto.lastNameEn !== undefined || dto.email !== undefined || dto.cellNumber !== undefined; @@ -395,6 +408,10 @@ export class CustomersService { data: { firstName: dto.firstName?.trim(), lastName: dto.lastName?.trim(), + firstNameEn: + dto.firstNameEn !== undefined ? dto.firstNameEn.trim() || null : undefined, + lastNameEn: + dto.lastNameEn !== undefined ? dto.lastNameEn.trim() || null : undefined, email: dto.email !== undefined ? dto.email.trim() || null : undefined, cellNumber: dto.cellNumber?.trim(), }, @@ -416,6 +433,8 @@ export class CustomersService { cellNumber: user.cellNumber, firstName: user.firstName, lastName: user.lastName, + firstNameEn: user.firstNameEn, + lastNameEn: user.lastNameEn, email: user.email, label: this.formatLabel(user), isEnabled: refreshed.isEnabled, diff --git a/src/customers/dto/update-customer.dto.ts b/src/customers/dto/update-customer.dto.ts index 9402d03..39e510e 100644 --- a/src/customers/dto/update-customer.dto.ts +++ b/src/customers/dto/update-customer.dto.ts @@ -1,4 +1,11 @@ -import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator'; +import { + IsBoolean, + IsOptional, + IsString, + MaxLength, + MinLength, + ValidateIf, +} from 'class-validator'; export class UpdateCustomerDto { @IsOptional() @@ -8,13 +15,29 @@ export class UpdateCustomerDto { @IsOptional() @IsString() @MinLength(1) + @MaxLength(100) firstName?: string; @IsOptional() @IsString() @MinLength(1) + @MaxLength(100) lastName?: string; + @IsOptional() + @IsString() + @ValidateIf((_, value) => typeof value === 'string' && value.trim().length > 0) + @MinLength(2) + @MaxLength(100) + firstNameEn?: string; + + @IsOptional() + @IsString() + @ValidateIf((_, value) => typeof value === 'string' && value.trim().length > 0) + @MinLength(2) + @MaxLength(100) + lastNameEn?: string; + @IsOptional() @IsString() email?: string; diff --git a/src/main.ts b/src/main.ts index 52f7a6b..5a5642a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,7 +24,9 @@ async function bootstrap() { }), ); app.useGlobalInterceptors(new BigIntSerializerInterceptor()); - app.enableCors(); + app.enableCors({ + exposedHeaders: ['Content-Disposition'], + }); const port = process.env.PORT ?? 3000; await app.listen(port); diff --git a/src/products/products.service.ts b/src/products/products.service.ts index 9e9b648..faee1fe 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -30,6 +30,20 @@ type ProductWithRelations = Prisma.ProductGetPayload<{ }; }>; +const productListInclude = { + featuredMedia: true, + brand: { include: { imageMedia: true } }, +} satisfies Prisma.ProductInclude; + +const RELATED_PRODUCTS_LIMIT = 8; + +const emptyStoreSummary = { + variantCount: 0, + minPrice: null as number | null, + maxPrice: null as number | null, + inStock: false, +}; + @Injectable() export class ProductsService { constructor( @@ -137,28 +151,7 @@ export class ProductsService { this.prisma.product.count({ where }), ]); - const storeSummaries = await this.loadStoreSummariesForProducts( - businessId, - items.map((item) => item.id), - ); - - const serialized = await Promise.all( - items.map(async (item) => { - const product = await this.serializeProduct(item, undefined, { - approvedCommentsOnly: true, - }); - const store = storeSummaries.get(item.id.toString()); - return { - ...product, - store: store ?? { - variantCount: 0, - minPrice: null, - maxPrice: null, - inStock: false, - }, - }; - }), - ); + const serialized = await this.serializePublicListItems(businessId, items); return { items: serialized, total, page, pageSize }; } @@ -173,31 +166,21 @@ export class ProductsService { slug, status: ContentStatus.published, }, - include: { - featuredMedia: true, - brand: { include: { imageMedia: true } }, - }, + include: productListInclude, }); if (!product) { throw new NotFoundException('Product not found'); } - const storeSummaries = await this.loadStoreSummariesForProducts(businessId, [product.id]); - const serialized = await this.serializeProduct(product, undefined, { - approvedCommentsOnly: true, - }); + const [serializedList, relatedProducts] = await Promise.all([ + this.serializePublicListItems(businessId, [product]), + this.loadRelatedPublicProducts(businessId, product), + ]); return { - product: { - ...serialized, - store: storeSummaries.get(product.id.toString()) ?? { - variantCount: 0, - minPrice: null, - maxPrice: null, - inStock: false, - }, - }, + product: serializedList[0], + relatedProducts, }; } @@ -697,6 +680,141 @@ export class ProductsService { }; } + private async serializePublicListItems( + businessId: bigint, + items: ProductWithRelations[], + ) { + const storeSummaries = await this.loadStoreSummariesForProducts( + businessId, + items.map((item) => item.id), + ); + + return Promise.all( + items.map(async (item) => { + const product = await this.serializeProduct(item, undefined, { + approvedCommentsOnly: true, + }); + return { + ...product, + store: storeSummaries.get(item.id.toString()) ?? { ...emptyStoreSummary }, + }; + }), + ); + } + + /** + * Related products for the details page. + * Relativity: same category first, then same brand. + * Availability: in-stock (unlimited or qty > 0) before out-of-stock within each group. + */ + private async loadRelatedPublicProducts( + businessId: bigint, + source: ProductWithRelations, + limit = RELATED_PRODUCTS_LIMIT, + ) { + const collected: ProductWithRelations[] = []; + const seen = new Set([source.id.toString()]); + + const remaining = () => limit - collected.length; + + const append = (items: ProductWithRelations[]) => { + for (const item of items) { + if (collected.length >= limit) { + break; + } + const id = item.id.toString(); + if (seen.has(id)) { + continue; + } + seen.add(id); + collected.push(item); + } + }; + + const fetchPool = async ( + extraWhere: Prisma.ProductWhereInput, + take: number, + ) => { + if (take <= 0) { + return []; + } + return this.prisma.product.findMany({ + where: { + businessId, + status: ContentStatus.published, + AND: [{ id: { not: source.id } }, extraWhere], + }, + orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }], + take, + include: productListInclude, + }); + }; + + const inStockWhere = this.publishedInStockWhere(); + const outOfStockWhere: Prisma.ProductWhereInput = { NOT: inStockWhere }; + + const assignment = await this.prisma.categoryAssignment.findFirst({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: source.id, + }, + select: { categoryId: true }, + }); + + if (assignment) { + const categoryRows = await this.prisma.categoryAssignment.findMany({ + where: { + businessId, + entityType: MediaEntityType.product, + categoryId: assignment.categoryId, + entityId: { not: source.id }, + }, + select: { entityId: true }, + }); + const categoryIds = categoryRows.map((row) => row.entityId); + if (categoryIds.length > 0) { + const categoryWhere: Prisma.ProductWhereInput = { id: { in: categoryIds } }; + const [inStock, outOfStock] = await Promise.all([ + fetchPool({ AND: [categoryWhere, inStockWhere] }, limit), + fetchPool({ AND: [categoryWhere, outOfStockWhere] }, limit), + ]); + append(inStock); + append(outOfStock); + } + } + + if (source.brandId && remaining() > 0) { + const brandWhere: Prisma.ProductWhereInput = { brandId: source.brandId }; + const take = remaining(); + const [inStock, outOfStock] = await Promise.all([ + fetchPool({ AND: [brandWhere, inStockWhere] }, take), + fetchPool({ AND: [brandWhere, outOfStockWhere] }, take), + ]); + append(inStock); + append(outOfStock); + } + + return this.serializePublicListItems(businessId, collected); + } + + /** Active store variant with unlimited stock (null) or quantity > 0. */ + private publishedInStockWhere(): Prisma.ProductWhereInput { + return { + storeItem: { + some: { + isActive: true, + variants: { + some: { + isActive: true, + OR: [{ stockQuantity: null }, { stockQuantity: { gt: 0 } }], + }, + }, + }, + }, + }; + } + private async loadStoreSummariesForProducts( businessId: bigint, productIds: bigint[], diff --git a/src/store/store-items-excel.ts b/src/store/store-items-excel.ts new file mode 100644 index 0000000..bea33db --- /dev/null +++ b/src/store/store-items-excel.ts @@ -0,0 +1,335 @@ +import { BadRequestException } from '@nestjs/common'; +import ExcelJS from 'exceljs'; + +export const STORE_ITEMS_SHEET_NAME = 'Store items'; + +export const STORE_ITEM_EXCEL_HEADERS = { + variantId: 'variant_id', + product: 'product', + variation: 'variation', + price: 'price', + stock: 'stock', +} as const; + +type HeaderKey = keyof typeof STORE_ITEM_EXCEL_HEADERS; + +const HEADER_ALIASES: Record = { + variant_id: 'variantId', + 'variant id': 'variantId', + id: 'variantId', + 'شناسه تنوع': 'variantId', + product: 'product', + product_en: 'product', + product_fa: 'product', + variation: 'variation', + price: 'price', + 'قیمت': 'price', + stock: 'stock', + stock_quantity: 'stock', + 'موجودی': 'stock', +}; + +const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'; + +export interface StoreItemExcelRow { + variantId: string; + productId: string; + productEn: string; + productFa: string; + variation: string; + price: number | null; + stock: number | null; +} + +export interface ParsedStoreItemExcelRow { + rowNumber: number; + variantId: string; + price: number | null; + stock: number | null; +} + +export async function buildStoreItemsExcel(rows: StoreItemExcelRow[]): Promise { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Meshkee'; + workbook.created = new Date(); + + const sheet = workbook.addWorksheet(STORE_ITEMS_SHEET_NAME); + sheet.columns = [ + { header: STORE_ITEM_EXCEL_HEADERS.variantId, key: 'variantId', width: 10 }, + { header: STORE_ITEM_EXCEL_HEADERS.product, key: 'product', width: 52 }, + { header: STORE_ITEM_EXCEL_HEADERS.variation, key: 'variation', width: 36 }, + { header: STORE_ITEM_EXCEL_HEADERS.price, key: 'price', width: 14 }, + { header: STORE_ITEM_EXCEL_HEADERS.stock, key: 'stock', width: 8 }, + { width: 3 }, + { header: 'دستورالعمل', width: 48 }, + { header: 'Instructions', width: 48 }, + ]; + + const header = sheet.getRow(1); + header.font = { bold: true }; + header.alignment = { vertical: 'middle' }; + for (let col = 1; col <= 5; col += 1) { + header.getCell(col).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE8EEF7' }, + }; + } + + const groups = groupRowsByProduct(rows); + groups.forEach((group, index) => { + const first = group[0]; + if (!first) return; + + const title = sheet.addRow({ + variantId: '', + product: formatProductName(first.productFa, first.productEn), + variation: '', + price: null, + stock: null, + }); + title.height = 32; + for (let col = 1; col <= 5; col += 1) { + const cell = title.getCell(col); + cell.font = { bold: true, size: 14 }; + cell.alignment = { vertical: 'middle', readingOrder: 'ltr' }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF3F3F3' }, + }; + } + + for (const row of group) { + const added = sheet.addRow({ + variantId: row.variantId, + product: '', + variation: row.variation, + price: row.price, + stock: row.stock, + }); + added.getCell('variantId').numFmt = '@'; + added.getCell('price').numFmt = '#,##0'; + added.getCell('stock').numFmt = '0'; + } + + if (index < groups.length - 1) { + sheet.addRow([]); + } + }); + + writeInstructionColumns(sheet); + sheet.views = [{ state: 'frozen', ySplit: 1, rightToLeft: false }]; + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +} + +export async function parseStoreItemsExcel(buffer: Buffer): Promise { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer as unknown as Parameters[0]); + + const sheet = + workbook.getWorksheet(STORE_ITEMS_SHEET_NAME) ?? workbook.worksheets[0]; + + if (!sheet) { + throw new BadRequestException('Excel file has no worksheet.'); + } + + const headerMap = readHeaderMap(sheet); + const variantCol = headerMap.variantId; + const priceCol = headerMap.price; + const stockCol = headerMap.stock; + + if (!variantCol || !priceCol || !stockCol) { + throw new BadRequestException( + 'Excel is missing required columns: variant_id, price, stock.', + ); + } + + const rows: ParsedStoreItemExcelRow[] = []; + const seenIds = new Set(); + + sheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; + + const variantId = cellText(row.getCell(variantCol).value); + if (!variantId) return; + + if (seenIds.has(variantId)) { + throw new BadRequestException( + `Row ${rowNumber}: duplicate variant_id "${variantId}".`, + ); + } + seenIds.add(variantId); + + const price = parseOptionalNumber(row.getCell(priceCol).value, rowNumber, 'price'); + const stock = parseOptionalInteger(row.getCell(stockCol).value, rowNumber, 'stock'); + + rows.push({ rowNumber, variantId, price, stock }); + }); + + if (rows.length === 0) { + throw new BadRequestException('Excel has no store item rows to import.'); + } + + if (rows.length > 50_000) { + throw new BadRequestException('Excel has too many rows (max 50,000).'); + } + + return rows; +} + +function writeInstructionColumns(sheet: ExcelJS.Worksheet) { + const faLines = [ + 'نام محصول، سپس تنوع‌ها، سپس یک ردیف خالی.', + 'فقط ستون‌های price و stock را در ردیف تنوع ویرایش کنید.', + 'ستون variant_id را تغییر ندهید.', + 'قیمت خالی = تماس برای قیمت.', + 'موجودی خالی = نامحدود. موجودی ۰ = ناموجود.', + 'این فایل محصولی اضافه یا حذف نمی‌کند.', + ]; + const enLines = [ + 'Product name, then its variations, then a blank row.', + 'Edit only price and stock on variation rows.', + 'Do not change variant_id.', + 'Empty price = call for price.', + 'Empty stock = unlimited. Stock 0 = out of stock.', + 'This file does not add or remove products.', + ]; + + faLines.forEach((line, index) => { + const row = sheet.getRow(index + 2); + row.getCell(7).value = line; + row.getCell(7).alignment = { wrapText: true, vertical: 'top', readingOrder: 'rtl' }; + row.getCell(8).value = enLines[index] ?? ''; + row.getCell(8).alignment = { wrapText: true, vertical: 'top', readingOrder: 'ltr' }; + }); +} + +function groupRowsByProduct(rows: StoreItemExcelRow[]): StoreItemExcelRow[][] { + const groups = new Map(); + const order: string[] = []; + + for (const row of rows) { + const key = row.productId || `${row.productEn}\n${row.productFa}`; + const group = groups.get(key); + if (!group) { + groups.set(key, [row]); + order.push(key); + } else { + group.push(row); + } + } + + return order.map((key) => groups.get(key) ?? []); +} + +export function storeItemsExcelFilename(now = new Date()) { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Tehran', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(now); + + const pick = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? ''; + + const date = `${pick('year')}-${pick('month')}-${pick('day')}`; + const time = `${pick('hour')}-${pick('minute')}`; + return `store items - ${date} - ${time}.xlsx`; +} + +function formatProductName(productFa: string, productEn: string) { + const fa = productFa.trim(); + const en = productEn.trim(); + if (fa && en) return `\u200E${fa} - ${en}`; + return fa || en; +} + +function readHeaderMap(sheet: ExcelJS.Worksheet): Partial> { + const headerRow = sheet.getRow(1); + const map: Partial> = {}; + + headerRow.eachCell((cell, colNumber) => { + const key = HEADER_ALIASES[normalizeHeader(cellText(cell.value))]; + if (key && !map[key]) { + map[key] = colNumber; + } + }); + + return map; +} + +function normalizeHeader(value: string) { + return value.trim().toLowerCase().replace(/\s+/g, ' '); +} + +function cellText(value: ExcelJS.CellValue): string { + if (value == null || value === '') return ''; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value).trim(); + } + if (value instanceof Date) return ''; + if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') { + return value.text.trim(); + } + if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) { + return value.richText.map((part) => part.text).join('').trim(); + } + if (typeof value === 'object' && 'result' in value) { + return cellText(value.result as ExcelJS.CellValue); + } + return String(value).trim(); +} + +function parseOptionalNumber( + value: ExcelJS.CellValue, + rowNumber: number, + field: string, +): number | null { + if (value == null || value === '') return null; + if (typeof value === 'number') { + if (!Number.isFinite(value) || value < 0) { + throw new BadRequestException(`Row ${rowNumber}: ${field} must be 0 or greater.`); + } + return value; + } + + const text = normalizeNumericText(cellText(value)); + if (!text) return null; + + const parsed = Number(text); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new BadRequestException(`Row ${rowNumber}: ${field} must be a number 0 or greater.`); + } + return parsed; +} + +function parseOptionalInteger( + value: ExcelJS.CellValue, + rowNumber: number, + field: string, +): number | null { + const parsed = parseOptionalNumber(value, rowNumber, field); + if (parsed == null) return null; + if (!Number.isInteger(parsed)) { + throw new BadRequestException(`Row ${rowNumber}: ${field} must be a whole number.`); + } + return parsed; +} + +function normalizeNumericText(value: string) { + const withLatinDigits = [...value] + .map((char) => { + const index = PERSIAN_DIGITS.indexOf(char); + return index >= 0 ? String(index) : char; + }) + .join(''); + + return withLatinDigits.replace(/,/g, '').replace(/\s+/g, '').trim(); +} diff --git a/src/store/store-items.controller.ts b/src/store/store-items.controller.ts index 0529c95..a13deb0 100644 --- a/src/store/store-items.controller.ts +++ b/src/store/store-items.controller.ts @@ -1,4 +1,21 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, + Query, + StreamableFile, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; import { AuthUser } from '../auth/auth.types'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; @@ -14,6 +31,7 @@ import { UpdateStoreItemVariantDto, } from './dto/store-items.dto'; import { StoreItemsService } from './store-items.service'; +import { storeItemsExcelFilename } from './store-items-excel'; @Controller('businesses/:businessId/store-items') @UseGuards(JwtAuthGuard, BusinessPermissionGuard) @@ -30,6 +48,40 @@ export class StoreItemsController { return this.service.list(businessId, query, user); } + @Get('export') + @RequireBusinessPermission('products.read') + async exportExcel( + @Param('businessId') businessId: string, + @CurrentUser() user: AuthUser, + ) { + const buffer = await this.service.exportExcel(businessId, user); + const filename = storeItemsExcelFilename(); + return new StreamableFile(buffer, { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + disposition: `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`, + length: buffer.length, + }); + } + + @Post('import') + @RequireBusinessPermission('products.update') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 8 * 1024 * 1024 }, + }), + ) + importExcel( + @Param('businessId') businessId: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUser, + ) { + if (!file) { + throw new BadRequestException('Excel file is required.'); + } + return this.service.importExcel(businessId, file, user); + } + @Get('by-product/:productId') @RequireBusinessPermission('products.read') getByProduct( diff --git a/src/store/store-items.service.ts b/src/store/store-items.service.ts index 2697a86..5a80e8e 100644 --- a/src/store/store-items.service.ts +++ b/src/store/store-items.service.ts @@ -19,6 +19,7 @@ import { SyncProductStoreItemVariantsDto, UpdateStoreItemVariantDto, } from './dto/store-items.dto'; +import { buildStoreItemsExcel, parseStoreItemsExcel } from './store-items-excel'; const variantInclude = { storeItem: { @@ -42,6 +43,12 @@ type VariantWithRelations = Prisma.StoreItemVariantGetPayload<{ include: typeof variantInclude; }>; +function sameNullableNumber(a: number | null, b: number | null) { + if (a === null && b === null) return true; + if (a === null || b === null) return false; + return Math.abs(a - b) < 0.005; +} + @Injectable() export class StoreItemsService { constructor( @@ -268,6 +275,136 @@ export class StoreItemsService { }; } + async exportExcel(businessIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'products.read'); + + const variants = await this.prisma.storeItemVariant.findMany({ + where: { businessId }, + orderBy: [{ createdAt: 'desc' }], + include: variantInclude, + }); + + const rows = variants.map((variant) => { + const product = variant.storeItem.product; + const content = this.asRecord(product.content); + const variation = variant.selections + .map((selection) => `${selection.variation.name}: ${selection.option.label}`) + .join(' · '); + + return { + variantId: variant.id.toString(), + productId: product.id.toString(), + productEn: product.title, + productFa: (content.nameFa as string | null | undefined) ?? '', + variation, + price: variant.price === null ? null : Number(variant.price), + stock: variant.stockQuantity, + }; + }); + + return buildStoreItemsExcel(rows); + } + + async importExcel(businessIdRaw: string, file: Express.Multer.File | undefined, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'products.update'); + + if (!file?.buffer?.length) { + throw new BadRequestException('Excel file is required.'); + } + + const name = file.originalname?.toLowerCase() ?? ''; + if (name && !name.endsWith('.xlsx')) { + throw new BadRequestException('Please upload an .xlsx Excel file.'); + } + + const rows = await parseStoreItemsExcel(file.buffer); + const ids = rows.map((row) => { + try { + return BigInt(row.variantId); + } catch { + throw new BadRequestException( + `Row ${row.rowNumber}: variant_id "${row.variantId}" is not valid.`, + ); + } + }); + + const existing = await this.prisma.storeItemVariant.findMany({ + where: { businessId, id: { in: ids } }, + select: { + id: true, + price: true, + stockQuantity: true, + compareAtPrice: true, + }, + }); + const existingById = new Map(existing.map((item) => [item.id.toString(), item])); + + const missing = rows.filter((row) => !existingById.has(row.variantId)); + if (missing.length > 0) { + const preview = missing + .slice(0, 8) + .map((row) => `Row ${row.rowNumber}: variant_id "${row.variantId}" was not found.`) + .join(' '); + throw new BadRequestException( + missing.length === 1 + ? preview + : `${missing.length} unknown variant_id values. No changes applied. ${preview}`, + ); + } + + const changes = rows.flatMap((row) => { + const current = existingById.get(row.variantId); + if (!current) return []; + + const currentPrice = current.price === null ? null : Number(current.price); + const priceChanged = !sameNullableNumber(currentPrice, row.price); + const stockChanged = current.stockQuantity !== row.stock; + if (!priceChanged && !stockChanged) return []; + + const compareAt = + current.compareAtPrice === null ? null : Number(current.compareAtPrice); + const clearCompareAt = + compareAt !== null && + (row.price === null || compareAt >= row.price); + + return [ + { + id: current.id, + price: row.price, + stockQuantity: row.stock, + clearCompareAt, + }, + ]; + }); + + if (changes.length > 0) { + await this.prisma.$transaction( + async (tx) => { + for (const item of changes) { + await tx.storeItemVariant.update({ + where: { id: item.id }, + data: { + price: item.price, + stockQuantity: item.stockQuantity, + ...(item.clearCompareAt ? { compareAtPrice: null } : {}), + }, + }); + } + }, + { timeout: 120_000, maxWait: 15_000 }, + ); + } + + return { + message: 'Store item prices and stock updated from Excel', + total: rows.length, + updated: changes.length, + unchanged: rows.length - changes.length, + }; + } + async batchCreateVariants( businessIdRaw: string, dto: BatchCreateStoreItemsDto, diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 2ce670a..ff5558e 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -31,7 +31,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` 2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials -3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings) +3. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings) 4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). 5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` - For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl` diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json index 81e8026..6234119 100644 --- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -1197,6 +1197,7 @@ "name": "Get published product by slug", "request": { "method": "GET", + "description": "Product details plus relatedProducts (same category then same brand; in-stock first within each group).", "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}" } }, diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index c0e3a88..74351f8 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -449,7 +449,7 @@ ], "responses": { "200": { - "description": "{ product }" + "description": "{ product, relatedProducts } — relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product." } } }