Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00cf7aed76 | ||
|
|
9ca91357b8 | ||
|
|
88018dd295 | ||
|
|
9a3df81ea0 | ||
|
|
30e3e268a5 | ||
|
|
c277295404 | ||
|
|
203a18b6d0 | ||
|
|
aa9f02cd1d | ||
|
|
cf7b1e7d59 |
Generated
+468
@@ -9,7 +9,9 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1113.0",
|
||||
"axios": "^1.20.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^6.3.2",
|
||||
"cloudinary": "^2.10.0",
|
||||
"compression": "^1.7.5",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -18,6 +20,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.1.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
@@ -423,6 +426,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz",
|
||||
"integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -441,6 +450,84 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@one-ini/wasm": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
@@ -655,6 +742,41 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -749,6 +871,12 @@
|
||||
"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",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/at-least-node": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
|
||||
@@ -759,6 +887,18 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
|
||||
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
@@ -896,6 +1036,42 @@
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bullmq": {
|
||||
"version": "6.3.2",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-6.3.2.tgz",
|
||||
"integrity": "sha512-jW4mEG1JOKewI2IhtuMery+kNhCs+EZp4qeNXeYRfueW7P4NFOStFGHOdsiJE9EO9Abz+JMrYlymb+r7fCbT7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "5.10.0",
|
||||
"msgpackr": "2.1.0",
|
||||
"node-abort-controller": "3.1.1",
|
||||
"semver": "7.8.5",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bullmq-otel": ">=2.0.0",
|
||||
"ioredis": ">=5.0.0",
|
||||
"pg": ">=8.0.0",
|
||||
"redis": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bullmq-otel": {
|
||||
"optional": true
|
||||
},
|
||||
"ioredis": {
|
||||
"optional": true
|
||||
},
|
||||
"pg": {
|
||||
"optional": true
|
||||
},
|
||||
"redis": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
@@ -1079,6 +1255,15 @@
|
||||
"node": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||
@@ -1154,6 +1339,18 @@
|
||||
"node": ">=12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
@@ -1292,6 +1489,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.10.0.tgz",
|
||||
"integrity": "sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
@@ -1333,6 +1542,24 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -1352,6 +1579,16 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
@@ -1576,6 +1813,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -1743,6 +1995,26 @@
|
||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -1759,6 +2031,22 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -1973,6 +2261,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
@@ -2017,6 +2320,42 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
@@ -2058,6 +2397,50 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz",
|
||||
"integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "2.0.0",
|
||||
"cluster-key-slot": "1.1.1",
|
||||
"debug": "4.4.3",
|
||||
"denque": "2.1.0",
|
||||
"redis-errors": "1.2.0",
|
||||
"standard-as-callback": "2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -2388,6 +2771,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -2558,6 +2950,37 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
|
||||
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "1.4.5-lts.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||
@@ -2586,6 +3009,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abort-controller": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
||||
@@ -2606,6 +3035,21 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
@@ -3002,6 +3446,15 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||
@@ -3083,6 +3536,15 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -3591,6 +4053,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1113.0",
|
||||
"axios": "^1.20.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^6.3.2",
|
||||
"cloudinary": "^2.10.0",
|
||||
"compression": "^1.7.5",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -31,6 +33,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.1.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
|
||||
@@ -14,8 +14,8 @@ export class AttributeGroupRepository {
|
||||
through: { attributes: ['display_order'] }
|
||||
}
|
||||
],
|
||||
order: [
|
||||
['name', 'ASC']
|
||||
order: options.order || [
|
||||
['created_at', 'DESC']
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ export class AttributeSetRepository {
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [
|
||||
['name', 'ASC']
|
||||
order: options.order || [
|
||||
['created_at', 'DESC']
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
|
||||
export class AttributeRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
order: [['created_at', 'DESC']],
|
||||
...options,
|
||||
where: applyTenantScope(options.where || {}, context)
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export class AttributeService {
|
||||
}
|
||||
|
||||
// Sorting
|
||||
let order = [['display_order', 'ASC']];
|
||||
let order = [['created_at', 'DESC']];
|
||||
if (query.sortBy) {
|
||||
const direction = query.sortDir?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
if (query.sortBy === 'name') order = [['name', direction]];
|
||||
|
||||
@@ -15,6 +15,7 @@ export class RoleRepository {
|
||||
};
|
||||
|
||||
return await models.Role.findAll({
|
||||
order: [['created_at', 'DESC']],
|
||||
include: [
|
||||
{
|
||||
model: models.PermissionNode,
|
||||
|
||||
@@ -8,6 +8,7 @@ export class UserRepository {
|
||||
? { tenant_id: context.tenantId }
|
||||
: {};
|
||||
return await models.User.findAll({
|
||||
order: [['created_at', 'DESC']],
|
||||
attributes: { exclude: ['password_hash'] },
|
||||
include: [
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
|
||||
export class BrandRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
order: [['created_at', 'DESC']],
|
||||
...options,
|
||||
where: applyTenantScope(options.where || {}, context)
|
||||
};
|
||||
@@ -50,14 +51,9 @@ export class BrandRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Brand.findByPk(id, queryOptions);
|
||||
const record = await models.Brand.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
|
||||
export class UnitRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
order: [['created_at', 'DESC']],
|
||||
...options,
|
||||
where: applyTenantScope(options.where || {}, context)
|
||||
};
|
||||
@@ -50,14 +51,9 @@ export class UnitRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Unit.findByPk(id, queryOptions);
|
||||
const record = await models.Unit.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -18,6 +18,7 @@ export class CatalogController {
|
||||
: (raw.category_id || ''),
|
||||
attributeSetId: raw.attribute_set_id || (raw.attributeSet ? raw.attributeSet.id : null) || '',
|
||||
workflowCode: raw.workflow_code || 'standard',
|
||||
productType: raw.completeness_rules?.productType || raw.completenessRules?.productType || raw.productType || raw.product_type || null,
|
||||
completenessRules: raw.completeness_rules || {},
|
||||
allowedBrands: raw.allowedBrands || [],
|
||||
allowedUnits: raw.allowedUnits || [],
|
||||
|
||||
@@ -25,17 +25,9 @@ export class CatalogRepository {
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'variantAxes',
|
||||
attributes: ['id', 'code', 'name', 'type', 'options'],
|
||||
attributes: ['id', 'code', 'name'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AttributeOption,
|
||||
as: 'optionsList',
|
||||
attributes: ['id', 'code', 'label', 'sort_order'],
|
||||
required: false
|
||||
}
|
||||
]
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: models.AssetFamily,
|
||||
@@ -65,8 +57,8 @@ export class CatalogRepository {
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [
|
||||
['name', 'ASC']
|
||||
order: options.order || [
|
||||
['created_at', 'DESC']
|
||||
],
|
||||
...queryOptions
|
||||
});
|
||||
@@ -153,23 +145,15 @@ export class CatalogRepository {
|
||||
const queryOptions = {
|
||||
paranoid: false, // Check both active and soft-deleted records for uniqueness checks
|
||||
...options,
|
||||
where: {
|
||||
code,
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ code, ...(options.where || {}) }, context)
|
||||
};
|
||||
return await models.Catalog.findOne(queryOptions);
|
||||
}
|
||||
|
||||
async create(data, options = {}, context = {}) {
|
||||
const createData = {
|
||||
...data
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
...data,
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
};
|
||||
return await models.Catalog.create(createData, options);
|
||||
}
|
||||
@@ -198,14 +182,9 @@ export class CatalogRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Catalog.findByPk(id, queryOptions);
|
||||
const record = await models.Catalog.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -243,11 +243,15 @@ export class CatalogService {
|
||||
const completenessRules = data.completenessRules || data.completeness_rules || {};
|
||||
completenessRules.allowedBrands = data.allowedBrands || data.allowed_brands || [];
|
||||
completenessRules.allowedUnits = data.allowedUnits || data.allowed_units || [];
|
||||
const incomingProductType = data.productType || data.product_type || data.type;
|
||||
if (incomingProductType) {
|
||||
completenessRules.productType = incomingProductType;
|
||||
}
|
||||
|
||||
let totalWeight = 0;
|
||||
let hasRules = false;
|
||||
for (const [key, val] of Object.entries(completenessRules)) {
|
||||
if (key === 'allowedBrands' || key === 'allowedUnits') continue;
|
||||
if (key === 'allowedBrands' || key === 'allowedUnits' || key === 'productType') continue;
|
||||
const weight = Number(val);
|
||||
if (isNaN(weight)) {
|
||||
throw new Error(`Completeness rule weight for "${key}" must be a number`);
|
||||
@@ -515,11 +519,17 @@ export class CatalogService {
|
||||
if (data.hasOwnProperty('allowedUnits') || data.hasOwnProperty('allowed_units')) {
|
||||
completenessRules.allowedUnits = data.allowedUnits || data.allowed_units || [];
|
||||
}
|
||||
if (data.hasOwnProperty('productType') || data.hasOwnProperty('product_type') || data.hasOwnProperty('type')) {
|
||||
const pType = data.productType || data.product_type || data.type;
|
||||
if (pType) {
|
||||
completenessRules.productType = pType;
|
||||
}
|
||||
}
|
||||
|
||||
let totalWeight = 0;
|
||||
let hasRules = false;
|
||||
for (const [key, val] of Object.entries(completenessRules)) {
|
||||
if (key === 'allowedBrands' || key === 'allowedUnits') continue;
|
||||
if (key === 'allowedBrands' || key === 'allowedUnits' || key === 'productType') continue;
|
||||
const weight = Number(val);
|
||||
if (isNaN(weight)) {
|
||||
throw new Error(`Completeness rule weight for "${key}" must be a number`);
|
||||
@@ -748,6 +758,7 @@ export class CatalogService {
|
||||
if (!family) throw new Error('Product Family not found');
|
||||
|
||||
let groups = [];
|
||||
let attributeSetObj = null;
|
||||
if (family.attribute_set_id) {
|
||||
const setRecord = await models.AttributeSet.findByPk(family.attribute_set_id, {
|
||||
include: [
|
||||
@@ -770,8 +781,13 @@ export class CatalogService {
|
||||
}
|
||||
]
|
||||
});
|
||||
if (setRecord && setRecord.groups) {
|
||||
groups = setRecord.groups;
|
||||
if (setRecord) {
|
||||
const setObj = setRecord.toJSON
|
||||
? setRecord.toJSON()
|
||||
: JSON.parse(JSON.stringify(setRecord));
|
||||
|
||||
attributeSetObj = setObj;
|
||||
groups = setObj.groups || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,7 +842,9 @@ export class CatalogService {
|
||||
name: family.name,
|
||||
description: family.description,
|
||||
category: family.category,
|
||||
attributeSet: family.attributeSet,
|
||||
attributeSet: attributeSetObj || family.attributeSet || null,
|
||||
attribute_set_id: family.attribute_set_id || null,
|
||||
attributeSetId: family.attribute_set_id || null,
|
||||
groups,
|
||||
attributes: family.attributes || [],
|
||||
variantAxes: family.variantAxes || [],
|
||||
@@ -837,7 +855,8 @@ export class CatalogService {
|
||||
workflow: workflow,
|
||||
allowedBrands: completenessRules.allowedBrands || [],
|
||||
allowedUnits: completenessRules.allowedUnits || [],
|
||||
completenessRules: completenessRules
|
||||
completenessRules: completenessRules,
|
||||
productType: completenessRules.productType || null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,17 @@ export const createValidation = [
|
||||
.isString(),
|
||||
body('categoryId')
|
||||
.optional({ nullable: true })
|
||||
.isString()
|
||||
.isString(),
|
||||
body('productType')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant'])
|
||||
.withMessage('Product type must be either simple or variant'),
|
||||
body('product_type')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant']),
|
||||
body('type')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant'])
|
||||
];
|
||||
|
||||
export const updateValidation = [
|
||||
@@ -103,7 +113,17 @@ export const updateValidation = [
|
||||
.isString(),
|
||||
body('categoryId')
|
||||
.optional({ nullable: true })
|
||||
.isString()
|
||||
.isString(),
|
||||
body('productType')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant'])
|
||||
.withMessage('Product type must be either simple or variant'),
|
||||
body('product_type')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant']),
|
||||
body('type')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant'])
|
||||
];
|
||||
|
||||
export const deleteValidation = [
|
||||
|
||||
@@ -47,13 +47,7 @@ export class CategorieRepository {
|
||||
async findByCode(code, options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
where: {
|
||||
code,
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ code, ...(options.where || {}) }, context)
|
||||
};
|
||||
return await models.Categorie.findOne(queryOptions);
|
||||
}
|
||||
@@ -61,23 +55,15 @@ export class CategorieRepository {
|
||||
async findChildren(parentId, options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
where: {
|
||||
parent_id: parentId,
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ parent_id: parentId, ...(options.where || {}) }, context)
|
||||
};
|
||||
return await models.Categorie.findAll(queryOptions);
|
||||
}
|
||||
|
||||
async create(data, options = {}, context = {}) {
|
||||
const createData = {
|
||||
...data
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
...data,
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
};
|
||||
return await models.Categorie.create(createData, options);
|
||||
}
|
||||
@@ -106,14 +92,9 @@ export class CategorieRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Categorie.findByPk(id, queryOptions);
|
||||
const record = await models.Categorie.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -4,7 +4,11 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
|
||||
export class ChannelTypeRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const where = applyTenantScope(options.where || {}, context);
|
||||
return await models.ChannelType.findAll({ ...options, where });
|
||||
return await models.ChannelType.findAll({
|
||||
order: [['created_at', 'DESC']],
|
||||
...options,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id, options = {}, context = {}) {
|
||||
|
||||
@@ -4,7 +4,11 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
|
||||
export class ChannelRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const where = applyTenantScope(options.where || {}, context);
|
||||
return await models.Channel.findAll({ ...options, where });
|
||||
return await models.Channel.findAll({
|
||||
order: [['created_at', 'DESC']],
|
||||
...options,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id, options = {}, context = {}) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ShopifyAdapter } from './shopify/shopify.adapter.js';
|
||||
|
||||
export const adapterRegistry = {
|
||||
getAdapter(channel, credentials) {
|
||||
switch (channel.toLowerCase()) {
|
||||
case 'shopify':
|
||||
return new ShopifyAdapter(credentials);
|
||||
default:
|
||||
throw new Error(`Unsupported channel adapter type: ${channel}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export class BaseChannelAdapter {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
throw new Error('Method testConnection() must be implemented');
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductPublisher {
|
||||
async publishProduct(context) {
|
||||
throw new Error('Method publishProduct() must be implemented');
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductDeleter {
|
||||
async deleteProduct(context) {
|
||||
throw new Error('Method deleteProduct() must be implemented');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BaseChannelAdapter } from '../contracts/channel-adapter.contract.js';
|
||||
import { ShopifyClient } from './shopify.client.js';
|
||||
import { ShopifyPublisher } from './shopify.publisher.js';
|
||||
|
||||
const SHOP_QUERY = `
|
||||
query {
|
||||
shop {
|
||||
name
|
||||
email
|
||||
myshopifyDomain
|
||||
plan {
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export class ShopifyAdapter extends BaseChannelAdapter {
|
||||
constructor(credentials = {}) {
|
||||
super(credentials);
|
||||
// Support both credential naming conventions
|
||||
const {
|
||||
shop_domain, access_token,
|
||||
api_key, api_secret_key,
|
||||
shopDomain, accessToken, apiKey, apiSecretKey
|
||||
} = credentials;
|
||||
|
||||
this.shopDomain = shop_domain || shopDomain;
|
||||
this.accessToken = access_token || accessToken;
|
||||
this.apiKey = api_key || apiKey;
|
||||
this.apiSecretKey = api_secret_key || apiSecretKey;
|
||||
|
||||
this.client = new ShopifyClient(
|
||||
this.shopDomain,
|
||||
this.accessToken,
|
||||
this.apiKey,
|
||||
this.apiSecretKey
|
||||
);
|
||||
this.publisher = new ShopifyPublisher(this.client);
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
if (!this.shopDomain) {
|
||||
throw new Error('Shopify connection requires shop_domain credential');
|
||||
}
|
||||
if (!this.accessToken && !this.apiSecretKey) {
|
||||
throw new Error('Shopify connection requires either access_token or api_secret_key credential');
|
||||
}
|
||||
const res = await this.client.graphql(SHOP_QUERY);
|
||||
const shop = res.data?.shop;
|
||||
if (!shop) {
|
||||
throw new Error('Invalid Shopify GraphQL shop query response — check your credentials');
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
shopName: shop.name,
|
||||
shopDomain: shop.myshopifyDomain,
|
||||
email: shop.email,
|
||||
plan: shop.plan?.displayName
|
||||
};
|
||||
}
|
||||
|
||||
async publishProduct(context) {
|
||||
return await this.publisher.publishProduct(context);
|
||||
}
|
||||
|
||||
async deleteProduct(context) {
|
||||
return await this.publisher.deleteProduct(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHttpClient } from '../../../../shared/infrastructure/http/http.client.js';
|
||||
|
||||
/**
|
||||
* ShopifyClient for Admin API (GraphQL v2025-01)
|
||||
* Uses X-Shopify-Access-Token header for all authenticated requests (OAuth & Custom App tokens)
|
||||
*/
|
||||
export class ShopifyClient {
|
||||
constructor(shopDomain, accessToken, apiKey = null, apiSecretKey = null) {
|
||||
this.shopDomain = shopDomain
|
||||
? shopDomain.replace(/^https?:\/\//, '').replace(/\/$/, '').trim()
|
||||
: '';
|
||||
|
||||
// Effective Admin API token is accessToken (or fallback to apiSecretKey if provided)
|
||||
this.accessToken = accessToken || apiSecretKey;
|
||||
this.apiKey = apiKey;
|
||||
this.apiSecretKey = apiSecretKey;
|
||||
|
||||
this.graphqlEndpoint = `/admin/api/2025-01/graphql.json`;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (this.accessToken) {
|
||||
headers['X-Shopify-Access-Token'] = this.accessToken;
|
||||
}
|
||||
|
||||
this.httpClient = createHttpClient({
|
||||
baseURL: `https://${this.shopDomain}`,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
async graphql(query, variables = {}) {
|
||||
const res = await this.httpClient.post(this.graphqlEndpoint, {
|
||||
query,
|
||||
variables
|
||||
});
|
||||
|
||||
const responseData = res.data || res;
|
||||
|
||||
// Inspect Shopify cost extensions for rate limiting
|
||||
if (responseData?.extensions?.cost) {
|
||||
const { currentlyAvailable, requestedQueryCost } = responseData.extensions.cost;
|
||||
if (currentlyAvailable && currentlyAvailable < requestedQueryCost) {
|
||||
const waitMs = Math.ceil((requestedQueryCost - currentlyAvailable) / 50) * 1000;
|
||||
await new Promise(r => setTimeout(r, Math.min(waitMs, 10000)));
|
||||
}
|
||||
}
|
||||
|
||||
if (responseData?.errors && responseData.errors.length > 0) {
|
||||
throw new Error(`Shopify GraphQL Error: ${responseData.errors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API helper for endpoints not available in GraphQL
|
||||
*/
|
||||
async restGet(path) {
|
||||
const res = await this.httpClient.get(path);
|
||||
return res.data || res;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export const shopifyMapper = {
|
||||
toProductInput(canonicalProduct, existingGid = null) {
|
||||
const { content, taxonomy, identity, variants = [] } = canonicalProduct;
|
||||
|
||||
const input = {
|
||||
title: content.name,
|
||||
descriptionHtml: content.description || '',
|
||||
status: content.status === 'active' ? 'ACTIVE' : 'DRAFT',
|
||||
vendor: taxonomy.brand?.name || 'PIM Catalog',
|
||||
productType: taxonomy.category?.name || 'General'
|
||||
};
|
||||
|
||||
// Generate 100% unique handle using product title + unique UUID snippet to prevent handle collisions
|
||||
if (!existingGid && identity.id) {
|
||||
const baseSlug = (content.name || 'product')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const uniqueSuffix = String(identity.id).replace(/[^a-z0-9]/gi, '').slice(0, 10);
|
||||
input.handle = `${baseSlug}-${uniqueSuffix}`;
|
||||
}
|
||||
|
||||
// productOptions can only be passed on CREATE (when existingGid is null) and capped at 3 options (Shopify limit)
|
||||
if (!existingGid && Array.isArray(variants) && variants.length > 0) {
|
||||
const optionKeys = new Set();
|
||||
variants.forEach(v => {
|
||||
if (v.attributes) {
|
||||
Object.keys(v.attributes).forEach(k => optionKeys.add(k));
|
||||
}
|
||||
});
|
||||
|
||||
if (optionKeys.size > 0) {
|
||||
input.productOptions = Array.from(optionKeys).slice(0, 3).map(optName => ({
|
||||
name: optName,
|
||||
values: Array.from(new Set(
|
||||
variants
|
||||
.map(v => v.attributes?.[optName])
|
||||
.filter(Boolean)
|
||||
)).map(val => ({ name: String(val) }))
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (existingGid) {
|
||||
input.id = existingGid;
|
||||
}
|
||||
|
||||
return input;
|
||||
},
|
||||
|
||||
toVariantsBulkInput(shopifyProductId, variants = []) {
|
||||
return variants.map(v => ({
|
||||
price: String(v.price || 0),
|
||||
compareAtPrice: v.costPrice ? String(v.costPrice) : null,
|
||||
sku: v.sku || '',
|
||||
barcode: v.sku || ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
import crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { models } from '../../../../shared/database/models.js';
|
||||
import { secretService } from '../../../../shared/infrastructure/secrets/secret.service.js';
|
||||
|
||||
/**
|
||||
* Shopify OAuth 2.0 service — Production-grade implementation
|
||||
*
|
||||
* Security measures:
|
||||
* - State nonce stored in DB (survives server restarts)
|
||||
* - HMAC-SHA256 signature validated before code exchange
|
||||
* - Shop domain validated against stored expected value
|
||||
* - Code exchanged server-side only — never exposed to frontend
|
||||
* - Access token stored AES-256-GCM encrypted
|
||||
* - All debug logs redact secrets
|
||||
*/
|
||||
|
||||
const REQUIRED_SCOPES = [
|
||||
'read_product_feeds',
|
||||
'write_product_feeds',
|
||||
'read_product_listings',
|
||||
'write_product_listings',
|
||||
'read_products',
|
||||
'write_products'
|
||||
].join(',');
|
||||
|
||||
const NONCE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
// ─── HMAC Validation ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate Shopify's HMAC signature on the callback.
|
||||
* Per Shopify docs: remove hmac from params, sort remaining,
|
||||
* build query string, HMAC-SHA256 with client_secret, compare.
|
||||
*/
|
||||
function validateHmac(queryParams, clientSecret) {
|
||||
const { hmac, ...rest } = queryParams;
|
||||
if (!hmac) return false;
|
||||
|
||||
// Build sorted query string (keys sorted lexicographically)
|
||||
const message = Object.keys(rest)
|
||||
.sort()
|
||||
.map(k => `${k}=${rest[k]}`)
|
||||
.join('&');
|
||||
|
||||
const digest = crypto
|
||||
.createHmac('sha256', clientSecret)
|
||||
.update(message)
|
||||
.digest('hex');
|
||||
|
||||
// Constant-time comparison
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(digest, 'hex'),
|
||||
Buffer.from(hmac, 'hex')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const shopifyOAuthService = {
|
||||
/**
|
||||
* Build the Shopify authorization URL.
|
||||
* Persists state nonce as an encrypted credential in DB (credential_type = 'oauth_nonce').
|
||||
*/
|
||||
async buildAuthorizationUrl(integrationId, rawShopDomain, rawApiKey, tenantId) {
|
||||
let shopDomain = (rawShopDomain || '').trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
let apiKey = (rawApiKey || '').trim();
|
||||
|
||||
// Auto-fix if domain and API Key were swapped
|
||||
if (apiKey.includes('.myshopify.com') && !shopDomain.includes('.myshopify.com')) {
|
||||
const temp = shopDomain;
|
||||
shopDomain = apiKey;
|
||||
apiKey = temp;
|
||||
}
|
||||
|
||||
if (shopDomain && !shopDomain.includes('.')) {
|
||||
shopDomain = `${shopDomain}.myshopify.com`;
|
||||
}
|
||||
|
||||
const nonce = crypto.randomBytes(24).toString('hex');
|
||||
const state = `${integrationId}::${nonce}`;
|
||||
|
||||
// Persist the state in DB so it survives restarts
|
||||
// Remove any old nonce for this integration first
|
||||
await models.IntegrationCredential.destroy({
|
||||
where: { integration_id: integrationId, credential_type: 'oauth_nonce' }
|
||||
});
|
||||
|
||||
await models.IntegrationCredential.create({
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
credential_type: 'oauth_nonce',
|
||||
encrypted_secret: secretService.encrypt(JSON.stringify({
|
||||
nonce,
|
||||
shopDomain,
|
||||
createdAt: Date.now()
|
||||
})),
|
||||
key_version: 1,
|
||||
expires_at: new Date(Date.now() + NONCE_TTL_MS)
|
||||
});
|
||||
|
||||
const backendPort = process.env.PORT || 5002;
|
||||
const backendUrl = process.env.BACKEND_URL || `http://localhost:${backendPort}`;
|
||||
const callbackUrl = `${backendUrl}/api/v1/integrations/shopify/oauth/callback`;
|
||||
|
||||
const url = [
|
||||
`https://${shopDomain}/admin/oauth/authorize`,
|
||||
`?client_id=${apiKey}`,
|
||||
`&scope=${encodeURIComponent(REQUIRED_SCOPES)}`,
|
||||
`&redirect_uri=${encodeURIComponent(callbackUrl)}`,
|
||||
`&state=${encodeURIComponent(state)}`
|
||||
].join('');
|
||||
|
||||
console.log(`[ShopifyOAuth] Authorization URL built for integration ${integrationId}, shop: ${shopDomain}`);
|
||||
return { authorizationUrl: url, state };
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the Shopify OAuth callback.
|
||||
* 1. Parse & validate state → look up nonce in DB
|
||||
* 2. Validate HMAC
|
||||
* 3. Validate shop domain
|
||||
* 4. Exchange code for access token
|
||||
* 5. Store token encrypted, mark integration active
|
||||
* 6. Return integrationId for redirect
|
||||
*/
|
||||
async handleCallback(query) {
|
||||
const { code, shop, state, hmac, timestamp } = query;
|
||||
|
||||
// ── Basic presence check ──────────────────────────────────────────────────
|
||||
if (!code || !shop || !state) {
|
||||
throw new Error('Missing required OAuth callback parameters (code, shop, state)');
|
||||
}
|
||||
|
||||
// ── Parse state — format is integrationId::nonce ──────────────────────────
|
||||
const stateParts = decodeURIComponent(state).split('::');
|
||||
if (stateParts.length !== 2) {
|
||||
throw new Error('Malformed OAuth state parameter');
|
||||
}
|
||||
const [integrationId, nonce] = stateParts;
|
||||
|
||||
// ── Load stored nonce from DB ─────────────────────────────────────────────
|
||||
const nonceRecord = await models.IntegrationCredential.findOne({
|
||||
where: { integration_id: integrationId, credential_type: 'oauth_nonce' }
|
||||
});
|
||||
|
||||
if (!nonceRecord) {
|
||||
throw new Error(
|
||||
'OAuth state not found or expired. The server may have restarted. Please restart the OAuth flow from the PIM Integration Hub.'
|
||||
);
|
||||
}
|
||||
|
||||
let storedData;
|
||||
try {
|
||||
storedData = JSON.parse(secretService.decrypt(nonceRecord.encrypted_secret));
|
||||
} catch {
|
||||
throw new Error('Failed to decrypt stored OAuth state');
|
||||
}
|
||||
|
||||
// ── Validate nonce ────────────────────────────────────────────────────────
|
||||
if (storedData.nonce !== nonce) {
|
||||
throw new Error('OAuth state nonce mismatch — possible CSRF attempt');
|
||||
}
|
||||
|
||||
// ── Check nonce TTL ───────────────────────────────────────────────────────
|
||||
if (Date.now() - storedData.createdAt > NONCE_TTL_MS) {
|
||||
await nonceRecord.destroy();
|
||||
throw new Error('OAuth state expired. Please restart the authorization flow.');
|
||||
}
|
||||
|
||||
// ── Validate shop domain ──────────────────────────────────────────────────
|
||||
const expectedShop = storedData.shopDomain;
|
||||
const normalizedShop = shop.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
if (normalizedShop !== expectedShop) {
|
||||
throw new Error(`Shop domain mismatch: expected "${expectedShop}", got "${normalizedShop}"`);
|
||||
}
|
||||
|
||||
// ── Load integration + credentials ────────────────────────────────────────
|
||||
const integration = await models.Integration.findByPk(integrationId);
|
||||
if (!integration) throw new Error(`Integration ${integrationId} not found`);
|
||||
|
||||
const credRecords = await models.IntegrationCredential.findAll({
|
||||
where: { integration_id: integrationId }
|
||||
});
|
||||
const credMap = {};
|
||||
credRecords.forEach(c => {
|
||||
credMap[c.credential_type] = secretService.decrypt(c.encrypted_secret);
|
||||
});
|
||||
|
||||
const apiKey = credMap.api_key;
|
||||
const apiSecretKey = credMap.api_secret_key;
|
||||
|
||||
if (!apiKey || !apiSecretKey) {
|
||||
throw new Error('api_key and api_secret_key credentials must be saved before OAuth can complete');
|
||||
}
|
||||
|
||||
// ── Validate Shopify HMAC ─────────────────────────────────────────────────
|
||||
if (hmac) {
|
||||
const hmacValid = validateHmac(query, apiSecretKey);
|
||||
if (!hmacValid) {
|
||||
throw new Error('Shopify HMAC validation failed — callback may be forged');
|
||||
}
|
||||
console.log('[ShopifyOAuth] HMAC validated successfully');
|
||||
} else {
|
||||
console.warn('[ShopifyOAuth] No HMAC in callback — skipping HMAC validation (dev mode)');
|
||||
}
|
||||
|
||||
// ── Exchange code for permanent access token ───────────────────────────────
|
||||
console.log(`[ShopifyOAuth] Exchanging code for access token with shop: ${normalizedShop}`);
|
||||
let tokenData;
|
||||
try {
|
||||
const tokenResponse = await axios.post(
|
||||
`https://${normalizedShop}/admin/oauth/access_token`,
|
||||
{
|
||||
client_id: apiKey,
|
||||
client_secret: apiSecretKey,
|
||||
code
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
timeout: 15000
|
||||
}
|
||||
);
|
||||
tokenData = tokenResponse.data;
|
||||
} catch (err) {
|
||||
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
||||
throw new Error(`Shopify token exchange failed: ${detail}`);
|
||||
}
|
||||
|
||||
const { access_token, scope } = tokenData;
|
||||
if (!access_token) {
|
||||
throw new Error('Shopify did not return an access_token in the token exchange response');
|
||||
}
|
||||
|
||||
console.log(`[ShopifyOAuth] Access token received. Scopes: ${scope}`);
|
||||
|
||||
// ── Store the permanent access token (encrypted) ──────────────────────────
|
||||
const tenantId = integration.tenant_id;
|
||||
|
||||
await models.IntegrationCredential.destroy({
|
||||
where: { integration_id: integrationId, credential_type: 'access_token' }
|
||||
});
|
||||
await models.IntegrationCredential.create({
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
credential_type: 'access_token',
|
||||
encrypted_secret: secretService.encrypt(access_token),
|
||||
key_version: 1,
|
||||
expires_at: null
|
||||
});
|
||||
|
||||
// ── Remove the one-time nonce ─────────────────────────────────────────────
|
||||
await nonceRecord.destroy();
|
||||
|
||||
// ── Mark integration as active ────────────────────────────────────────────
|
||||
await integration.update({
|
||||
status: 'active',
|
||||
health_status: 'healthy',
|
||||
last_synced_at: null
|
||||
});
|
||||
|
||||
console.log(`[ShopifyOAuth] Integration ${integrationId} connected to shop: ${normalizedShop}`);
|
||||
|
||||
return {
|
||||
integrationId,
|
||||
shop: normalizedShop,
|
||||
scope,
|
||||
connected: true
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { models } from '../../../../shared/database/models.js';
|
||||
import { shopifyMapper } from './shopify.mapper.js';
|
||||
|
||||
const PRODUCT_CREATE_MUTATION = `
|
||||
mutation productCreate($input: ProductInput!) {
|
||||
productCreate(input: $input) {
|
||||
product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PRODUCT_UPDATE_MUTATION = `
|
||||
mutation productUpdate($input: ProductInput!) {
|
||||
productUpdate(input: $input) {
|
||||
product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PRODUCT_DELETE_MUTATION = `
|
||||
mutation productDelete($input: ProductDeleteInput!) {
|
||||
productDelete(input: $input) {
|
||||
deletedProductId
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PRODUCT_CREATE_MEDIA_MUTATION = `
|
||||
mutation productCreateMedia($media: [CreateMediaInput!]!, $productId: ID!) {
|
||||
productCreateMedia(media: $media, productId: $productId) {
|
||||
media {
|
||||
id
|
||||
status
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export class ShopifyPublisher {
|
||||
constructor(shopifyClient) {
|
||||
this.client = shopifyClient;
|
||||
}
|
||||
|
||||
async publishProduct(context) {
|
||||
const { tenantId, integrationId, canonicalProduct } = context;
|
||||
const productId = canonicalProduct.identity.id;
|
||||
|
||||
// Check existing mapping
|
||||
const existingResource = await models.ExternalResource.findOne({
|
||||
where: {
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
source_id: productId,
|
||||
resource_type: 'PRODUCT'
|
||||
}
|
||||
});
|
||||
|
||||
const isUpdate = !!existingResource;
|
||||
const input = shopifyMapper.toProductInput(canonicalProduct, existingResource?.external_id);
|
||||
|
||||
let response;
|
||||
let shopifyProduct;
|
||||
|
||||
if (isUpdate) {
|
||||
response = await this.client.graphql(PRODUCT_UPDATE_MUTATION, { input });
|
||||
const result = response.data?.productUpdate;
|
||||
if (result?.userErrors?.length > 0) {
|
||||
throw new Error(`Shopify productUpdate user errors: ${result.userErrors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
shopifyProduct = result.product;
|
||||
} else {
|
||||
response = await this.client.graphql(PRODUCT_CREATE_MUTATION, { input });
|
||||
const result = response.data?.productCreate;
|
||||
if (result?.userErrors?.length > 0) {
|
||||
throw new Error(`Shopify productCreate user errors: ${result.userErrors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
shopifyProduct = result.product;
|
||||
}
|
||||
|
||||
if (!shopifyProduct || !shopifyProduct.id) {
|
||||
throw new Error('Shopify GraphQL returned empty product response');
|
||||
}
|
||||
|
||||
// Attach media/image URLs to Shopify product
|
||||
const validMedia = (canonicalProduct.media || [])
|
||||
.filter(m => m.url && (m.url.startsWith('http://') || m.url.startsWith('https://')))
|
||||
.map(m => ({
|
||||
mediaContentType: 'IMAGE',
|
||||
originalSource: m.url,
|
||||
alt: canonicalProduct.content.name
|
||||
}));
|
||||
|
||||
if (validMedia.length > 0 && shopifyProduct?.id) {
|
||||
try {
|
||||
await this.client.graphql(PRODUCT_CREATE_MEDIA_MUTATION, {
|
||||
productId: shopifyProduct.id,
|
||||
media: validMedia
|
||||
});
|
||||
} catch (mediaErr) {
|
||||
console.warn(`[ShopifyPublisher] Media attach warning for product ${shopifyProduct.id}:`, mediaErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Save external_resources mapping
|
||||
if (existingResource) {
|
||||
await existingResource.update({
|
||||
external_url: shopifyProduct.onlineStoreUrl || existingResource.external_url,
|
||||
last_synced_at: new Date(),
|
||||
last_source_version: canonicalProduct.version || 1
|
||||
});
|
||||
} else {
|
||||
await models.ExternalResource.create({
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
resource_type: 'PRODUCT',
|
||||
source_type: 'PIM',
|
||||
source_id: productId,
|
||||
external_id: shopifyProduct.id,
|
||||
external_url: shopifyProduct.onlineStoreUrl || `https://${this.client.shopDomain}/admin/products/${shopifyProduct.id.split('/').pop()}`,
|
||||
status: 'active',
|
||||
last_synced_at: new Date(),
|
||||
last_source_version: canonicalProduct.version || 1
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
externalId: shopifyProduct.id,
|
||||
externalUrl: shopifyProduct.onlineStoreUrl,
|
||||
operation: isUpdate ? 'UPDATE' : 'CREATE'
|
||||
};
|
||||
}
|
||||
|
||||
async deleteProduct(context) {
|
||||
const { tenantId, integrationId, productId } = context;
|
||||
|
||||
const existingResource = await models.ExternalResource.findOne({
|
||||
where: {
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
source_id: productId,
|
||||
resource_type: 'PRODUCT'
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingResource) {
|
||||
return { success: true, message: 'Resource was not published to Shopify' };
|
||||
}
|
||||
|
||||
const response = await this.client.graphql(PRODUCT_DELETE_MUTATION, {
|
||||
input: { id: existingResource.external_id }
|
||||
});
|
||||
|
||||
const result = response.data?.productDelete;
|
||||
if (result?.userErrors?.length > 0) {
|
||||
throw new Error(`Shopify productDelete user errors: ${result.userErrors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
|
||||
await existingResource.destroy();
|
||||
return { success: true, deletedProductId: result.deletedProductId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { integrationService } from '../domain/integration.service.js';
|
||||
import { syncService } from '../domain/sync.service.js';
|
||||
import { queueService } from '../../../shared/infrastructure/queue/queue.service.js';
|
||||
import { QUEUE_NAMES } from '../../../shared/infrastructure/queue/queue.constants.js';
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
import { shopifyOAuthService } from '../adapters/shopify/shopify.oauth.service.js';
|
||||
|
||||
const getTenantId = (req) => {
|
||||
return req.context?.tenant_id || req.context?.tenantId || req.user?.tenant_id || req.user?.tenantId || 19;
|
||||
};
|
||||
|
||||
export const integrationController = {
|
||||
async listIntegrations(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const data = await integrationService.listIntegrations(tenantId);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async getIntegration(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const data = await integrationService.getById(req.params.id, tenantId);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async createIntegration(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const data = await integrationService.createIntegration(tenantId, req.body);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async updateIntegration(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const data = await integrationService.updateIntegration(req.params.id, tenantId, req.body);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteIntegration(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
await integrationService.deleteIntegration(req.params.id, tenantId);
|
||||
res.json({ success: true, message: 'Integration deleted successfully' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async setCredentials(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const { credential_type, secret_value, expires_at } = req.body;
|
||||
if (!credential_type || !secret_value) {
|
||||
return res.status(400).json({ success: false, message: 'credential_type and secret_value are required' });
|
||||
}
|
||||
const data = await integrationService.setCredentials(
|
||||
req.params.id,
|
||||
tenantId,
|
||||
credential_type,
|
||||
secret_value,
|
||||
expires_at
|
||||
);
|
||||
res.json({ success: true, message: 'Credentials updated securely', data: { id: data.id, type: data.credential_type } });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async getCredentials(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const data = await integrationService.getDecryptedCredentials(req.params.id, tenantId);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async testConnection(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const result = await integrationService.testConnection(req.params.id, tenantId);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async triggerSync(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const integrationId = req.params.id;
|
||||
const { productId, productIds } = req.body;
|
||||
|
||||
const integration = await integrationService.getById(integrationId, tenantId);
|
||||
const syncJob = await syncService.createSyncJob(tenantId, integrationId, 'manual');
|
||||
|
||||
let targets = [];
|
||||
if (productId) {
|
||||
targets.push(productId);
|
||||
} else if (Array.isArray(productIds)) {
|
||||
targets = productIds;
|
||||
} else {
|
||||
// Fetch all products for tenant if no specific ID provided
|
||||
const allProds = await models.Product.findAll({
|
||||
where: { tenant_id: tenantId },
|
||||
attributes: ['id']
|
||||
});
|
||||
targets = allProds.map(p => p.id);
|
||||
}
|
||||
|
||||
await syncJob.update({ total_items: targets.length });
|
||||
|
||||
for (const pId of targets) {
|
||||
await queueService.addJob(QUEUE_NAMES.INTEGRATION_SYNC, `sync_product_${pId}`, {
|
||||
tenantId,
|
||||
integrationId,
|
||||
syncJobId: syncJob.id,
|
||||
productId: pId,
|
||||
operation: 'UPDATE'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: `Sync job initialized for ${targets.length} products`,
|
||||
data: { syncJobId: syncJob.id, totalItems: targets.length }
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async listAllSyncJobs(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const jobs = await models.IntegrationSyncJob.findAll({
|
||||
where: { tenant_id: tenantId },
|
||||
include: [
|
||||
{
|
||||
model: models.Integration,
|
||||
as: 'integration',
|
||||
attributes: ['id', 'name', 'channel']
|
||||
}
|
||||
],
|
||||
order: [['created_at', 'DESC']],
|
||||
limit: 100
|
||||
});
|
||||
res.json({ success: true, data: jobs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async listSyncJobs(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const integrationId = req.params.id;
|
||||
const jobs = await models.IntegrationSyncJob.findAll({
|
||||
where: { tenant_id: tenantId, integration_id: integrationId },
|
||||
order: [['created_at', 'DESC']],
|
||||
limit: 50
|
||||
});
|
||||
res.json({ success: true, data: jobs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async listSyncItems(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const { jobId } = req.params;
|
||||
|
||||
// Validate UUID format before querying Postgres
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(jobId);
|
||||
if (!isUuid) {
|
||||
return res.json({ success: true, data: [] });
|
||||
}
|
||||
|
||||
const items = await models.SyncItem.findAll({
|
||||
where: { tenant_id: tenantId, sync_job_id: jobId },
|
||||
order: [['created_at', 'DESC']],
|
||||
raw: true
|
||||
});
|
||||
|
||||
// Safely enrich with Product details in-memory to prevent SQL type mismatch JOIN errors
|
||||
const productIds = Array.from(new Set(items.map(i => i.product_id).filter(Boolean)));
|
||||
const productMap = {};
|
||||
if (productIds.length > 0) {
|
||||
try {
|
||||
const prods = await models.Product.findAll({
|
||||
where: { id: productIds },
|
||||
attributes: ['id', 'name', 'sku'],
|
||||
raw: true
|
||||
});
|
||||
prods.forEach(p => { productMap[p.id] = p; });
|
||||
} catch (pErr) {
|
||||
console.warn('[listSyncItems] Failed to fetch product metadata:', pErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
const enriched = items.map(item => ({
|
||||
...item,
|
||||
product: productMap[item.product_id] || { id: item.product_id, name: `Product #${item.product_id.slice(0, 8)}`, sku: item.sku || '' }
|
||||
}));
|
||||
|
||||
res.json({ success: true, data: enriched });
|
||||
} catch (err) {
|
||||
console.error('[listSyncItems] Error:', err);
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start Shopify OAuth flow:
|
||||
* Requires credentials: shop_domain, api_key already saved on this integration.
|
||||
* Returns { authorizationUrl } which the frontend opens in a new tab.
|
||||
*/
|
||||
async startShopifyOAuth(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const integrationId = req.params.id;
|
||||
|
||||
// Load decrypted credentials to get shop_domain + api_key
|
||||
const creds = await integrationService.getDecryptedCredentials(integrationId, tenantId);
|
||||
|
||||
if (!creds.shop_domain) {
|
||||
return res.status(400).json({ success: false, message: 'Save shop_domain credential first before starting OAuth' });
|
||||
}
|
||||
if (!creds.api_key) {
|
||||
return res.status(400).json({ success: false, message: 'Save api_key credential first before starting OAuth' });
|
||||
}
|
||||
|
||||
const result = await shopifyOAuthService.buildAuthorizationUrl(
|
||||
integrationId,
|
||||
creds.shop_domain,
|
||||
creds.api_key,
|
||||
tenantId
|
||||
);
|
||||
|
||||
console.log(`[ShopifyOAuth] Generated Authorization URL:\n Domain: ${creds.shop_domain}\n API Key: ${creds.api_key}\n URL: ${result.authorizationUrl}`);
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Shopify OAuth callback — public route (no auth middleware needed).
|
||||
* Shopify redirects here with ?code=...&shop=...&state=...
|
||||
* After exchange, redirects user to frontend /integrations?oauth=success&integrationId=...
|
||||
*/
|
||||
async handleShopifyOAuthCallback(req, res) {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
try {
|
||||
const result = await shopifyOAuthService.handleCallback(req.query);
|
||||
res.redirect(`${frontendUrl}/integrations?oauth=success&integrationId=${result.integrationId}&shop=${result.shop}`);
|
||||
} catch (err) {
|
||||
console.error('[ShopifyOAuth] Callback error:', err.message);
|
||||
res.redirect(`${frontendUrl}/integrations?oauth=error&message=${encodeURIComponent(err.message)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
import { secretService } from '../../../shared/infrastructure/secrets/secret.service.js';
|
||||
import { adapterRegistry } from '../adapters/adapter.registry.js';
|
||||
|
||||
export const integrationService = {
|
||||
async listIntegrations(tenantId) {
|
||||
return await models.Integration.findAll({
|
||||
where: { tenant_id: tenantId },
|
||||
include: [
|
||||
{
|
||||
model: models.IntegrationCredential,
|
||||
as: 'credentials',
|
||||
attributes: ['id', 'credential_type', 'expires_at', 'created_at']
|
||||
}
|
||||
],
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
},
|
||||
|
||||
async getById(id, tenantId) {
|
||||
const integration = await models.Integration.findOne({
|
||||
where: { id, tenant_id: tenantId },
|
||||
include: [
|
||||
{
|
||||
model: models.IntegrationCredential,
|
||||
as: 'credentials',
|
||||
attributes: ['id', 'credential_type', 'expires_at', 'created_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new Error(`Integration ${id} not found`);
|
||||
}
|
||||
return integration;
|
||||
},
|
||||
|
||||
async createIntegration(tenantId, body) {
|
||||
return await models.Integration.create({
|
||||
tenant_id: tenantId,
|
||||
name: body.name,
|
||||
channel: body.channel || body.channelName || 'shopify',
|
||||
integration_type: body.integration_type || body.integrationType || 'ecommerce',
|
||||
status: body.status || 'active',
|
||||
sync_mode: body.sync_mode || body.syncMode || 'auto',
|
||||
sync_frequency: body.sync_frequency || body.syncFrequency || 'realtime',
|
||||
health_status: 'healthy'
|
||||
});
|
||||
},
|
||||
|
||||
async updateIntegration(id, tenantId, updates) {
|
||||
const integration = await this.getById(id, tenantId);
|
||||
return await integration.update(updates);
|
||||
},
|
||||
|
||||
async setCredentials(id, tenantId, credentialType, secretValue, expiresAt = null) {
|
||||
const integration = await this.getById(id, tenantId);
|
||||
const encryptedSecret = secretService.encrypt(secretValue);
|
||||
|
||||
// Remove old credential of same type
|
||||
await models.IntegrationCredential.destroy({
|
||||
where: { integration_id: id, credential_type: credentialType, tenant_id: tenantId }
|
||||
});
|
||||
|
||||
return await models.IntegrationCredential.create({
|
||||
tenant_id: tenantId,
|
||||
integration_id: id,
|
||||
credential_type: credentialType,
|
||||
encrypted_secret: encryptedSecret,
|
||||
key_version: 1,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
},
|
||||
|
||||
async getDecryptedCredentials(id, tenantId) {
|
||||
const credentials = await models.IntegrationCredential.findAll({
|
||||
where: { integration_id: id, tenant_id: tenantId }
|
||||
});
|
||||
|
||||
const map = {};
|
||||
credentials.forEach(c => {
|
||||
map[c.credential_type] = secretService.decrypt(c.encrypted_secret);
|
||||
});
|
||||
return map;
|
||||
},
|
||||
|
||||
async testConnection(id, tenantId) {
|
||||
const integration = await this.getById(id, tenantId);
|
||||
const decryptedConfig = await this.getDecryptedCredentials(id, tenantId);
|
||||
|
||||
const adapter = adapterRegistry.getAdapter(integration.channel, decryptedConfig);
|
||||
return await adapter.testConnection();
|
||||
},
|
||||
|
||||
async deleteIntegration(id, tenantId) {
|
||||
const integration = await this.getById(id, tenantId);
|
||||
|
||||
// Clean up credentials and associated records
|
||||
await models.IntegrationCredential.destroy({
|
||||
where: { integration_id: id, tenant_id: tenantId }
|
||||
});
|
||||
if (models.ExternalResource) {
|
||||
await models.ExternalResource.destroy({
|
||||
where: { integration_id: id, tenant_id: tenantId }
|
||||
});
|
||||
}
|
||||
if (models.PublishingRule) {
|
||||
await models.PublishingRule.destroy({
|
||||
where: { integration_id: id, tenant_id: tenantId }
|
||||
});
|
||||
}
|
||||
|
||||
await integration.destroy();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
|
||||
export const ruleService = {
|
||||
async evaluateRules(integrationId, tenantId, canonicalProduct) {
|
||||
const rules = await models.PublishingRule.findAll({
|
||||
where: { integration_id: integrationId, tenant_id: tenantId }
|
||||
});
|
||||
|
||||
if (rules.length === 0) {
|
||||
return { eligible: true, reasons: [] };
|
||||
}
|
||||
|
||||
const reasons = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
const cond = rule.conditions_json || {};
|
||||
|
||||
// Category check
|
||||
if (cond.allowedCategories && Array.isArray(cond.allowedCategories) && cond.allowedCategories.length > 0) {
|
||||
const prodCatId = canonicalProduct.taxonomy?.category?.id;
|
||||
if (!prodCatId || !cond.allowedCategories.includes(prodCatId)) {
|
||||
reasons.push(`Product category not in allowed rule list for rule ${rule.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Status check
|
||||
if (cond.requiredStatus && canonicalProduct.content?.status !== cond.requiredStatus) {
|
||||
reasons.push(`Product status '${canonicalProduct.content?.status}' does not match required status '${cond.requiredStatus}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: reasons.length === 0,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import crypto from 'crypto';
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
|
||||
export const syncService = {
|
||||
generateIdempotencyKey(tenantId, integrationId, productId, sourceVersion, operation) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(`${tenantId}:${integrationId}:${productId}:${sourceVersion || 1}:${operation || 'UPDATE'}`)
|
||||
.digest('hex');
|
||||
},
|
||||
|
||||
async createSyncJob(tenantId, integrationId, triggerSource = 'manual') {
|
||||
return await models.IntegrationSyncJob.create({
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
trigger_source: triggerSource,
|
||||
status: 'pending',
|
||||
total_items: 0,
|
||||
success_items: 0,
|
||||
failed_items: 0,
|
||||
started_at: new Date()
|
||||
});
|
||||
},
|
||||
|
||||
async createSyncItem(tenantId, syncJobId, integrationId, productId, operation = 'UPDATE', sourceVersion = 1) {
|
||||
const idempotencyKey = this.generateIdempotencyKey(tenantId, integrationId, productId, sourceVersion, operation);
|
||||
|
||||
// Find existing item or create
|
||||
const [item] = await models.SyncItem.findOrCreate({
|
||||
where: { tenant_id: tenantId, idempotency_key: idempotencyKey },
|
||||
defaults: {
|
||||
tenant_id: tenantId,
|
||||
sync_job_id: syncJobId,
|
||||
integration_id: integrationId,
|
||||
product_id: productId,
|
||||
operation,
|
||||
status: 'pending',
|
||||
source_version: sourceVersion,
|
||||
idempotency_key: idempotencyKey,
|
||||
attempt_count: 0
|
||||
}
|
||||
});
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
async recordAttempt(tenantId, syncItemId, attemptNumber, requestMethod, requestUrl, idempotencyKey) {
|
||||
return await models.SyncAttempt.create({
|
||||
tenant_id: tenantId,
|
||||
sync_item_id: syncItemId,
|
||||
attempt_number: attemptNumber,
|
||||
started_at: new Date(),
|
||||
status: 'processing',
|
||||
request_method: requestMethod,
|
||||
request_url: requestUrl,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
},
|
||||
|
||||
async completeAttempt(attemptId, status, responseStatus, durationMs, errorCode = null, errorMessage = null) {
|
||||
const attempt = await models.SyncAttempt.findByPk(attemptId);
|
||||
if (attempt) {
|
||||
await attempt.update({
|
||||
status,
|
||||
response_status: responseStatus,
|
||||
duration_ms: durationMs,
|
||||
error_code: errorCode,
|
||||
error_message: errorMessage,
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async logError(tenantId, syncJobId, syncItemId, errorCode, errorType, message, providerMessage = null, httpStatus = null, retryable = true, attemptNumber = 1, metadata = {}) {
|
||||
return await models.SyncError.create({
|
||||
tenant_id: tenantId,
|
||||
sync_job_id: syncJobId,
|
||||
sync_item_id: syncItemId,
|
||||
error_code: errorCode,
|
||||
error_type: errorType,
|
||||
message,
|
||||
provider_message: providerMessage,
|
||||
http_status: httpStatus,
|
||||
retryable,
|
||||
attempt_number: attemptNumber,
|
||||
metadata
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,32 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import router from './routes/integration.routes.js';
|
||||
import { startSyncWorker } from './workers/sync.worker.js';
|
||||
import { startOutboxWorker } from './workers/outbox.worker.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const defaultIntegrations = [
|
||||
{ id: '1', name: 'Shopify Storefront Sync', type: 'ecommerce', status: 'active', target_channel: 'shopify', createdAt: new Date() },
|
||||
{ id: '2', name: 'Amazon Seller Central', type: 'marketplace', status: 'active', target_channel: 'amazon', createdAt: new Date() },
|
||||
{ id: '3', name: 'SAP ERP Connector', type: 'erp', status: 'inactive', target_channel: 'b2b', createdAt: new Date() }
|
||||
];
|
||||
|
||||
router.get('/integrations', (req, res) => {
|
||||
res.json({ success: true, data: defaultIntegrations });
|
||||
});
|
||||
|
||||
router.get('/integrations/:id', (req, res) => {
|
||||
const item = defaultIntegrations.find(i => i.id === req.params.id) || defaultIntegrations[0];
|
||||
res.json({ success: true, data: item });
|
||||
});
|
||||
|
||||
router.post('/integrations', (req, res) => {
|
||||
res.json({ success: true, data: { id: String(Date.now()), ...req.body, status: 'active' } });
|
||||
});
|
||||
|
||||
router.put('/integrations/:id', (req, res) => {
|
||||
res.json({ success: true, data: { id: req.params.id, ...req.body } });
|
||||
});
|
||||
|
||||
router.delete('/integrations/:id', (req, res) => {
|
||||
res.json({ success: true, message: 'Deleted' });
|
||||
});
|
||||
// Initialize background queue worker and outbox polling worker
|
||||
try {
|
||||
startSyncWorker();
|
||||
startOutboxWorker();
|
||||
} catch (err) {
|
||||
console.warn('Background worker initialization deferred:', err.message);
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const ChannelMapping = sequelize.define(
|
||||
'ChannelMapping',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
entity_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
source_path: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
target_path: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
transformation_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
defaultValue: 'string'
|
||||
},
|
||||
transformation_config: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {}
|
||||
},
|
||||
default_value: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
required: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
defaultValue: 'active'
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'channel_mappings',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
ChannelMapping.associate = (models) => {
|
||||
if (models.Integration) {
|
||||
ChannelMapping.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
};
|
||||
|
||||
return ChannelMapping;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const ExternalResource = sequelize.define(
|
||||
'ExternalResource',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
resource_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
source_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
defaultValue: 'PIM'
|
||||
},
|
||||
source_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
external_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
external_parent_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
external_url: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
defaultValue: 'active'
|
||||
},
|
||||
last_synced_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
last_source_version: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'external_resources',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
ExternalResource.associate = (models) => {
|
||||
if (models.Integration) {
|
||||
ExternalResource.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
};
|
||||
|
||||
return ExternalResource;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const Integration = sequelize.define(
|
||||
'Integration',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
channel: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
integration_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
defaultValue: 'ecommerce'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'active'
|
||||
},
|
||||
sync_mode: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'auto'
|
||||
},
|
||||
sync_frequency: {
|
||||
type: DataTypes.STRING(50),
|
||||
defaultValue: 'realtime'
|
||||
},
|
||||
health_status: {
|
||||
type: DataTypes.STRING(20),
|
||||
defaultValue: 'healthy'
|
||||
},
|
||||
last_synced_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'integrations',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
Integration.associate = (models) => {
|
||||
if (models.Tenant) {
|
||||
Integration.belongsTo(models.Tenant, { foreignKey: 'tenant_id', as: 'tenant' });
|
||||
}
|
||||
if (models.IntegrationCredential) {
|
||||
Integration.hasMany(models.IntegrationCredential, { foreignKey: 'integration_id', as: 'credentials' });
|
||||
}
|
||||
if (models.PublishingRule) {
|
||||
Integration.hasMany(models.PublishingRule, { foreignKey: 'integration_id', as: 'publishingRules' });
|
||||
}
|
||||
if (models.ChannelMapping) {
|
||||
Integration.hasMany(models.ChannelMapping, { foreignKey: 'integration_id', as: 'channelMappings' });
|
||||
}
|
||||
if (models.SyncJob) {
|
||||
Integration.hasMany(models.SyncJob, { foreignKey: 'integration_id', as: 'syncJobs' });
|
||||
}
|
||||
if (models.ExternalResource) {
|
||||
Integration.hasMany(models.ExternalResource, { foreignKey: 'integration_id', as: 'externalResources' });
|
||||
}
|
||||
};
|
||||
|
||||
return Integration;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const IntegrationCredential = sequelize.define(
|
||||
'IntegrationCredential',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
credential_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
encrypted_secret: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
key_version: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1,
|
||||
allowNull: false
|
||||
},
|
||||
expires_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'integration_credentials',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
IntegrationCredential.associate = (models) => {
|
||||
if (models.Integration) {
|
||||
IntegrationCredential.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
};
|
||||
|
||||
return IntegrationCredential;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const OutboxEvent = sequelize.define(
|
||||
'OutboxEvent',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
event_type: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
aggregate_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
defaultValue: 'PRODUCT'
|
||||
},
|
||||
aggregate_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
payload: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
defaultValue: 'pending',
|
||||
allowNull: false
|
||||
},
|
||||
event_version: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
idempotency_key: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
retry_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
locked_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
locked_by: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
last_error: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
scheduled_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
processed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'outbox_events',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
return OutboxEvent;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const PublishingRule = sequelize.define(
|
||||
'PublishingRule',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
rule_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
conditions_json: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'publishing_rules',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
PublishingRule.associate = (models) => {
|
||||
if (models.Integration) {
|
||||
PublishingRule.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
};
|
||||
|
||||
return PublishingRule;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const SyncAttempt = sequelize.define(
|
||||
'SyncAttempt',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
sync_item_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
attempt_number: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
started_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
completed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false
|
||||
},
|
||||
request_method: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false
|
||||
},
|
||||
request_url: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
idempotency_key: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
response_status: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
error_code: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
error_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
duration_ms: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'sync_attempts',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false
|
||||
}
|
||||
);
|
||||
|
||||
SyncAttempt.associate = (models) => {
|
||||
if (models.SyncItem) {
|
||||
SyncAttempt.belongsTo(models.SyncItem, { foreignKey: 'sync_item_id', as: 'syncItem' });
|
||||
}
|
||||
};
|
||||
|
||||
return SyncAttempt;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const SyncError = sequelize.define(
|
||||
'SyncError',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
sync_job_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
sync_item_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
error_code: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
error_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
provider_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
http_status: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
retryable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
attempt_number: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'sync_errors',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false
|
||||
}
|
||||
);
|
||||
|
||||
SyncError.associate = (models) => {
|
||||
if (models.SyncJob) {
|
||||
SyncError.belongsTo(models.SyncJob, { foreignKey: 'sync_job_id', as: 'job' });
|
||||
}
|
||||
if (models.SyncItem) {
|
||||
SyncError.belongsTo(models.SyncItem, { foreignKey: 'sync_item_id', as: 'item' });
|
||||
}
|
||||
};
|
||||
|
||||
return SyncError;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const SyncItem = sequelize.define(
|
||||
'SyncItem',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
sync_job_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
product_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
variant_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
sku: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
operation: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
source_version: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
idempotency_key: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
external_resource_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
attempt_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
error_code: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
error_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'sync_items',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
SyncItem.associate = (models) => {
|
||||
if (models.SyncJob) {
|
||||
SyncItem.belongsTo(models.SyncJob, { foreignKey: 'sync_job_id', as: 'job' });
|
||||
}
|
||||
if (models.Integration) {
|
||||
SyncItem.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
if (models.Product) {
|
||||
SyncItem.belongsTo(models.Product, { foreignKey: 'product_id', as: 'product' });
|
||||
}
|
||||
if (models.SyncAttempt) {
|
||||
SyncItem.hasMany(models.SyncAttempt, { foreignKey: 'sync_item_id', as: 'attempts' });
|
||||
}
|
||||
if (models.SyncError) {
|
||||
SyncItem.hasMany(models.SyncError, { foreignKey: 'sync_item_id', as: 'errors' });
|
||||
}
|
||||
};
|
||||
|
||||
return SyncItem;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const SyncJob = sequelize.define(
|
||||
'SyncJob',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
trigger_source: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
total_items: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
success_items: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
failed_items: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
started_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
completed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'sync_jobs',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
SyncJob.associate = (models) => {
|
||||
if (models.Integration) {
|
||||
SyncJob.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
if (models.SyncItem) {
|
||||
SyncJob.hasMany(models.SyncItem, { foreignKey: 'sync_job_id', as: 'items' });
|
||||
}
|
||||
if (models.SyncError) {
|
||||
SyncJob.hasMany(models.SyncError, { foreignKey: 'sync_job_id', as: 'errors' });
|
||||
}
|
||||
};
|
||||
|
||||
return SyncJob;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
|
||||
export const canonicalProductBuilder = {
|
||||
async build(productId, tenantId) {
|
||||
const product = await models.Product.findOne({
|
||||
where: { id: productId, tenant_id: tenantId },
|
||||
include: [
|
||||
{ model: models.Brand, as: 'brand', required: false },
|
||||
{ model: models.Categorie, as: 'category', required: false },
|
||||
{ model: models.Catalog, as: 'family', required: false },
|
||||
{
|
||||
model: models.ProductAttributeValue,
|
||||
as: 'attributeValues',
|
||||
required: false,
|
||||
include: [{ model: models.Attribute, as: 'attribute', required: false }]
|
||||
},
|
||||
{
|
||||
model: models.ProductAsset,
|
||||
as: 'productAssets',
|
||||
required: false,
|
||||
include: [{ model: models.Asset, as: 'asset', required: false }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new Error(`Product ${productId} not found for tenant ${tenantId}`);
|
||||
}
|
||||
|
||||
// Fetch product variants
|
||||
const variants = await models.Variant.findAll({
|
||||
where: { product_id: productId, tenant_id: tenantId },
|
||||
include: [
|
||||
{
|
||||
model: models.VariantValue,
|
||||
as: 'values',
|
||||
required: false,
|
||||
include: [{ model: models.Attribute, as: 'axis', required: false }]
|
||||
},
|
||||
{
|
||||
model: models.VariantAsset,
|
||||
as: 'variantAssets',
|
||||
required: false,
|
||||
include: [{ model: models.Asset, as: 'asset', required: false }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Compile attributes map
|
||||
const attributes = {};
|
||||
if (Array.isArray(product.attributeValues)) {
|
||||
product.attributeValues.forEach(av => {
|
||||
const key = av.attribute?.code || av.axis?.code || av.attribute_id;
|
||||
attributes[key] = av.value || av.value_text || av.value_number || av.value_boolean;
|
||||
});
|
||||
}
|
||||
|
||||
// Compile media assets
|
||||
const media = [];
|
||||
const baseUrl = process.env.PUBLIC_APP_URL || process.env.APP_URL || 'http://localhost:5002';
|
||||
if (Array.isArray(product.productAssets)) {
|
||||
product.productAssets.forEach(pa => {
|
||||
if (pa.asset) {
|
||||
let url = pa.asset.file_url || '';
|
||||
if (url.startsWith('/')) {
|
||||
url = `${baseUrl}${url}`;
|
||||
}
|
||||
media.push({
|
||||
id: pa.asset.id,
|
||||
url,
|
||||
role: pa.role || 'gallery',
|
||||
isPrimary: pa.is_primary || false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Compile variants list
|
||||
const compiledVariants = variants.map(v => {
|
||||
const vAttrs = {};
|
||||
if (Array.isArray(v.values)) {
|
||||
v.values.forEach(val => {
|
||||
const key = val.axis?.code || val.attribute_id;
|
||||
vAttrs[key] = val.value_text || val.value_number;
|
||||
});
|
||||
}
|
||||
|
||||
const vMedia = [];
|
||||
if (Array.isArray(v.variantAssets)) {
|
||||
v.variantAssets.forEach(va => {
|
||||
if (va.asset) {
|
||||
let url = va.asset.file_url || '';
|
||||
if (url.startsWith('/')) {
|
||||
url = `${baseUrl}${url}`;
|
||||
}
|
||||
vMedia.push({
|
||||
id: va.asset.id,
|
||||
url,
|
||||
role: va.role || 'gallery',
|
||||
isPrimary: va.is_primary || false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: v.id,
|
||||
sku: v.sku,
|
||||
name: v.name,
|
||||
price: parseFloat(v.price) || 0,
|
||||
costPrice: parseFloat(v.costPrice) || 0,
|
||||
stock: parseInt(v.stock, 10) || 0,
|
||||
status: v.status,
|
||||
attributes: vAttrs,
|
||||
media: vMedia
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
identity: {
|
||||
id: product.id,
|
||||
tenantId: product.tenant_id,
|
||||
code: product.code || product.id,
|
||||
sku: product.sku || ''
|
||||
},
|
||||
content: {
|
||||
name: product.name,
|
||||
description: product.description || '',
|
||||
status: product.status || 'draft'
|
||||
},
|
||||
taxonomy: {
|
||||
brand: product.brand ? { id: product.brand.id, name: product.brand.name } : null,
|
||||
category: product.category ? { id: product.category.id, name: product.category.name } : null,
|
||||
family: product.family ? { id: product.family.id, name: product.family.name } : null
|
||||
},
|
||||
attributes,
|
||||
media,
|
||||
variants: compiledVariants,
|
||||
version: product.updated_at ? new Date(product.updated_at).getTime() : 1
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Router } from 'express';
|
||||
import { integrationController } from '../controllers/integration.controller.js';
|
||||
import { authenticate } from '../../../shared/middleware/auth.middleware.js';
|
||||
import { authorize } from '../../../shared/middleware/permission.middleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── PUBLIC ROUTE — Shopify OAuth callback (no auth; Shopify redirects here) ──
|
||||
router.get(
|
||||
'/integrations/shopify/oauth/callback',
|
||||
integrationController.handleShopifyOAuthCallback
|
||||
);
|
||||
|
||||
// ─── Protected routes ─────────────────────────────────────────────────────────
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/integrations', authorize(['settings.integrations']), integrationController.listIntegrations);
|
||||
router.post('/integrations', authorize(['settings.integrations']), integrationController.createIntegration);
|
||||
|
||||
router.get('/integrations/:id', authorize(['settings.integrations']), integrationController.getIntegration);
|
||||
router.put('/integrations/:id', authorize(['settings.integrations']), integrationController.updateIntegration);
|
||||
router.delete('/integrations/:id', authorize(['settings.integrations']), integrationController.deleteIntegration);
|
||||
|
||||
router.get('/integrations/:id/credentials', authorize(['settings.integrations']), integrationController.getCredentials);
|
||||
router.post('/integrations/:id/credentials', authorize(['settings.integrations']), integrationController.setCredentials);
|
||||
router.post('/integrations/:id/test-connection', authorize(['settings.integrations']), integrationController.testConnection);
|
||||
router.post('/integrations/:id/sync', authorize(['settings.integrations']), integrationController.triggerSync);
|
||||
|
||||
// ─── Shopify OAuth — Start flow (protected; user-initiated) ──────────────────
|
||||
router.post('/integrations/:id/shopify/oauth/start', authorize(['settings.integrations']), integrationController.startShopifyOAuth);
|
||||
|
||||
router.get('/integrations/jobs/all', authorize(['settings.integrations']), integrationController.listAllSyncJobs);
|
||||
router.get('/integrations/:id/jobs', authorize(['settings.integrations']), integrationController.listSyncJobs);
|
||||
router.get('/integrations/jobs/:jobId/items', authorize(['settings.integrations']), integrationController.listSyncItems);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { models, sequelize } from '../../../shared/database/models.js';
|
||||
import { queueService } from '../../../shared/infrastructure/queue/queue.service.js';
|
||||
import { QUEUE_NAMES } from '../../../shared/infrastructure/queue/queue.constants.js';
|
||||
import { syncService } from '../domain/sync.service.js';
|
||||
import { Op } from 'sequelize';
|
||||
import winston from 'winston';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
export const processOutboxEvents = async () => {
|
||||
const workerId = `worker_${process.pid}_${Date.now()}`;
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
// 1. Claim pending outbox events using locking
|
||||
const [claimedCount] = await models.OutboxEvent.update(
|
||||
{
|
||||
status: 'processing',
|
||||
locked_at: now,
|
||||
locked_by: workerId
|
||||
},
|
||||
{
|
||||
where: {
|
||||
status: 'pending',
|
||||
scheduled_at: { [Op.lte]: now }
|
||||
},
|
||||
limit: 50
|
||||
}
|
||||
);
|
||||
|
||||
if (claimedCount === 0) return;
|
||||
|
||||
// 2. Fetch claimed events
|
||||
const claimedEvents = await models.OutboxEvent.findAll({
|
||||
where: {
|
||||
locked_by: workerId,
|
||||
status: 'processing'
|
||||
}
|
||||
});
|
||||
|
||||
for (const event of claimedEvents) {
|
||||
try {
|
||||
const { tenant_id, aggregate_id, event_type, payload } = event;
|
||||
const productId = aggregate_id;
|
||||
|
||||
// Find active integrations for tenant
|
||||
const activeIntegrations = await models.Integration.findAll({
|
||||
where: { tenant_id, status: 'active', sync_mode: 'auto' }
|
||||
});
|
||||
|
||||
for (const integration of activeIntegrations) {
|
||||
const syncJob = await syncService.createSyncJob(tenant_id, integration.id, 'outbox');
|
||||
|
||||
await queueService.addJob(QUEUE_NAMES.INTEGRATION_SYNC, `sync_product_${productId}`, {
|
||||
tenantId: tenant_id,
|
||||
integrationId: integration.id,
|
||||
syncJobId: syncJob.id,
|
||||
productId,
|
||||
operation: event_type.includes('delete') ? 'DELETE' : 'UPDATE'
|
||||
});
|
||||
}
|
||||
|
||||
await event.update({ status: 'completed', processed_at: new Date() });
|
||||
} catch (err) {
|
||||
logger.error(`Error processing outbox event ${event.id}: ${err.message}`);
|
||||
await event.update({
|
||||
status: 'failed',
|
||||
retry_count: (event.retry_count || 0) + 1,
|
||||
last_error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Outbox worker failure: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
let isProcessing = false;
|
||||
|
||||
export const guardedProcessOutboxEvents = async () => {
|
||||
if (isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
try {
|
||||
await processOutboxEvents();
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
export const startOutboxWorker = (intervalMs = 10000) => {
|
||||
setInterval(guardedProcessOutboxEvents, intervalMs);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { queueService } from '../../../shared/infrastructure/queue/queue.service.js';
|
||||
import { QUEUE_NAMES } from '../../../shared/infrastructure/queue/queue.constants.js';
|
||||
import { canonicalProductBuilder } from '../projection/canonical-product.builder.js';
|
||||
import { ruleService } from '../domain/rule.service.js';
|
||||
import { syncService } from '../domain/sync.service.js';
|
||||
import { integrationService } from '../domain/integration.service.js';
|
||||
import { adapterRegistry } from '../adapters/adapter.registry.js';
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
import winston from 'winston';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
export const startSyncWorker = () => {
|
||||
queueService.registerWorker(QUEUE_NAMES.INTEGRATION_SYNC, async (job) => {
|
||||
const { tenantId, integrationId, syncJobId, productId, operation = 'UPDATE' } = job.data;
|
||||
logger.info(`Processing sync job for product ${productId} on integration ${integrationId}`);
|
||||
|
||||
// Build canonical product
|
||||
const canonicalProduct = await canonicalProductBuilder.build(productId, tenantId);
|
||||
|
||||
// Create sync item
|
||||
const syncItem = await syncService.createSyncItem(
|
||||
tenantId,
|
||||
syncJobId,
|
||||
integrationId,
|
||||
productId,
|
||||
operation,
|
||||
canonicalProduct.version
|
||||
);
|
||||
|
||||
// Rule evaluation
|
||||
const ruleResult = await ruleService.evaluateRules(integrationId, tenantId, canonicalProduct);
|
||||
if (!ruleResult.eligible) {
|
||||
await syncItem.update({
|
||||
status: 'skipped',
|
||||
error_code: 'RULE_INELIGIBLE',
|
||||
error_message: ruleResult.reasons.join('; ')
|
||||
});
|
||||
logger.info(`Skipped product ${productId}: ${ruleResult.reasons.join('; ')}`);
|
||||
return { skipped: true, reasons: ruleResult.reasons };
|
||||
}
|
||||
|
||||
const attemptNumber = (syncItem.attempt_count || 0) + 1;
|
||||
const requestUrl = `integration://${integrationId}/product/${productId}`;
|
||||
const attemptRecord = await syncService.recordAttempt(
|
||||
tenantId,
|
||||
syncItem.id,
|
||||
attemptNumber,
|
||||
'POST',
|
||||
requestUrl,
|
||||
syncItem.idempotency_key
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
await syncItem.update({ status: 'processing', attempt_count: attemptNumber });
|
||||
|
||||
const integration = await integrationService.getById(integrationId, tenantId);
|
||||
const decryptedCredentials = await integrationService.getDecryptedCredentials(integrationId, tenantId);
|
||||
const adapter = adapterRegistry.getAdapter(integration.channel, decryptedCredentials);
|
||||
|
||||
let result;
|
||||
if (operation === 'DELETE') {
|
||||
result = await adapter.deleteProduct({ tenantId, integrationId, productId });
|
||||
} else {
|
||||
result = await adapter.publishProduct({ tenantId, integrationId, canonicalProduct });
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
await syncService.completeAttempt(attemptRecord.id, 'success', 200, durationMs);
|
||||
await syncItem.update({ status: 'success', error_code: null, error_message: null });
|
||||
|
||||
// Increment job success count and check completion
|
||||
if (syncJobId) {
|
||||
await models.IntegrationSyncJob.increment('success_items', { where: { id: syncJobId } });
|
||||
const jobRecord = await models.IntegrationSyncJob.findByPk(syncJobId);
|
||||
if (jobRecord && (jobRecord.success_items + jobRecord.failed_items) >= jobRecord.total_items) {
|
||||
await jobRecord.update({
|
||||
status: jobRecord.failed_items === 0 ? 'completed' : (jobRecord.success_items > 0 ? 'completed' : 'failed'),
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Successfully synced product ${productId} to ${integration.channel}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const httpStatus = err.status || 500;
|
||||
const errorMessage = err.message || 'Unknown integration error';
|
||||
|
||||
await syncService.completeAttempt(attemptRecord.id, 'failed', httpStatus, durationMs, 'ADAPTER_ERROR', errorMessage);
|
||||
await syncItem.update({ status: 'failed', error_code: 'ADAPTER_ERROR', error_message: errorMessage });
|
||||
|
||||
await syncService.logError(
|
||||
tenantId,
|
||||
syncJobId,
|
||||
syncItem.id,
|
||||
'ADAPTER_ERROR',
|
||||
httpStatus === 429 ? 'RATE_LIMIT' : 'PROVIDER_ERROR',
|
||||
errorMessage,
|
||||
err.providerData ? JSON.stringify(err.providerData) : null,
|
||||
httpStatus,
|
||||
err.retryable !== false,
|
||||
attemptNumber,
|
||||
{ productId, integrationId }
|
||||
);
|
||||
|
||||
if (syncJobId) {
|
||||
await models.IntegrationSyncJob.increment('failed_items', { where: { id: syncJobId } });
|
||||
const jobRecord = await models.IntegrationSyncJob.findByPk(syncJobId);
|
||||
if (jobRecord && (jobRecord.success_items + jobRecord.failed_items) >= jobRecord.total_items) {
|
||||
await jobRecord.update({
|
||||
status: jobRecord.success_items > 0 ? 'completed' : 'failed',
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -14,7 +14,11 @@ export class AssetFamilyRepository {
|
||||
|
||||
async findAll(options = {}, context = {}) {
|
||||
const where = applyTenantScope(options.where || {}, context);
|
||||
const queryOptions = { ...options, where };
|
||||
const queryOptions = {
|
||||
order: [['created_at', 'DESC']],
|
||||
...options,
|
||||
where
|
||||
};
|
||||
try {
|
||||
return await models.AssetFamily.findAll(queryOptions);
|
||||
} catch (err) {
|
||||
|
||||
@@ -37,6 +37,10 @@ export default (sequelize) => {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
is_variant_eligible: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
category: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'other'
|
||||
|
||||
@@ -4,7 +4,11 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
|
||||
export class AssetTypeRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const where = applyTenantScope(options.where || {}, context);
|
||||
return await models.AssetType.findAll({ ...options, where });
|
||||
return await models.AssetType.findAll({
|
||||
order: [['created_at', 'DESC']],
|
||||
...options,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id, options = {}, context = {}) {
|
||||
|
||||
@@ -32,6 +32,7 @@ export class AssetTypeService {
|
||||
description: data.description || null,
|
||||
status: data.status || 'active',
|
||||
is_required: data.is_required !== undefined ? Boolean(data.is_required) : (data.isRequired !== undefined ? Boolean(data.isRequired) : false),
|
||||
is_variant_eligible: data.is_variant_eligible !== undefined ? Boolean(data.is_variant_eligible) : (data.isVariantEligible !== undefined ? Boolean(data.isVariantEligible) : false),
|
||||
category: data.category || 'other',
|
||||
validation: data.validation || {}
|
||||
};
|
||||
@@ -72,6 +73,9 @@ export class AssetTypeService {
|
||||
if (data.isRequired !== undefined && data.is_required === undefined) {
|
||||
payload.is_required = Boolean(data.isRequired);
|
||||
}
|
||||
if (data.isVariantEligible !== undefined && data.is_variant_eligible === undefined) {
|
||||
payload.is_variant_eligible = Boolean(data.isVariantEligible);
|
||||
}
|
||||
|
||||
const updatedRecord = await repository.update(id, payload);
|
||||
|
||||
|
||||
@@ -45,6 +45,23 @@ export class CompletenessService {
|
||||
as: 'attributeValues',
|
||||
include: [{ model: models.Attribute, as: 'attribute' }]
|
||||
},
|
||||
{
|
||||
model: models.Variant,
|
||||
as: 'variants',
|
||||
include: [
|
||||
{
|
||||
model: models.VariantAsset,
|
||||
as: 'variantAssets',
|
||||
include: [
|
||||
{
|
||||
model: models.Asset,
|
||||
as: 'asset',
|
||||
include: [{ model: models.AssetType, as: 'assetType' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.ProductAsset,
|
||||
as: 'productAssets',
|
||||
@@ -165,8 +182,11 @@ export class CompletenessService {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Validate required assets
|
||||
const assignedAssetTypeIds = (product.productAssets || []).map(pa => pa.asset?.assetType?.id).filter(Boolean);
|
||||
// 2. Validate required assets (check product assets AND variant assets)
|
||||
const productAssetTypeIds = (product.productAssets || []).map(pa => pa.asset?.assetType?.id).filter(Boolean);
|
||||
const variantAssetTypeIds = (product.variants || []).flatMap(v => (v.variantAssets || []).map(va => va.asset?.assetType?.id)).filter(Boolean);
|
||||
const assignedAssetTypeIds = Array.from(new Set([...productAssetTypeIds, ...variantAssetTypeIds]));
|
||||
|
||||
for (const assetType of uniqueRequiredAssetTypes) {
|
||||
if (assignedAssetTypeIds.includes(assetType.id)) {
|
||||
fulfilledCount++;
|
||||
@@ -175,8 +195,11 @@ export class CompletenessService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validate required channels
|
||||
const enabledChannels = product.metadata?.channels || [];
|
||||
// 3. Validate required channels (check family channels AND user-selected metadata channels)
|
||||
const familyChannels = product.family ? (product.family.channels || []).map(c => c.channel_code) : [];
|
||||
const metadataChannels = Array.isArray(product.metadata?.channels) ? product.metadata.channels : [];
|
||||
const enabledChannels = Array.from(new Set([...familyChannels, ...metadataChannels]));
|
||||
|
||||
for (const ch of requiredChannels) {
|
||||
if (enabledChannels.includes(ch.channel_code)) {
|
||||
fulfilledCount++;
|
||||
@@ -185,9 +208,11 @@ export class CompletenessService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validate General required fields (Name, Brand, Unit, Category)
|
||||
// 4. Validate General required fields (Name, SKU, Brand, Unit, Category)
|
||||
const requiredGeneral = [];
|
||||
const skuVal = product.product_type === 'simple' && product.variants?.length > 0 ? product.variants[0].sku : (product.product_type === 'variable' ? 'CONFIGURABLE' : '');
|
||||
requiredGeneral.push({ code: 'name', name: 'Product Name', value: product.name });
|
||||
requiredGeneral.push({ code: 'sku', name: 'Product SKU', value: skuVal });
|
||||
requiredGeneral.push({ code: 'category', name: 'Product Category', value: product.category_id });
|
||||
requiredGeneral.push({ code: 'brand', name: 'Brand', value: product.brand_id });
|
||||
requiredGeneral.push({ code: 'unit', name: 'Unit', value: product.unit_id });
|
||||
@@ -200,8 +225,48 @@ export class CompletenessService {
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = evaluatedAttributes.length + uniqueRequiredAssetTypes.length + requiredChannels.length + requiredGeneral.length;
|
||||
const percentage = totalCount > 0 ? Math.round((fulfilledCount / totalCount) * 100) : 100;
|
||||
let variantsTotalChecks = 0;
|
||||
let variantsFulfilledChecks = 0;
|
||||
if (product.variants && product.variants.length > 0) {
|
||||
for (const variant of product.variants) {
|
||||
// Check SKU
|
||||
variantsTotalChecks++;
|
||||
if (variant.sku && variant.sku.trim() !== '') {
|
||||
variantsFulfilledChecks++;
|
||||
} else {
|
||||
missingGeneral.push({ code: `variant_${variant.id}_sku`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" SKU` });
|
||||
}
|
||||
|
||||
// Check Price
|
||||
variantsTotalChecks++;
|
||||
if (variant.price !== undefined && variant.price !== null && Number(variant.price) > 0) {
|
||||
variantsFulfilledChecks++;
|
||||
} else {
|
||||
missingGeneral.push({ code: `variant_${variant.id}_price`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" Price` });
|
||||
}
|
||||
|
||||
// Check assets: if there are any variant-eligible asset types, they must be assigned to this variant!
|
||||
const vAssets = await models.VariantAsset.findAll({
|
||||
where: { variant_id: variant.id },
|
||||
include: [{ model: models.Asset, as: 'asset' }],
|
||||
transaction
|
||||
});
|
||||
|
||||
const requiredVariantAssetTypes = uniqueRequiredAssetTypes.filter(at => at.is_variant_eligible);
|
||||
for (const at of requiredVariantAssetTypes) {
|
||||
variantsTotalChecks++;
|
||||
const hasAsset = vAssets.some(va => va.asset && va.asset.asset_type_id === at.id);
|
||||
if (hasAsset) {
|
||||
variantsFulfilledChecks++;
|
||||
} else {
|
||||
missingGeneral.push({ code: `variant_${variant.id}_asset_${at.code}`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" ${at.name}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = evaluatedAttributes.length + uniqueRequiredAssetTypes.length + requiredChannels.length + requiredGeneral.length + variantsTotalChecks;
|
||||
const percentage = totalCount > 0 ? Math.round(((fulfilledCount + variantsFulfilledChecks) / totalCount) * 100) : 100;
|
||||
|
||||
// Upsert generic default completeness
|
||||
const existingDefault = await models.ProductCompleteness.findOne({
|
||||
|
||||
@@ -73,6 +73,15 @@ export class ProductController {
|
||||
}
|
||||
}
|
||||
|
||||
getAllVariantAssets = async (req, res, next) => {
|
||||
try {
|
||||
const data = await service.getAllVariantAssets(req.params.id, req.context);
|
||||
return res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
assignAsset = async (req, res, next) => {
|
||||
try {
|
||||
const data = await service.assignAsset(req.params.id, req.body.asset_id, req.body, req.context);
|
||||
@@ -82,6 +91,15 @@ export class ProductController {
|
||||
}
|
||||
}
|
||||
|
||||
bulkAssignAsset = async (req, res, next) => {
|
||||
try {
|
||||
const data = await service.bulkAssignAsset(req.params.id, req.body, req.context);
|
||||
return res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
updateAssetMapping = async (req, res, next) => {
|
||||
try {
|
||||
const data = await service.updateAssetMapping(req.params.id, req.params.assetId, req.body, req.context);
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class Product extends Model {
|
||||
static associate(models) {
|
||||
Product.belongsTo(models.Tenant, {
|
||||
foreignKey: 'tenant_id',
|
||||
as: 'tenant'
|
||||
});
|
||||
Product.belongsTo(models.Catalog, {
|
||||
foreignKey: 'family_id',
|
||||
as: 'family'
|
||||
@@ -43,15 +47,31 @@ export default (sequelize) => {
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
allowNull: false
|
||||
},
|
||||
code: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
product_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
unique: true
|
||||
defaultValue: 'simple'
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
short_description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
slug: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
@@ -59,6 +79,36 @@ export default (sequelize) => {
|
||||
allowNull: false,
|
||||
defaultValue: 'draft'
|
||||
},
|
||||
meta_title: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
meta_description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
meta_keywords: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
completeness_score: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0.00
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
approved_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
approved_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
family_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
@@ -75,9 +125,26 @@ export default (sequelize) => {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
manufacturer_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
hsn_code_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
tax_class_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
workflow_state_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: {}
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
|
||||
export class ProductRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
order: [['created_at', 'DESC']],
|
||||
...options,
|
||||
where: applyTenantScope(options.where || {}, context)
|
||||
};
|
||||
@@ -62,79 +63,16 @@ export class ProductRepository {
|
||||
as: 'family',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'attributes',
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AttributeOption,
|
||||
as: 'optionsList',
|
||||
attributes: ['id', 'code', 'label', 'sort_order', 'status'],
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'variantAxes',
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AttributeOption,
|
||||
as: 'optionsList',
|
||||
attributes: ['id', 'code', 'label', 'sort_order', 'status'],
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.AssetFamily,
|
||||
as: 'assetRequirements',
|
||||
through: { attributes: [] },
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: models.FamilyChannel,
|
||||
as: 'channels',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: models.Categorie,
|
||||
as: 'category',
|
||||
attributes: ['id', 'name', 'code'],
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: models.AttributeSet,
|
||||
as: 'attributeSet',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AttributeGroup,
|
||||
as: 'groups',
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'attributes',
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AttributeOption,
|
||||
as: 'optionsList',
|
||||
attributes: ['id', 'code', 'label', 'sort_order', 'status'],
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
attributes: ['id', 'name']
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -172,6 +110,25 @@ export class ProductRepository {
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.VariantAsset,
|
||||
as: 'variantAssets',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.Asset,
|
||||
as: 'asset',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AssetType,
|
||||
as: 'assetType',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -218,10 +175,8 @@ export class ProductRepository {
|
||||
|
||||
async create(data, options = {}, context = {}) {
|
||||
const createData = {
|
||||
...data
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
...data,
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
};
|
||||
return await models.Product.create(createData, options);
|
||||
}
|
||||
@@ -251,10 +206,8 @@ export class ProductRepository {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
...(options.where || {}),
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
};
|
||||
const record = await models.Product.findByPk(id, queryOptions);
|
||||
|
||||
@@ -133,6 +133,13 @@ router.get(
|
||||
controller.getAssets
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/all-variant-assets',
|
||||
authenticate,
|
||||
authorize(['products.items']),
|
||||
controller.getAllVariantAssets
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/assets',
|
||||
authenticate,
|
||||
@@ -140,6 +147,13 @@ router.post(
|
||||
controller.assignAsset
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/assets/bulk-assign',
|
||||
authenticate,
|
||||
authorize(['products.items']),
|
||||
controller.bulkAssignAsset
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id/assets/:assetId',
|
||||
authenticate,
|
||||
|
||||
@@ -14,47 +14,88 @@ export function formatProductResponse(json) {
|
||||
const defaultCompleteness = (json.completenessEntries || []).find(c => c.channel === 'default');
|
||||
json.completeness = defaultCompleteness ? defaultCompleteness.percentage : 0;
|
||||
|
||||
if (json.metadata) {
|
||||
const {
|
||||
sku, price, stock, barcode, gtin, upc, ean, country, hsn, type, description, categories, attributes,
|
||||
...restMetadata
|
||||
} = json.metadata;
|
||||
|
||||
return {
|
||||
...json,
|
||||
family_id: json.family_id || json.familyId || (json.family ? json.family.id : null),
|
||||
version: json.version || 1,
|
||||
sku: sku !== undefined ? sku : '',
|
||||
price: price !== undefined ? price : '',
|
||||
stock: stock !== undefined ? stock : 0,
|
||||
barcode: barcode !== undefined ? barcode : '',
|
||||
gtin: gtin !== undefined ? gtin : '',
|
||||
upc: upc !== undefined ? upc : '',
|
||||
ean: ean !== undefined ? ean : '',
|
||||
country: country !== undefined ? country : '',
|
||||
hsn: hsn !== undefined ? hsn : '',
|
||||
type: type !== undefined ? type : 'simple',
|
||||
description: description !== undefined ? description : '',
|
||||
categories: Array.isArray(categories) ? categories : (json.category_id ? [json.category_id] : []),
|
||||
attributes: attributes || {},
|
||||
metadata: restMetadata
|
||||
};
|
||||
}
|
||||
// Extract master variant details for simple products
|
||||
let sku = '';
|
||||
let barcode = '';
|
||||
let price = '';
|
||||
let costPrice = '';
|
||||
let currency = 'USD';
|
||||
|
||||
const isSimple = json.product_type === 'simple';
|
||||
if (isSimple && Array.isArray(json.variants) && json.variants.length > 0) {
|
||||
const master = json.variants[0];
|
||||
sku = master.sku || '';
|
||||
barcode = master.barcode || '';
|
||||
price = master.price !== undefined && master.price !== null ? String(master.price) : '';
|
||||
costPrice = master.cost_price !== undefined && master.cost_price !== null ? String(master.cost_price) : '';
|
||||
currency = master.currency || 'USD';
|
||||
}
|
||||
|
||||
// Extract dynamic EAV attribute values
|
||||
const attributes = {};
|
||||
if (Array.isArray(json.attributeValues)) {
|
||||
for (const av of json.attributeValues) {
|
||||
const code = av.attribute ? av.attribute.code : (av.attribute_code || '');
|
||||
if (code) {
|
||||
attributes[code] = av.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format variants and extract images from variantAssets
|
||||
let formattedVariants = [];
|
||||
if (Array.isArray(json.variants)) {
|
||||
formattedVariants = json.variants.map(v => {
|
||||
const vRaw = v.toJSON ? v.toJSON() : v;
|
||||
const images = [];
|
||||
if (Array.isArray(vRaw.variantAssets)) {
|
||||
vRaw.variantAssets
|
||||
.filter(va => va.asset)
|
||||
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0))
|
||||
.forEach((va, idx) => {
|
||||
images.push({
|
||||
assetId: va.asset_id,
|
||||
url: va.asset.file_url || va.asset.url || null,
|
||||
thumbnailUrl: va.asset.thumbnail_url || va.asset.file_url || va.asset.url || null,
|
||||
name: va.asset.name || va.asset.original_name || `Asset ${idx + 1}`,
|
||||
role: va.role || (idx === 0 ? 'primary' : 'gallery'),
|
||||
isPrimary: va.is_primary || idx === 0,
|
||||
displayOrder: va.display_order || idx,
|
||||
assetType: va.asset.assetType ? {
|
||||
id: va.asset.assetType.id,
|
||||
code: va.asset.assetType.code,
|
||||
name: va.asset.assetType.name
|
||||
} : null
|
||||
});
|
||||
});
|
||||
}
|
||||
return {
|
||||
...vRaw,
|
||||
images
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Map backend 'variable' type back to frontend 'variant'
|
||||
const type = json.product_type === 'variable' ? 'variant' : 'simple';
|
||||
|
||||
return {
|
||||
...json,
|
||||
sku: '',
|
||||
price: '',
|
||||
stock: 0,
|
||||
barcode: '',
|
||||
gtin: '',
|
||||
upc: '',
|
||||
ean: '',
|
||||
country: '',
|
||||
hsn: '',
|
||||
type: 'simple',
|
||||
description: '',
|
||||
categories: json.category_id ? [json.category_id] : []
|
||||
variants: formattedVariants,
|
||||
family_id: json.family_id || json.familyId || (json.family ? json.family.id : null),
|
||||
version: json.version || 1,
|
||||
sku,
|
||||
price,
|
||||
costPrice,
|
||||
currency,
|
||||
stock: 0, // stock is managed by Inventory service, default to 0 for PIM static response
|
||||
barcode,
|
||||
type,
|
||||
description: json.description || '',
|
||||
short_description: json.short_description || '',
|
||||
slug: json.slug || '',
|
||||
categories: json.category_id ? [json.category_id] : [],
|
||||
attributes
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,6 +189,49 @@ export class ProductService {
|
||||
});
|
||||
}
|
||||
|
||||
async getAllVariantAssets(productId) {
|
||||
const product = await repository.findById(productId);
|
||||
if (!product) throw new Error('Product not found');
|
||||
|
||||
const variants = await models.Variant.findAll({
|
||||
where: { product_id: productId },
|
||||
attributes: ['id', 'name', 'sku']
|
||||
});
|
||||
|
||||
const variantIds = variants.map(v => v.id);
|
||||
if (variantIds.length === 0) return [];
|
||||
|
||||
const variantAssets = await models.VariantAsset.findAll({
|
||||
where: { variant_id: variantIds },
|
||||
include: [
|
||||
{
|
||||
model: models.Asset,
|
||||
as: 'asset',
|
||||
include: [{ model: models.AssetType, as: 'assetType' }]
|
||||
},
|
||||
{
|
||||
model: models.Variant,
|
||||
as: 'variant',
|
||||
attributes: ['id', 'name', 'sku']
|
||||
}
|
||||
],
|
||||
order: [['display_order', 'ASC']]
|
||||
});
|
||||
|
||||
return variantAssets.map(va => ({
|
||||
id: va.id,
|
||||
variant_id: va.variant_id,
|
||||
asset_id: va.asset_id,
|
||||
role: va.role,
|
||||
display_order: va.display_order,
|
||||
is_primary: va.is_primary,
|
||||
asset: va.asset,
|
||||
variantId: va.variant_id,
|
||||
variantName: va.variant?.name || '',
|
||||
variantSku: va.variant?.sku || ''
|
||||
}));
|
||||
}
|
||||
|
||||
async assignAsset(productId, assetId, data, userContext = {}) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
@@ -193,6 +277,61 @@ export class ProductService {
|
||||
}
|
||||
}
|
||||
|
||||
async bulkAssignAsset(productId, data, userContext = {}) {
|
||||
const { asset_id, role, variant_ids, is_primary } = data;
|
||||
if (!asset_id) throw new Error('asset_id is required');
|
||||
if (!variant_ids || !Array.isArray(variant_ids) || variant_ids.length === 0) {
|
||||
throw new Error('variant_ids must be a non-empty array');
|
||||
}
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const product = await repository.findById(productId, { transaction });
|
||||
if (!product) throw new Error('Product not found');
|
||||
|
||||
const asset = await models.Asset.findByPk(asset_id, { transaction });
|
||||
if (!asset) throw new Error('Asset not found');
|
||||
|
||||
const results = [];
|
||||
for (const variantId of variant_ids) {
|
||||
if (is_primary) {
|
||||
await models.VariantAsset.update(
|
||||
{ is_primary: false },
|
||||
{ where: { variant_id: variantId }, transaction }
|
||||
);
|
||||
}
|
||||
|
||||
const [mapping, created] = await models.VariantAsset.findOrCreate({
|
||||
where: { variant_id: variantId, asset_id },
|
||||
defaults: {
|
||||
role: role || 'gallery_image',
|
||||
display_order: 0,
|
||||
is_primary: !!is_primary
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
await mapping.update({
|
||||
role: role || mapping.role,
|
||||
is_primary: is_primary !== undefined ? !!is_primary : mapping.is_primary
|
||||
}, { transaction });
|
||||
}
|
||||
results.push(mapping);
|
||||
}
|
||||
|
||||
await this.calculateCompleteness(productId, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
SocketService.broadcast('product.updated', { id: productId });
|
||||
SocketService.broadcast('variants.updated', { productId });
|
||||
return results;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateAssetMapping(productId, assetId, data, userContext = {}) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
@@ -264,9 +403,9 @@ export class ProductService {
|
||||
if (query.search) {
|
||||
const escapedSearch = query.search.replace(/'/g, "''");
|
||||
where[Op.or] = [
|
||||
sequelize.literal(`products.metadata->>'sku' iLike '%${escapedSearch}%'`),
|
||||
{ name: { [Op.iLike]: `%${query.search}%` } },
|
||||
{ code: { [Op.iLike]: `%${query.search}%` } }
|
||||
{ code: { [Op.iLike]: `%${query.search}%` } },
|
||||
sequelize.literal(`EXISTS (SELECT 1 FROM product_variants WHERE product_variants.product_id = "Product".id AND product_variants.sku iLike '%${escapedSearch}%')`)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -410,72 +549,47 @@ export class ProductService {
|
||||
}
|
||||
data.code = finalCode;
|
||||
|
||||
// Setup default inherited channels and workflows
|
||||
const metadata = data.metadata || {};
|
||||
if (!metadata.channels) {
|
||||
metadata.channels = family ? (family.channels || []).map(c => c.channel_code) : [];
|
||||
}
|
||||
|
||||
// Fetch workflow details dynamically
|
||||
const workflowCode = family ? (family.workflow_code || 'standard') : 'standard';
|
||||
let workflowName = 'Standard Approval';
|
||||
let currentStage = 'draft';
|
||||
|
||||
const wfRegistry = await models.WorkflowRegistry.findOne({
|
||||
where: { code: workflowCode },
|
||||
transaction
|
||||
});
|
||||
if (wfRegistry) {
|
||||
workflowName = wfRegistry.name;
|
||||
const stages = wfRegistry.stages || [];
|
||||
if (stages.length > 0) {
|
||||
const sortedStages = [...stages].sort((a, b) => Number(a.order || 0) - Number(b.order || 0));
|
||||
currentStage = sortedStages[0].code || sortedStages[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
metadata.workflowCode = workflowCode;
|
||||
metadata.workflowName = workflowName;
|
||||
metadata.currentStage = currentStage;
|
||||
|
||||
// Pack general form fields into metadata since they aren't core columns
|
||||
const initialStatus = data.status || 'draft';
|
||||
if (data.sku && String(data.sku).trim() !== '') {
|
||||
metadata.sku = String(data.sku).trim();
|
||||
} else if (initialStatus === 'pending' || initialStatus === 'active') {
|
||||
metadata.sku = await this.generateSku(family, finalCode, transaction);
|
||||
} else {
|
||||
metadata.sku = null;
|
||||
}
|
||||
metadata.price = data.price || '';
|
||||
metadata.stock = data.stock !== undefined ? data.stock : 0;
|
||||
metadata.barcode = data.barcode || '';
|
||||
metadata.gtin = data.gtin || '';
|
||||
metadata.upc = data.upc || '';
|
||||
metadata.ean = data.ean || '';
|
||||
metadata.country = data.country || '';
|
||||
metadata.hsn = data.hsn || '';
|
||||
metadata.type = data.type || 'simple';
|
||||
metadata.description = data.description || '';
|
||||
metadata.categories = Array.isArray(data.categories) ? data.categories : (categoryId ? [categoryId] : []);
|
||||
|
||||
// 5. Create Core Product
|
||||
const prodType = data.type === 'variant' ? 'variable' : 'simple';
|
||||
let slug = data.slug || data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-+|-+$)/g, '');
|
||||
if (!slug) slug = `product-${Date.now()}`;
|
||||
|
||||
const product = await repository.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
status: data.status || 'draft',
|
||||
product_type: prodType,
|
||||
description: data.description || '',
|
||||
short_description: data.short_description || '',
|
||||
slug: slug,
|
||||
family_id: family ? family.id : null,
|
||||
category_id: categoryId,
|
||||
brand_id: data.brand_id || data.brandId || data.brand,
|
||||
unit_id: data.unit_id || data.unitId || data.unit,
|
||||
metadata: metadata
|
||||
}, { transaction });
|
||||
metadata: data.metadata || {}
|
||||
}, { transaction }, context);
|
||||
|
||||
// 6. Save dynamic attributes from inherited Attribute Set blueprint or direct family attributes
|
||||
// Create child variant automatically for simple product type
|
||||
if (prodType === 'simple') {
|
||||
const sku = data.sku || await this.generateSku(family, finalCode, transaction);
|
||||
await models.Variant.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
product_id: product.id,
|
||||
sku: sku,
|
||||
barcode: data.barcode || null,
|
||||
name: data.name,
|
||||
price: parseFloat(data.price) || 0.00,
|
||||
cost_price: parseFloat(data.cost_price || data.costPrice) || 0.00,
|
||||
currency: data.currency || 'USD',
|
||||
status: data.status || 'draft',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
// 6. Save dynamic attributes
|
||||
const bodyAttributes = data.attributes || data.attributeValues || {};
|
||||
metadata.attributes = bodyAttributes;
|
||||
await product.update({ metadata }, { transaction });
|
||||
|
||||
const familyAttributesMap = new Map();
|
||||
if (family && family.attributeSet && Array.isArray(family.attributeSet.groups)) {
|
||||
for (const g of family.attributeSet.groups) {
|
||||
@@ -514,13 +628,10 @@ export class ProductService {
|
||||
|
||||
for (const attr of uniqueFamilyAttributes) {
|
||||
let val = bodyAttributes[attr.code] !== undefined ? bodyAttributes[attr.code] : bodyAttributes[attr.id];
|
||||
if (val === undefined && bodyAttributes.metadata) {
|
||||
val = bodyAttributes.metadata[attr.code] !== undefined ? bodyAttributes.metadata[attr.code] : bodyAttributes.metadata[attr.id];
|
||||
}
|
||||
|
||||
if (val !== undefined && val !== null) {
|
||||
await validateAttributeValue(attr, val, product.id, transaction);
|
||||
await models.ProductAttributeValue.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
product_id: product.id,
|
||||
attribute_id: attr.id,
|
||||
value: typeof val === 'object' ? JSON.stringify(val) : String(val),
|
||||
@@ -580,38 +691,41 @@ export class ProductService {
|
||||
|
||||
// Validate Family
|
||||
const familyId = data.family_id || data.familyId || record.family_id;
|
||||
const family = await models.Catalog.findByPk(familyId, {
|
||||
transaction,
|
||||
include: [
|
||||
{
|
||||
model: models.FamilyChannel,
|
||||
as: 'channels'
|
||||
},
|
||||
{
|
||||
model: models.AttributeSet,
|
||||
as: 'attributeSet',
|
||||
include: [
|
||||
{
|
||||
model: models.AttributeGroup,
|
||||
as: 'groups',
|
||||
include: [
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'attributes'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
if (!family) {
|
||||
throw new Error('Product Family (Catalog) must exist');
|
||||
let family = null;
|
||||
if (familyId) {
|
||||
family = await models.Catalog.findByPk(familyId, {
|
||||
transaction,
|
||||
include: [
|
||||
{
|
||||
model: models.FamilyChannel,
|
||||
as: 'channels'
|
||||
},
|
||||
{
|
||||
model: models.AttributeSet,
|
||||
as: 'attributeSet',
|
||||
include: [
|
||||
{
|
||||
model: models.AttributeGroup,
|
||||
as: 'groups',
|
||||
include: [
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'attributes'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
if (!family) {
|
||||
throw new Error('Product Family (Catalog) must exist if specified');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Brand is allowed by Family (if changed)
|
||||
const brandId = data.brand_id || data.brandId || data.brand;
|
||||
if (brandId && brandId !== record.brand_id) {
|
||||
if (brandId && brandId !== record.brand_id && family) {
|
||||
const allowedBrands = family.completeness_rules?.allowedBrands || [];
|
||||
if (allowedBrands.length > 0 && !allowedBrands.includes(brandId)) {
|
||||
throw new Error('Selected brand is not allowed for this product family');
|
||||
@@ -620,7 +734,7 @@ export class ProductService {
|
||||
|
||||
// Validate Unit is allowed by Family (if changed)
|
||||
const unitId = data.unit_id || data.unitId || data.unit;
|
||||
if (unitId && unitId !== record.unit_id) {
|
||||
if (unitId && unitId !== record.unit_id && family) {
|
||||
const allowedUnits = family.completeness_rules?.allowedUnits || [];
|
||||
if (allowedUnits.length > 0 && !allowedUnits.includes(unitId)) {
|
||||
throw new Error('Selected unit is not allowed for this product family');
|
||||
@@ -636,69 +750,66 @@ export class ProductService {
|
||||
categoryId = family ? family.category_id : null;
|
||||
}
|
||||
|
||||
// Ensure inherited channels are locked and cannot be removed
|
||||
const metadata = { ...(record.metadata || {}), ...(data.metadata || {}) };
|
||||
const familyChannels = (family.channels || []).map(c => c.channel_code);
|
||||
let updatedChannels = metadata.channels || [];
|
||||
// Combine existing with family channels so family channels cannot be deleted
|
||||
updatedChannels = [...new Set([...updatedChannels, ...familyChannels])];
|
||||
metadata.channels = updatedChannels;
|
||||
|
||||
if (!metadata.workflowCode) {
|
||||
metadata.workflowCode = family.workflow_code || record.metadata?.workflowCode || 'standard';
|
||||
}
|
||||
if (!metadata.workflowName) {
|
||||
metadata.workflowName = record.metadata?.workflowName || 'Standard Approval';
|
||||
}
|
||||
if (!metadata.currentStage) {
|
||||
metadata.currentStage = record.metadata?.currentStage || 'draft';
|
||||
// 3. Update Core Product
|
||||
const prodType = data.type === 'variant' ? 'variable' : (data.type || record.product_type || 'simple');
|
||||
let slug = data.slug || record.slug;
|
||||
if (data.name && !data.slug) {
|
||||
slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-+|-+$)/g, '');
|
||||
}
|
||||
if (!slug) slug = `product-${Date.now()}`;
|
||||
|
||||
const oldStatus = record.status || record.metadata?.currentStage || 'draft';
|
||||
const newStatus = data.status || metadata.currentStage || oldStatus;
|
||||
|
||||
if (data.hasOwnProperty('sku') && data.sku && String(data.sku).trim() !== '') {
|
||||
metadata.sku = String(data.sku).trim();
|
||||
} else if (!metadata.sku && (newStatus === 'pending' || newStatus === 'active')) {
|
||||
metadata.sku = await this.generateSku(family, record.code, transaction);
|
||||
}
|
||||
|
||||
// Keep general fields inside metadata up to date
|
||||
if (data.hasOwnProperty('price')) metadata.price = data.price;
|
||||
if (data.hasOwnProperty('stock')) metadata.stock = data.stock;
|
||||
if (data.hasOwnProperty('barcode')) metadata.barcode = data.barcode;
|
||||
if (data.hasOwnProperty('gtin')) metadata.gtin = data.gtin;
|
||||
if (data.hasOwnProperty('upc')) metadata.upc = data.upc;
|
||||
if (data.hasOwnProperty('ean')) metadata.ean = data.ean;
|
||||
if (data.hasOwnProperty('country')) metadata.country = data.country;
|
||||
if (data.hasOwnProperty('hsn')) metadata.hsn = data.hsn;
|
||||
if (data.hasOwnProperty('type')) metadata.type = data.type;
|
||||
if (data.hasOwnProperty('description')) metadata.description = data.description;
|
||||
if (data.hasOwnProperty('categories')) {
|
||||
metadata.categories = Array.isArray(data.categories) ? data.categories : [];
|
||||
} else if (categoryId && (!metadata.categories || metadata.categories.length === 0)) {
|
||||
metadata.categories = [categoryId];
|
||||
}
|
||||
|
||||
// Update Core Product
|
||||
await record.update({
|
||||
name: data.name || record.name,
|
||||
status: data.status || record.status,
|
||||
product_type: prodType,
|
||||
description: data.hasOwnProperty('description') ? data.description : record.description,
|
||||
short_description: data.hasOwnProperty('short_description') ? data.short_description : record.short_description,
|
||||
slug: slug,
|
||||
brand_id: data.brand_id || data.brandId || data.brand || record.brand_id,
|
||||
unit_id: data.unit_id || data.unitId || data.unit || record.unit_id,
|
||||
category_id: categoryId,
|
||||
metadata: metadata
|
||||
metadata: data.metadata ? { ...(record.metadata || {}), ...data.metadata } : record.metadata
|
||||
}, { transaction });
|
||||
|
||||
// Save dynamic attributes from inherited Attribute Set blueprint or direct family attributes
|
||||
const bodyAttributes = data.attributes || data.attributeValues || {};
|
||||
if (data.hasOwnProperty('attributes') || data.hasOwnProperty('attributeValues')) {
|
||||
metadata.attributes = { ...(metadata.attributes || {}), ...bodyAttributes };
|
||||
await record.update({ metadata }, { transaction });
|
||||
// Create/Update child variant for simple products
|
||||
if (prodType === 'simple') {
|
||||
const [variant, created] = await models.Variant.findOrCreate({
|
||||
where: { product_id: id },
|
||||
defaults: {
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
sku: data.sku || await this.generateSku(family, record.code, transaction),
|
||||
barcode: data.barcode || null,
|
||||
name: data.name || record.name,
|
||||
price: parseFloat(data.price) || 0.00,
|
||||
cost_price: parseFloat(data.cost_price || data.costPrice) || 0.00,
|
||||
currency: data.currency || 'USD',
|
||||
status: data.status || record.status || 'draft',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
const variantPayload = {};
|
||||
if (data.hasOwnProperty('sku')) variantPayload.sku = data.sku;
|
||||
if (data.hasOwnProperty('barcode')) variantPayload.barcode = data.barcode;
|
||||
if (data.name) variantPayload.name = data.name;
|
||||
if (data.hasOwnProperty('price')) variantPayload.price = parseFloat(data.price) || 0.00;
|
||||
if (data.hasOwnProperty('cost_price')) variantPayload.cost_price = parseFloat(data.cost_price) || 0.00;
|
||||
if (data.hasOwnProperty('costPrice')) variantPayload.cost_price = parseFloat(data.costPrice) || 0.00;
|
||||
if (data.currency) variantPayload.currency = data.currency;
|
||||
if (data.status) variantPayload.status = data.status;
|
||||
|
||||
await variant.update(variantPayload, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
// Save dynamic attributes from body
|
||||
const bodyAttributes = data.attributes || data.attributeValues || {};
|
||||
const familyAttributesMap = new Map();
|
||||
if (family.attributeSet && Array.isArray(family.attributeSet.groups)) {
|
||||
if (family && family.attributeSet && Array.isArray(family.attributeSet.groups)) {
|
||||
for (const g of family.attributeSet.groups) {
|
||||
if (Array.isArray(g.attributes)) {
|
||||
for (const a of g.attributes) {
|
||||
@@ -707,7 +818,7 @@ export class ProductService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(family.attributes)) {
|
||||
if (family && Array.isArray(family.attributes)) {
|
||||
for (const a of family.attributes) {
|
||||
if (a && a.id) familyAttributesMap.set(a.id, a);
|
||||
}
|
||||
@@ -735,15 +846,14 @@ export class ProductService {
|
||||
|
||||
for (const attr of uniqueFamilyAttributes) {
|
||||
let val = bodyAttributes[attr.code] !== undefined ? bodyAttributes[attr.code] : bodyAttributes[attr.id];
|
||||
if (val === undefined && bodyAttributes.metadata) {
|
||||
val = bodyAttributes.metadata[attr.code] !== undefined ? bodyAttributes.metadata[attr.code] : bodyAttributes.metadata[attr.id];
|
||||
}
|
||||
|
||||
if (val !== undefined && val !== null) {
|
||||
await validateAttributeValue(attr, val, id, transaction);
|
||||
const [attrVal, created] = await models.ProductAttributeValue.findOrCreate({
|
||||
where: { product_id: id, attribute_id: attr.id, locale: 'en', channel: 'default' },
|
||||
defaults: { value: typeof val === 'object' ? JSON.stringify(val) : String(val) },
|
||||
defaults: {
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
value: typeof val === 'object' ? JSON.stringify(val) : String(val)
|
||||
},
|
||||
transaction
|
||||
});
|
||||
if (!created) {
|
||||
@@ -899,6 +1009,7 @@ export class ProductService {
|
||||
const item = values[i];
|
||||
if (!item.axis_id || !item.value) continue;
|
||||
const created = await models.ProductVariantValue.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
product_id: productId,
|
||||
axis_id: item.axis_id,
|
||||
value: item.value,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class ProductAttributeValue extends Model {
|
||||
static associate(models) {
|
||||
ProductAttributeValue.belongsTo(models.Tenant, {
|
||||
foreignKey: 'tenant_id',
|
||||
as: 'tenant'
|
||||
});
|
||||
ProductAttributeValue.belongsTo(models.Product, {
|
||||
foreignKey: 'product_id',
|
||||
as: 'product'
|
||||
@@ -21,6 +25,10 @@ export default (sequelize) => {
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
product_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class ProductVariantValue extends Model {
|
||||
static associate(models) {
|
||||
ProductVariantValue.belongsTo(models.Tenant, {
|
||||
foreignKey: 'tenant_id',
|
||||
as: 'tenant'
|
||||
});
|
||||
ProductVariantValue.belongsTo(models.Product, {
|
||||
foreignKey: 'product_id',
|
||||
as: 'product'
|
||||
@@ -21,6 +25,10 @@ export default (sequelize) => {
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
product_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
|
||||
@@ -14,6 +14,30 @@ export class VariantController {
|
||||
}
|
||||
}
|
||||
|
||||
// Map variantAssets → images array for frontend display
|
||||
const images = [];
|
||||
if (Array.isArray(raw.variantAssets)) {
|
||||
raw.variantAssets
|
||||
.filter(va => va.asset)
|
||||
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0))
|
||||
.forEach((va, idx) => {
|
||||
images.push({
|
||||
assetId: va.asset_id,
|
||||
url: va.asset.file_url || va.asset.url || null,
|
||||
thumbnailUrl: va.asset.thumbnail_url || va.asset.file_url || va.asset.url || null,
|
||||
name: va.asset.name || va.asset.original_name || `Asset ${idx + 1}`,
|
||||
role: va.role || (idx === 0 ? 'primary' : 'gallery'),
|
||||
isPrimary: va.is_primary || idx === 0,
|
||||
displayOrder: va.display_order || idx,
|
||||
assetType: va.asset.assetType ? {
|
||||
id: va.asset.assetType.id,
|
||||
code: va.asset.assetType.code,
|
||||
name: va.asset.assetType.name
|
||||
} : null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
sku: raw.sku,
|
||||
@@ -30,7 +54,8 @@ export class VariantController {
|
||||
reservedStock: raw.reserved_stock || 0,
|
||||
safetyStock: raw.safety_stock || 0,
|
||||
lastUpdated: raw.updated_at || raw.updatedAt,
|
||||
createdBy: raw.created_by || 'system'
|
||||
createdBy: raw.created_by || 'system',
|
||||
images
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class Variant extends Model {
|
||||
static associate(models) {
|
||||
Variant.belongsTo(models.Tenant, {
|
||||
foreignKey: 'tenant_id',
|
||||
as: 'tenant'
|
||||
});
|
||||
// Belongs to product
|
||||
Variant.belongsTo(models.Product, {
|
||||
foreignKey: 'product_id',
|
||||
@@ -32,19 +36,22 @@ export default (sequelize) => {
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
allowNull: false
|
||||
},
|
||||
product_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
sku: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
unique: true
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
barcode: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(150),
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
},
|
||||
price: {
|
||||
@@ -62,30 +69,20 @@ export default (sequelize) => {
|
||||
allowNull: true,
|
||||
defaultValue: 'USD'
|
||||
},
|
||||
stock: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
available_stock: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
reserved_stock: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
safety_stock: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft'
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
sort_order: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
|
||||
@@ -1,64 +1,67 @@
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
|
||||
|
||||
const getDefaultVariantIncludes = () => [
|
||||
{
|
||||
model: models.VariantValue,
|
||||
as: 'values',
|
||||
include: [
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'axis',
|
||||
attributes: ['code', 'name', 'type']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.Product,
|
||||
as: 'product',
|
||||
attributes: ['id', 'name', 'code']
|
||||
},
|
||||
{
|
||||
model: models.VariantAsset,
|
||||
as: 'variantAssets',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.Asset,
|
||||
as: 'asset',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AssetType,
|
||||
as: 'assetType',
|
||||
required: false,
|
||||
attributes: ['id', 'code', 'name', 'is_variant_eligible']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export class VariantRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const where = applyTenantScope(options.where || {}, context);
|
||||
return await models.Variant.findAll({
|
||||
order: options.order || [['created_at', 'DESC']],
|
||||
include: options.include || getDefaultVariantIncludes(),
|
||||
...options,
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: models.VariantValue,
|
||||
as: 'values',
|
||||
include: [
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'axis',
|
||||
attributes: ['code', 'name', 'type']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.Product,
|
||||
as: 'product',
|
||||
attributes: ['id', 'name', 'code']
|
||||
}
|
||||
],
|
||||
order: [
|
||||
['sku', 'ASC']
|
||||
]
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id, options = {}, context = {}) {
|
||||
const where = applyTenantScope({ id }, context);
|
||||
const where = applyTenantScope({ id, ...(options.where || {}) }, context);
|
||||
return await models.Variant.findOne({
|
||||
include: options.include || getDefaultVariantIncludes(),
|
||||
...options,
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: models.VariantValue,
|
||||
as: 'values',
|
||||
include: [
|
||||
{
|
||||
model: models.Attribute,
|
||||
as: 'axis',
|
||||
attributes: ['code', 'name', 'type']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.Product,
|
||||
as: 'product',
|
||||
attributes: ['id', 'name', 'code']
|
||||
}
|
||||
]
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
async findBySku(sku, options = {}, context = {}) {
|
||||
const where = applyTenantScope({ sku }, context);
|
||||
const where = applyTenantScope({ sku, ...(options.where || {}) }, context);
|
||||
return await models.Variant.findOne({
|
||||
...options,
|
||||
where,
|
||||
@@ -72,7 +75,11 @@ export class VariantRepository {
|
||||
}
|
||||
|
||||
async create(data, options = {}, context = {}) {
|
||||
return await models.Variant.create(data, options);
|
||||
const createData = {
|
||||
...data,
|
||||
tenant_id: context.tenantId
|
||||
};
|
||||
return await models.Variant.create(createData, options);
|
||||
}
|
||||
|
||||
async update(id, data, options = {}, context = {}) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import repository from './variant.repository.js';
|
||||
import { models, sequelize } from '../../../shared/database/models.js';
|
||||
import { SocketService } from '../../../shared/services/socket.service.js';
|
||||
import { AuditService } from '../../../shared/services/audit.service.js';
|
||||
import CompletenessService from '../../products/products/completeness.service.js';
|
||||
|
||||
export class VariantService {
|
||||
async getAll(query = {}, context = {}) {
|
||||
@@ -19,8 +20,12 @@ export class VariantService {
|
||||
include: []
|
||||
};
|
||||
|
||||
if (query.parentProductId) {
|
||||
where.product_id = query.parentProductId;
|
||||
const parentId = query.parentProductId || query.productId || query.product_id;
|
||||
if (parentId !== undefined) {
|
||||
if (!parentId || parentId === 'new' || parentId === 'null' || parentId === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
where.product_id = parentId;
|
||||
}
|
||||
|
||||
const catalogInclude = {
|
||||
@@ -65,6 +70,26 @@ export class VariantService {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.VariantAsset,
|
||||
as: 'variantAssets',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.Asset,
|
||||
as: 'asset',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.AssetType,
|
||||
as: 'assetType',
|
||||
required: false,
|
||||
attributes: ['id', 'code', 'name', 'is_variant_eligible']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
productInclude
|
||||
]
|
||||
}, context);
|
||||
@@ -139,11 +164,9 @@ export class VariantService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validate Price and Stock
|
||||
// 4. Validate Price
|
||||
const price = parseFloat(data.price) ?? 0;
|
||||
const stock = parseInt(data.stock) ?? 0;
|
||||
if (price < 0) throw new Error('Price must be a positive number');
|
||||
if (stock < 0) throw new Error('Stock must be a non-negative integer');
|
||||
|
||||
// 5. Reject duplicate variant combination
|
||||
const existingVariants = await models.Variant.findAll({
|
||||
@@ -184,17 +207,17 @@ export class VariantService {
|
||||
|
||||
// 6. Create Variant
|
||||
const record = await models.Variant.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
product_id: data.parentProductId,
|
||||
sku: data.sku,
|
||||
barcode: data.barcode || null,
|
||||
name: data.name || data.variantName,
|
||||
price: price,
|
||||
cost_price: parseFloat(data.cost_price || data.costPrice) || 0.00,
|
||||
currency: data.currency || 'USD',
|
||||
stock: stock,
|
||||
available_stock: parseInt(data.available_stock || data.availableStock) || stock,
|
||||
reserved_stock: parseInt(data.reserved_stock || data.reservedStock) || 0,
|
||||
safety_stock: parseInt(data.safety_stock || data.safetyStock) || 0,
|
||||
status: data.status || 'draft'
|
||||
status: data.status || 'draft',
|
||||
is_active: data.is_active !== undefined ? !!data.is_active : true,
|
||||
sort_order: parseInt(data.sort_order || data.sortOrder) || 0
|
||||
}, { transaction });
|
||||
|
||||
// 7. Insert Variant Values
|
||||
@@ -202,6 +225,7 @@ export class VariantService {
|
||||
const attribute = family.variantAxes.find(axis => axis.code === code);
|
||||
if (attribute) {
|
||||
await models.VariantValue.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
variant_id: record.id,
|
||||
axis_id: attribute.id,
|
||||
value_text: String(val)
|
||||
@@ -209,6 +233,7 @@ export class VariantService {
|
||||
}
|
||||
}
|
||||
|
||||
await CompletenessService.calculate(record.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
const fullRecord = await repository.findById(record.id, {}, context);
|
||||
@@ -328,24 +353,21 @@ export class VariantService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validate pricing & stock
|
||||
// 4. Validate pricing
|
||||
const price = data.hasOwnProperty('price') ? parseFloat(data.price) : record.price;
|
||||
const stock = data.hasOwnProperty('stock') ? parseInt(data.stock) : record.stock;
|
||||
if (price < 0) throw new Error('Price must be a positive number');
|
||||
if (stock < 0) throw new Error('Stock must be a non-negative integer');
|
||||
|
||||
// 5. Update Record
|
||||
const updatePayload = {
|
||||
sku: data.sku || record.sku,
|
||||
barcode: data.hasOwnProperty('barcode') ? data.barcode : record.barcode,
|
||||
name: data.name || data.variantName || record.name,
|
||||
price: price,
|
||||
cost_price: data.hasOwnProperty('cost_price') ? parseFloat(data.cost_price) : (data.hasOwnProperty('costPrice') ? parseFloat(data.costPrice) : record.cost_price),
|
||||
currency: data.currency || record.currency,
|
||||
stock: stock,
|
||||
available_stock: data.hasOwnProperty('available_stock') ? parseInt(data.available_stock) : (data.hasOwnProperty('availableStock') ? parseInt(data.availableStock) : record.available_stock),
|
||||
reserved_stock: data.hasOwnProperty('reserved_stock') ? parseInt(data.reserved_stock) : (data.hasOwnProperty('reservedStock') ? parseInt(data.reservedStock) : record.reserved_stock),
|
||||
safety_stock: data.hasOwnProperty('safety_stock') ? parseInt(data.safety_stock) : (data.hasOwnProperty('safetyStock') ? parseInt(data.safetyStock) : record.safety_stock),
|
||||
status: data.status || record.status
|
||||
status: data.status || record.status,
|
||||
is_active: data.hasOwnProperty('is_active') ? !!data.is_active : record.is_active,
|
||||
sort_order: data.hasOwnProperty('sort_order') ? parseInt(data.sort_order) : (data.hasOwnProperty('sortOrder') ? parseInt(data.sortOrder) : record.sort_order)
|
||||
};
|
||||
|
||||
await record.update(updatePayload, { transaction });
|
||||
@@ -357,6 +379,7 @@ export class VariantService {
|
||||
const attribute = family.variantAxes.find(axis => axis.code === code);
|
||||
if (attribute) {
|
||||
await models.VariantValue.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
variant_id: id,
|
||||
axis_id: attribute.id,
|
||||
value_text: String(val)
|
||||
@@ -365,6 +388,7 @@ export class VariantService {
|
||||
}
|
||||
}
|
||||
|
||||
await CompletenessService.calculate(record.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
const fullRecord = await repository.findById(id, {}, context);
|
||||
@@ -401,6 +425,7 @@ export class VariantService {
|
||||
|
||||
// Hard delete variant
|
||||
await record.destroy({ force: true, transaction });
|
||||
await CompletenessService.calculate(record.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
// Emit socket event
|
||||
@@ -430,6 +455,7 @@ export class VariantService {
|
||||
}
|
||||
|
||||
await record.destroy({ transaction });
|
||||
await CompletenessService.calculate(record.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
SocketService.broadcast('variant.archived', { id });
|
||||
@@ -522,6 +548,7 @@ export class VariantService {
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
await CompletenessService.calculate(variant.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
SocketService.broadcast('variant.updated', { id: variantId });
|
||||
@@ -535,6 +562,9 @@ export class VariantService {
|
||||
async updateAssetMapping(variantId, assetId, data, context = {}) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const variant = await repository.findById(variantId, { transaction }, context);
|
||||
if (!variant) throw new Error('Variant not found');
|
||||
|
||||
const mapping = await models.VariantAsset.findOne({
|
||||
where: { variant_id: variantId, asset_id: assetId },
|
||||
transaction
|
||||
@@ -554,6 +584,7 @@ export class VariantService {
|
||||
is_primary: data.is_primary !== undefined ? !!data.is_primary : mapping.is_primary
|
||||
}, { transaction });
|
||||
|
||||
await CompletenessService.calculate(variant.product_id, transaction);
|
||||
await transaction.commit();
|
||||
|
||||
SocketService.broadcast('variant.updated', { id: variantId });
|
||||
@@ -565,12 +596,16 @@ export class VariantService {
|
||||
}
|
||||
|
||||
async unassignAsset(variantId, assetId, context = {}) {
|
||||
const variant = await repository.findById(variantId, {}, context);
|
||||
if (!variant) throw new Error('Variant not found');
|
||||
|
||||
const mapping = await models.VariantAsset.findOne({
|
||||
where: { variant_id: variantId, asset_id: assetId }
|
||||
});
|
||||
if (!mapping) throw new Error('Asset mapping not found');
|
||||
|
||||
await mapping.destroy();
|
||||
await CompletenessService.calculate(variant.product_id);
|
||||
SocketService.broadcast('variant.updated', { id: variantId });
|
||||
return true;
|
||||
}
|
||||
@@ -628,17 +663,11 @@ export class VariantService {
|
||||
if (!parentProduct) throw new Error('Parent product not found');
|
||||
|
||||
const family = parentProduct.family;
|
||||
if (!family) throw new Error('Parent product has no Product Family assigned');
|
||||
|
||||
const configuredAxesCodes = (family.variantAxes || []).map(a => a.code);
|
||||
|
||||
// 2. Validate all provided axis codes are configured on the family
|
||||
// 2. Validate all provided axis codes
|
||||
for (const axis of axes) {
|
||||
if (!configuredAxesCodes.includes(axis.code)) {
|
||||
throw new Error(`Axis "${axis.code}" is not a configured variant axis for family "${family.name}"`);
|
||||
}
|
||||
if (!axis.values || axis.values.length === 0) {
|
||||
throw new Error(`Axis "${axis.code}" must have at least one value`);
|
||||
throw new Error(`Axis "${axis.name || axis.code}" must have at least one value selected`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,7 +690,15 @@ export class VariantService {
|
||||
transaction
|
||||
});
|
||||
|
||||
const existingSignatures = new Set(existingVariants.map(v => {
|
||||
// Delete any unconfigured master variant (0 values) created when product was in simple mode
|
||||
const unconfiguredMasterVariants = existingVariants.filter(v => (!v.values || v.values.length === 0));
|
||||
for (const unconf of unconfiguredMasterVariants) {
|
||||
await unconf.destroy({ transaction });
|
||||
}
|
||||
|
||||
const activeExistingVariants = existingVariants.filter(v => (v.values && v.values.length > 0));
|
||||
|
||||
const existingSignatures = new Set(activeExistingVariants.map(v => {
|
||||
const map = {};
|
||||
for (const val of (v.values || [])) {
|
||||
if (val.axis) map[val.axis.code] = val.value_text;
|
||||
@@ -701,24 +738,51 @@ export class VariantService {
|
||||
|
||||
// Create variant
|
||||
const variant = await models.Variant.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
product_id: productId,
|
||||
sku: finalSku,
|
||||
name: variantName,
|
||||
price: parseFloat(parentProduct.price) || 0.00,
|
||||
cost_price: 0.00,
|
||||
currency: 'USD',
|
||||
stock: 0,
|
||||
available_stock: 0,
|
||||
reserved_stock: 0,
|
||||
safety_stock: 0,
|
||||
status: 'draft'
|
||||
status: 'draft',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
}, { transaction });
|
||||
|
||||
// Create variant values
|
||||
for (const { code, value } of combo) {
|
||||
const axisAttr = family.variantAxes.find(a => a.code === code);
|
||||
let axisAttr = null;
|
||||
if (family && Array.isArray(family.variantAxes)) {
|
||||
axisAttr = family.variantAxes.find(a => a.code === code);
|
||||
}
|
||||
if (!axisAttr) {
|
||||
axisAttr = await models.Attribute.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ code: code },
|
||||
{ code: code.toLowerCase() }
|
||||
]
|
||||
},
|
||||
transaction
|
||||
});
|
||||
}
|
||||
if (!axisAttr) {
|
||||
// Auto-create attribute record for custom or dynamic axis if not existing in DB
|
||||
const axisInfo = axes.find(a => a.code === code);
|
||||
const axisName = axisInfo?.name || code.replace(/_/g, ' ').toUpperCase();
|
||||
axisAttr = await models.Attribute.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
code: code.toLowerCase(),
|
||||
name: axisName,
|
||||
type: 'select',
|
||||
is_variant_eligible: true,
|
||||
is_active: true
|
||||
}, { transaction });
|
||||
}
|
||||
if (axisAttr) {
|
||||
await models.VariantValue.create({
|
||||
tenant_id: context.tenantId || context.tenant_id || 1,
|
||||
variant_id: variant.id,
|
||||
axis_id: axisAttr.id,
|
||||
value_text: value
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Model, DataTypes } from 'sequelize';
|
||||
|
||||
export class VariantValue extends Model {
|
||||
static associate(models) {
|
||||
VariantValue.belongsTo(models.Tenant, {
|
||||
foreignKey: 'tenant_id',
|
||||
as: 'tenant'
|
||||
});
|
||||
// Belongs to variant
|
||||
VariantValue.belongsTo(models.Variant, {
|
||||
foreignKey: 'variant_id',
|
||||
@@ -17,6 +21,10 @@ export class VariantValue extends Model {
|
||||
|
||||
export default (sequelize) => {
|
||||
VariantValue.init({
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
variant_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
|
||||
@@ -4,7 +4,11 @@ import { applyTenantScope } from '../../utils/helpers/common.helper.js';
|
||||
export class WorkflowRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const where = applyTenantScope(options.where || {}, context);
|
||||
return await models.WorkflowRegistry.findAll({ ...options, where });
|
||||
return await models.WorkflowRegistry.findAll({
|
||||
order: [['created_at', 'DESC']],
|
||||
...options,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id, options = {}, context = {}) {
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// 1. Clean up any partial state from previous run by dropping tenant_id columns if they exist
|
||||
await queryInterface.removeColumn('products', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('product_attribute_values', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('variant_values', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variant_values', 'tenant_id').catch(() => null);
|
||||
|
||||
// 2. Add tenant_id column (INTEGER, matching tenants.id) to relevant tables
|
||||
await queryInterface.addColumn('products', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('product_variants', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('product_attribute_values', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('variant_values', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('product_variant_values', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
// 3. Add barcode column to product_variants (as specified in the blueprint)
|
||||
await queryInterface.addColumn('product_variants', 'barcode', {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
// 4. Add other new relational/metadata columns to products (catch if already exists)
|
||||
await queryInterface.addColumn('products', 'product_type', {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: 'simple'
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'description', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'short_description', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'slug', {
|
||||
type: Sequelize.STRING(500),
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'meta_title', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'meta_description', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'meta_keywords', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'completeness_score', {
|
||||
type: Sequelize.DECIMAL(5, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0.00
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'is_active', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'approved_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'approved_by', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
// Add other fields from blueprint (manufacturer_id, hsn_code_id, tax_class_id, workflow_state_id)
|
||||
await queryInterface.addColumn('products', 'manufacturer_id', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'hsn_code_id', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'tax_class_id', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('products', 'workflow_state_id', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
// 5. Add other columns to product_variants
|
||||
await queryInterface.addColumn('product_variants', 'is_active', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('product_variants', 'sort_order', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}).catch(() => null);
|
||||
|
||||
// 6. Ensure at least one tenant exists and update tenant_id fields
|
||||
const [tenants] = await queryInterface.sequelize.query('SELECT id FROM tenants LIMIT 1;');
|
||||
let defaultTenantId;
|
||||
if (tenants && tenants.length > 0) {
|
||||
defaultTenantId = tenants[0].id;
|
||||
} else {
|
||||
defaultTenantId = 1;
|
||||
await queryInterface.sequelize.query(`
|
||||
INSERT INTO tenants (id, tenant_code, tenant_name, status, created_at, updated_at)
|
||||
VALUES (${defaultTenantId}, 'default', 'Default Tenant', true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
}
|
||||
|
||||
await queryInterface.sequelize.query(`UPDATE products SET tenant_id = ${defaultTenantId} WHERE tenant_id IS NULL;`);
|
||||
await queryInterface.sequelize.query(`UPDATE product_variants SET tenant_id = ${defaultTenantId} WHERE tenant_id IS NULL;`);
|
||||
await queryInterface.sequelize.query(`UPDATE product_attribute_values SET tenant_id = ${defaultTenantId} WHERE tenant_id IS NULL;`);
|
||||
await queryInterface.sequelize.query(`UPDATE variant_values SET tenant_id = ${defaultTenantId} WHERE tenant_id IS NULL;`);
|
||||
await queryInterface.sequelize.query(`UPDATE product_variant_values SET tenant_id = ${defaultTenantId} WHERE tenant_id IS NULL;`);
|
||||
|
||||
// 7. Populate slug for existing products
|
||||
const [products] = await queryInterface.sequelize.query('SELECT id, name FROM products;');
|
||||
for (const prod of products) {
|
||||
const baseSlug = prod.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-+|-+$)/g, '');
|
||||
const slug = baseSlug || `product-${prod.id.slice(0, 8)}`;
|
||||
await queryInterface.sequelize.query(`UPDATE products SET slug = :slug WHERE id = :id;`, {
|
||||
replacements: { slug, id: prod.id }
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Enforce NOT NULL constraints
|
||||
await queryInterface.changeColumn('products', 'tenant_id', { type: Sequelize.INTEGER, allowNull: false });
|
||||
await queryInterface.changeColumn('products', 'slug', { type: Sequelize.STRING(500), allowNull: false });
|
||||
await queryInterface.changeColumn('product_variants', 'tenant_id', { type: Sequelize.INTEGER, allowNull: false });
|
||||
await queryInterface.changeColumn('product_attribute_values', 'tenant_id', { type: Sequelize.INTEGER, allowNull: false });
|
||||
await queryInterface.changeColumn('variant_values', 'tenant_id', { type: Sequelize.INTEGER, allowNull: false });
|
||||
|
||||
// 9. Migrate metadata details into EAV and variant tables
|
||||
const [existingProds] = await queryInterface.sequelize.query('SELECT id, name, status, tenant_id, metadata, description FROM products;');
|
||||
for (const prod of existingProds) {
|
||||
const metadata = prod.metadata || {};
|
||||
const prodType = metadata.type === 'variant' ? 'variable' : 'simple';
|
||||
const descVal = prod.description || metadata.description || '';
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE products
|
||||
SET product_type = :product_type, description = :description
|
||||
WHERE id = :id;
|
||||
`, {
|
||||
replacements: {
|
||||
product_type: prodType,
|
||||
description: descVal,
|
||||
id: prod.id
|
||||
}
|
||||
});
|
||||
|
||||
// Simple products: Migrate SKU, barcode, price to default master variant
|
||||
const [variants] = await queryInterface.sequelize.query(`SELECT id FROM product_variants WHERE product_id = '${prod.id}' LIMIT 1;`);
|
||||
if (variants.length === 0) {
|
||||
const sku = metadata.sku || `SKU-${prod.id.slice(0, 8).toUpperCase()}`;
|
||||
const barcode = metadata.barcode || null;
|
||||
const price = parseFloat(metadata.price) || 0.00;
|
||||
const costPrice = parseFloat(metadata.cost_price || metadata.costPrice) || 0.00;
|
||||
const generatedVariantId = crypto.randomUUID();
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
INSERT INTO product_variants (id, tenant_id, product_id, sku, barcode, name, price, cost_price, status, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :product_id, :sku, :barcode, :name, :price, :cost_price, :status, NOW(), NOW());
|
||||
`, {
|
||||
replacements: {
|
||||
id: generatedVariantId,
|
||||
tenant_id: prod.tenant_id,
|
||||
product_id: prod.id,
|
||||
sku: sku,
|
||||
barcode: barcode,
|
||||
name: prod.name,
|
||||
price: price,
|
||||
cost_price: costPrice,
|
||||
status: prod.status || 'draft'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate custom metadata attributes to product_attribute_values
|
||||
const attributes = metadata.attributes || {};
|
||||
for (const [code, val] of Object.entries(attributes)) {
|
||||
const [attrs] = await queryInterface.sequelize.query('SELECT id FROM attributes WHERE code = :code LIMIT 1;', {
|
||||
replacements: { code }
|
||||
});
|
||||
if (attrs.length > 0) {
|
||||
const attrId = attrs[0].id;
|
||||
const [existingVals] = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM product_attribute_values WHERE product_id = :productId AND attribute_id = :attrId LIMIT 1;',
|
||||
{ replacements: { productId: prod.id, attrId } }
|
||||
);
|
||||
if (existingVals.length === 0) {
|
||||
const valId = crypto.randomUUID();
|
||||
await queryInterface.sequelize.query(`
|
||||
INSERT INTO product_attribute_values (id, tenant_id, product_id, attribute_id, value, locale, channel, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :product_id, :attribute_id, :value, 'en', 'default', NOW(), NOW());
|
||||
`, {
|
||||
replacements: {
|
||||
id: valId,
|
||||
tenant_id: prod.tenant_id,
|
||||
product_id: prod.id,
|
||||
attribute_id: attrId,
|
||||
value: typeof val === 'object' ? JSON.stringify(val) : String(val)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Remove metadata column from products (catch if already done)
|
||||
await queryInterface.removeColumn('products', 'metadata').catch(() => null);
|
||||
|
||||
// 11. Remove stock columns from product_variants
|
||||
await queryInterface.removeColumn('product_variants', 'stock').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'available_stock').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'reserved_stock').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'safety_stock').catch(() => null);
|
||||
|
||||
// 12. Update unique constraints
|
||||
await queryInterface.sequelize.query('ALTER TABLE products DROP CONSTRAINT IF EXISTS products_code_key;').catch(() => null);
|
||||
await queryInterface.sequelize.query('DROP INDEX IF EXISTS products_code_key;').catch(() => null);
|
||||
await queryInterface.sequelize.query('ALTER TABLE product_variants DROP CONSTRAINT IF EXISTS product_variants_sku_key;').catch(() => null);
|
||||
await queryInterface.sequelize.query('DROP INDEX IF EXISTS product_variants_sku_key;').catch(() => null);
|
||||
|
||||
await queryInterface.addIndex('products', ['tenant_id', 'code'], {
|
||||
unique: true,
|
||||
name: 'idx_products_tenant_code'
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addIndex('product_variants', ['tenant_id', 'sku'], {
|
||||
unique: true,
|
||||
name: 'idx_variants_tenant_sku'
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addIndex('product_variants', ['tenant_id', 'barcode'], {
|
||||
unique: true,
|
||||
name: 'idx_variants_tenant_barcode'
|
||||
}).catch(() => null);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// Reverting migrations best-effort
|
||||
await queryInterface.addColumn('products', 'metadata', {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: true
|
||||
}).catch(() => null);
|
||||
|
||||
await queryInterface.addColumn('product_variants', 'stock', { type: Sequelize.INTEGER, defaultValue: 0 }).catch(() => null);
|
||||
await queryInterface.addColumn('product_variants', 'available_stock', { type: Sequelize.INTEGER, defaultValue: 0 }).catch(() => null);
|
||||
await queryInterface.addColumn('product_variants', 'reserved_stock', { type: Sequelize.INTEGER, defaultValue: 0 }).catch(() => null);
|
||||
await queryInterface.addColumn('product_variants', 'safety_stock', { type: Sequelize.INTEGER, defaultValue: 0 }).catch(() => null);
|
||||
|
||||
await queryInterface.removeIndex('products', 'idx_products_tenant_code').catch(() => null);
|
||||
await queryInterface.removeIndex('product_variants', 'idx_variants_tenant_sku').catch(() => null);
|
||||
await queryInterface.removeIndex('product_variants', 'idx_variants_tenant_barcode').catch(() => null);
|
||||
|
||||
await queryInterface.removeColumn('products', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'product_type').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'description').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'short_description').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'slug').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'meta_title').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'meta_description').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'meta_keywords').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'completeness_score').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'is_active').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'approved_at').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'approved_by').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'manufacturer_id').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'hsn_code_id').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'tax_class_id').catch(() => null);
|
||||
await queryInterface.removeColumn('products', 'workflow_state_id').catch(() => null);
|
||||
|
||||
await queryInterface.removeColumn('product_variants', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'is_active').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'sort_order').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variants', 'barcode').catch(() => null);
|
||||
|
||||
await queryInterface.removeColumn('product_attribute_values', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('variant_values', 'tenant_id').catch(() => null);
|
||||
await queryInterface.removeColumn('product_variant_values', 'tenant_id').catch(() => null);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,751 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// 1. integrations
|
||||
const integrationsExists = await queryInterface.describeTable('integrations').catch(() => null);
|
||||
if (!integrationsExists) {
|
||||
await queryInterface.createTable('integrations', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
channel: {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'active'
|
||||
},
|
||||
sync_mode: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'auto'
|
||||
},
|
||||
sync_frequency: {
|
||||
type: Sequelize.STRING(50),
|
||||
defaultValue: 'realtime'
|
||||
},
|
||||
health_status: {
|
||||
type: Sequelize.STRING(20),
|
||||
defaultValue: 'healthy'
|
||||
},
|
||||
last_synced_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
await queryInterface.addIndex('integrations', ['tenant_id', 'channel'], { name: 'idx_integrations_tenant_channel' }).catch(() => null);
|
||||
} else {
|
||||
if (!integrationsExists.channel) {
|
||||
await queryInterface.addColumn('integrations', 'channel', { type: Sequelize.STRING(50), allowNull: false, defaultValue: 'shopify' });
|
||||
}
|
||||
if (!integrationsExists.tenant_id) {
|
||||
await queryInterface.addColumn('integrations', 'tenant_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
}
|
||||
if (!integrationsExists.sync_mode) {
|
||||
await queryInterface.addColumn('integrations', 'sync_mode', { type: Sequelize.STRING(20), defaultValue: 'auto' });
|
||||
}
|
||||
if (!integrationsExists.sync_frequency) {
|
||||
await queryInterface.addColumn('integrations', 'sync_frequency', { type: Sequelize.STRING(50), defaultValue: 'realtime' });
|
||||
}
|
||||
if (!integrationsExists.health_status) {
|
||||
await queryInterface.addColumn('integrations', 'health_status', { type: Sequelize.STRING(20), defaultValue: 'healthy' });
|
||||
}
|
||||
if (!integrationsExists.last_synced_at) {
|
||||
await queryInterface.addColumn('integrations', 'last_synced_at', { type: Sequelize.DATE, allowNull: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. integration_credentials
|
||||
const credentialsExists = await queryInterface.describeTable('integration_credentials').catch(() => null);
|
||||
if (!credentialsExists) {
|
||||
await queryInterface.createTable('integration_credentials', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
integration_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'integrations', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
credential_type: {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
encrypted_secret: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
key_version: {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 1,
|
||||
allowNull: false
|
||||
},
|
||||
expires_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
await queryInterface.addIndex('integration_credentials', ['integration_id'], { name: 'idx_integration_credentials_integration' }).catch(() => null);
|
||||
}
|
||||
|
||||
// 3. publishing_rules
|
||||
const rulesExists = await queryInterface.describeTable('publishing_rules').catch(() => null);
|
||||
if (!rulesExists) {
|
||||
await queryInterface.createTable('publishing_rules', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
integration_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'integrations', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
rule_type: {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
conditions_json: {
|
||||
type: Sequelize.JSONB,
|
||||
defaultValue: {}
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. channel_mappings
|
||||
const mappingsExists = await queryInterface.describeTable('channel_mappings').catch(() => null);
|
||||
if (!mappingsExists) {
|
||||
await queryInterface.createTable('channel_mappings', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
integration_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'integrations', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
entity_type: {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
source_path: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
target_path: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
transformation_type: {
|
||||
type: Sequelize.STRING(50),
|
||||
defaultValue: 'string'
|
||||
},
|
||||
transformation_config: {
|
||||
type: Sequelize.JSONB,
|
||||
defaultValue: {}
|
||||
},
|
||||
default_value: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
required: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
version: {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.STRING(20),
|
||||
defaultValue: 'active'
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. sync_jobs
|
||||
const jobsExists = await queryInterface.describeTable('sync_jobs').catch(() => null);
|
||||
if (!jobsExists) {
|
||||
await queryInterface.createTable('sync_jobs', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
integration_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'integrations', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
trigger_source: {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual'
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
total_items: {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
success_items: {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
failed_items: {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
started_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
completed_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 6. sync_items
|
||||
const itemsExists = await queryInterface.describeTable('sync_items').catch(() => null);
|
||||
if (!itemsExists) {
|
||||
await queryInterface.createTable('sync_items', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
sync_job_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'sync_jobs', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
integration_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'integrations', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
product_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
variant_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
sku: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
operation: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
source_version: {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
idempotency_key: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
external_resource_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
attempt_count: {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
error_code: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
error_message: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
await queryInterface.addIndex('sync_items', ['tenant_id', 'idempotency_key'], {
|
||||
name: 'uq_sync_item_idempotency',
|
||||
unique: true
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
// 7. sync_attempts
|
||||
const attemptsExists = await queryInterface.describeTable('sync_attempts').catch(() => null);
|
||||
if (!attemptsExists) {
|
||||
await queryInterface.createTable('sync_attempts', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
sync_item_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'sync_items', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
attempt_number: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
started_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
completed_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false
|
||||
},
|
||||
request_method: {
|
||||
type: Sequelize.STRING(10),
|
||||
allowNull: false
|
||||
},
|
||||
request_url: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
idempotency_key: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
response_status: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
error_code: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
error_message: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
duration_ms: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 8. sync_errors
|
||||
const errorsExists = await queryInterface.describeTable('sync_errors').catch(() => null);
|
||||
if (!errorsExists) {
|
||||
await queryInterface.createTable('sync_errors', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
sync_job_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'sync_jobs', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
sync_item_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'sync_items', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
error_code: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
error_type: {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
message: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
provider_message: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
http_status: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
retryable: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
attempt_number: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
metadata: {
|
||||
type: Sequelize.JSONB,
|
||||
defaultValue: {}
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 9. external_resources
|
||||
const externalExists = await queryInterface.describeTable('external_resources').catch(() => null);
|
||||
if (!externalExists) {
|
||||
await queryInterface.createTable('external_resources', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
integration_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: { model: 'integrations', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
resource_type: {
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
source_type: {
|
||||
type: Sequelize.STRING(50),
|
||||
defaultValue: 'PIM'
|
||||
},
|
||||
source_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
external_id: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
external_parent_id: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
external_url: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.STRING(20),
|
||||
defaultValue: 'active'
|
||||
},
|
||||
last_synced_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
last_source_version: {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
await queryInterface.addIndex('external_resources', ['tenant_id', 'integration_id', 'source_id', 'resource_type'], {
|
||||
name: 'uq_external_resource_mapping',
|
||||
unique: true
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
// 10. outbox_events
|
||||
const outboxExists = await queryInterface.describeTable('outbox_events').catch(() => null);
|
||||
if (!outboxExists) {
|
||||
await queryInterface.createTable('outbox_events', {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: { model: 'tenants', key: 'id' },
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
event_type: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
aggregate_type: {
|
||||
type: Sequelize.STRING(50),
|
||||
defaultValue: 'PRODUCT'
|
||||
},
|
||||
aggregate_id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
payload: {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.STRING(20),
|
||||
defaultValue: 'pending',
|
||||
allowNull: false
|
||||
},
|
||||
event_version: {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
idempotency_key: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
retry_count: {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
locked_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
locked_by: {
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
last_error: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
scheduled_at: {
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
processed_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
await queryInterface.addIndex('outbox_events', ['tenant_id', 'status', 'scheduled_at'], { name: 'idx_outbox_events_status_scheduled' }).catch(() => null);
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('outbox_events').catch(() => null);
|
||||
await queryInterface.dropTable('external_resources').catch(() => null);
|
||||
await queryInterface.dropTable('sync_errors').catch(() => null);
|
||||
await queryInterface.dropTable('sync_attempts').catch(() => null);
|
||||
await queryInterface.dropTable('sync_items').catch(() => null);
|
||||
await queryInterface.dropTable('sync_jobs').catch(() => null);
|
||||
await queryInterface.dropTable('channel_mappings').catch(() => null);
|
||||
await queryInterface.dropTable('publishing_rules').catch(() => null);
|
||||
await queryInterface.dropTable('integration_credentials').catch(() => null);
|
||||
await queryInterface.dropTable('integrations').catch(() => null);
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,12 @@ module.exports = {
|
||||
port: process.env.DB_PORT || 5432,
|
||||
dialect: process.env.DB_DIALECT || "postgres",
|
||||
logging: false,
|
||||
pool: {
|
||||
max: 15,
|
||||
min: 2,
|
||||
acquire: 30000,
|
||||
idle: 10000,
|
||||
},
|
||||
},
|
||||
|
||||
development: {
|
||||
@@ -30,6 +36,12 @@ module.exports = {
|
||||
port: process.env.DB_PORT || 5432,
|
||||
dialect: process.env.DB_DIALECT || "postgres",
|
||||
logging: false,
|
||||
pool: {
|
||||
max: 15,
|
||||
min: 2,
|
||||
acquire: 30000,
|
||||
idle: 10000,
|
||||
},
|
||||
},
|
||||
|
||||
test: {
|
||||
|
||||
@@ -42,6 +42,14 @@ export const connectDatabase = async () => {
|
||||
primaryKey: true
|
||||
}).catch(() => null);
|
||||
}
|
||||
const assetTypesCols = await qi.describeTable('asset_types').catch(() => null);
|
||||
if (assetTypesCols && !assetTypesCols.is_variant_eligible) {
|
||||
await qi.addColumn('asset_types', 'is_variant_eligible', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false
|
||||
}).catch(() => null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Schema alignment warning:', err.message);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,17 @@ import productAttributeValueModelInit from '../../features/products/products/pro
|
||||
import productVariantValueModelInit from '../../features/products/products/productVariantValue.model.js';
|
||||
import productCompletenessModelInit from '../../features/products/products/productCompleteness.model.js';
|
||||
|
||||
import integrationModelInit from '../../features/integrations/models/integration.model.js';
|
||||
import integrationCredentialModelInit from '../../features/integrations/models/integrationCredential.model.js';
|
||||
import publishingRuleModelInit from '../../features/integrations/models/publishingRule.model.js';
|
||||
import integrationChannelMappingModelInit from '../../features/integrations/models/channelMapping.model.js';
|
||||
import integrationSyncJobModelInit from '../../features/integrations/models/syncJob.model.js';
|
||||
import syncItemModelInit from '../../features/integrations/models/syncItem.model.js';
|
||||
import syncAttemptModelInit from '../../features/integrations/models/syncAttempt.model.js';
|
||||
import syncErrorModelInit from '../../features/integrations/models/syncError.model.js';
|
||||
import externalResourceModelInit from '../../features/integrations/models/externalResource.model.js';
|
||||
import outboxEventModelInit from '../../features/integrations/models/outboxEvent.model.js';
|
||||
|
||||
export const initializeDatabaseModels = () => {
|
||||
registerModel('Tenant', tenantModelInit);
|
||||
registerModel('User', userModelInit);
|
||||
@@ -239,6 +250,18 @@ export const initializeDatabaseModels = () => {
|
||||
registerModel('ProductVariantValue', productVariantValueModelInit);
|
||||
registerModel('ProductCompleteness', productCompletenessModelInit);
|
||||
|
||||
// Integration Hub & Outbox Platform
|
||||
registerModel('Integration', integrationModelInit);
|
||||
registerModel('IntegrationCredential', integrationCredentialModelInit);
|
||||
registerModel('PublishingRule', publishingRuleModelInit);
|
||||
registerModel('IntegrationChannelMapping', integrationChannelMappingModelInit);
|
||||
registerModel('IntegrationSyncJob', integrationSyncJobModelInit);
|
||||
registerModel('SyncItem', syncItemModelInit);
|
||||
registerModel('SyncAttempt', syncAttemptModelInit);
|
||||
registerModel('SyncError', syncErrorModelInit);
|
||||
registerModel('ExternalResource', externalResourceModelInit);
|
||||
registerModel('OutboxEvent', outboxEventModelInit);
|
||||
|
||||
// Variants
|
||||
registerModel('Variant', variantModelInit);
|
||||
registerModel('VariantValue', variantValueModelInit);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { getRedisClient, isRedisReady } from './redis.client.js';
|
||||
|
||||
class CacheService {
|
||||
buildKey(tenantId, category, key) {
|
||||
return `tenant:${tenantId || 'global'}:${category}:${key}`;
|
||||
}
|
||||
|
||||
async get(tenantId, category, key) {
|
||||
if (!isRedisReady()) return null;
|
||||
try {
|
||||
const fullKey = this.buildKey(tenantId, category, key);
|
||||
const raw = await getRedisClient().get(fullKey);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(tenantId, category, key, value, ttlSeconds = 300) {
|
||||
if (!isRedisReady()) return false;
|
||||
try {
|
||||
const fullKey = this.buildKey(tenantId, category, key);
|
||||
const payload = JSON.stringify(value);
|
||||
if (ttlSeconds > 0) {
|
||||
await getRedisClient().set(fullKey, payload, 'EX', ttlSeconds);
|
||||
} else {
|
||||
await getRedisClient().set(fullKey, payload);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(tenantId, category, key) {
|
||||
if (!isRedisReady()) return false;
|
||||
try {
|
||||
const fullKey = this.buildKey(tenantId, category, key);
|
||||
await getRedisClient().del(fullKey);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMany(keys = []) {
|
||||
if (!isRedisReady() || keys.length === 0) return false;
|
||||
try {
|
||||
await getRedisClient().del(...keys);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cacheService = new CacheService();
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import Redis from 'ioredis';
|
||||
import winston from 'winston';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
let redisClient = null;
|
||||
let isConnected = false;
|
||||
|
||||
export const getRedisClient = () => {
|
||||
if (redisClient) return redisClient;
|
||||
|
||||
const redisHost = process.env.REDIS_HOST || '127.0.0.1';
|
||||
const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||
const redisPassword = process.env.REDIS_PASSWORD || undefined;
|
||||
|
||||
try {
|
||||
redisClient = new Redis({
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
password: redisPassword,
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: true,
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 100, 3000);
|
||||
return delay;
|
||||
}
|
||||
});
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
isConnected = true;
|
||||
logger.info('Redis client connected successfully');
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
isConnected = false;
|
||||
logger.warn(`Redis connection error: ${err.message}`);
|
||||
});
|
||||
|
||||
redisClient.connect().catch((err) => {
|
||||
logger.warn(`Redis failed to connect initially: ${err.message}`);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to initialize Redis client: ${err.message}`);
|
||||
}
|
||||
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
export const isRedisReady = () => isConnected;
|
||||
@@ -0,0 +1,32 @@
|
||||
class CircuitBreaker {
|
||||
constructor(threshold = 5, cooldownMs = 60000) {
|
||||
this.threshold = threshold;
|
||||
this.cooldownMs = cooldownMs;
|
||||
this.states = new Map(); // key -> { failures, state, nextAttempt }
|
||||
}
|
||||
|
||||
getCircuitState(key) {
|
||||
const current = this.states.get(key) || { failures: 0, state: 'CLOSED', nextAttempt: 0 };
|
||||
if (current.state === 'OPEN' && Date.now() >= current.nextAttempt) {
|
||||
current.state = 'HALF_OPEN';
|
||||
this.states.set(key, current);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
recordSuccess(key) {
|
||||
this.states.delete(key);
|
||||
}
|
||||
|
||||
recordFailure(key) {
|
||||
const current = this.getCircuitState(key);
|
||||
current.failures += 1;
|
||||
if (current.failures >= this.threshold) {
|
||||
current.state = 'OPEN';
|
||||
current.nextAttempt = Date.now() + this.cooldownMs;
|
||||
}
|
||||
this.states.set(key, current);
|
||||
}
|
||||
}
|
||||
|
||||
export const circuitBreaker = new CircuitBreaker();
|
||||
@@ -0,0 +1,64 @@
|
||||
import axios from 'axios';
|
||||
import { calculateBackoff, isRetryableError } from './retry.policy.js';
|
||||
import { circuitBreaker } from './circuit-breaker.js';
|
||||
import { sanitizeHeaders, sanitizePayload } from './sanitization.js';
|
||||
|
||||
export const createHttpClient = (config = {}) => {
|
||||
const instance = axios.create({
|
||||
baseURL: config.baseURL,
|
||||
timeout: config.timeout || 30000,
|
||||
headers: config.headers || {}
|
||||
});
|
||||
|
||||
const requestKey = config.baseURL || 'default_http';
|
||||
|
||||
const executeRequest = async (requestFn, maxAttempts = 3) => {
|
||||
const breaker = circuitBreaker.getCircuitState(requestKey);
|
||||
if (breaker.state === 'OPEN') {
|
||||
throw new Error(`Circuit breaker is OPEN for target ${requestKey}. Cooldown active.`);
|
||||
}
|
||||
|
||||
let attempt = 0;
|
||||
while (attempt < maxAttempts) {
|
||||
attempt += 1;
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const response = await requestFn(instance);
|
||||
circuitBreaker.recordSuccess(requestKey);
|
||||
return {
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
headers: response.headers,
|
||||
durationMs: Date.now() - startTime,
|
||||
attempt
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const retryable = isRetryableError(error);
|
||||
|
||||
if (attempt >= maxAttempts || !retryable) {
|
||||
circuitBreaker.recordFailure(requestKey);
|
||||
throw {
|
||||
status: error.response?.status || 500,
|
||||
message: error.message,
|
||||
providerData: error.response?.data,
|
||||
durationMs,
|
||||
attempt,
|
||||
retryable,
|
||||
sanitizedHeaders: sanitizeHeaders(error.config?.headers)
|
||||
};
|
||||
}
|
||||
|
||||
const waitMs = calculateBackoff(attempt);
|
||||
await new Promise((res) => setTimeout(res, waitMs));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get: (url, opts) => executeRequest((client) => client.get(url, opts)),
|
||||
post: (url, data, opts) => executeRequest((client) => client.post(url, data, opts)),
|
||||
put: (url, data, opts) => executeRequest((client) => client.put(url, data, opts)),
|
||||
delete: (url, opts) => executeRequest((client) => client.delete(url, opts))
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
export class RateLimiter {
|
||||
constructor(requestsPerSecond = 2) {
|
||||
this.requestsPerSecond = requestsPerSecond;
|
||||
this.queue = [];
|
||||
this.lastRequestTime = 0;
|
||||
}
|
||||
|
||||
async throttle() {
|
||||
const minInterval = 1000 / this.requestsPerSecond;
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.lastRequestTime;
|
||||
|
||||
if (elapsed < minInterval) {
|
||||
const waitMs = minInterval - elapsed;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
this.lastRequestTime = Date.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const calculateBackoff = (attempt, baseMs = 500, maxMs = 30000) => {
|
||||
const exponential = Math.min(maxMs, baseMs * Math.pow(2, attempt - 1));
|
||||
const jitter = Math.random() * (exponential * 0.2); // 20% jitter
|
||||
return Math.round(exponential + jitter);
|
||||
};
|
||||
|
||||
export const isRetryableError = (error) => {
|
||||
if (!error) return false;
|
||||
const status = error.response?.status;
|
||||
if (!status) return true; // Network errors, timeouts
|
||||
if (status === 429) return true; // Rate limiting
|
||||
if (status >= 500 && status < 600) return true; // Internal server errors
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
export const sanitizeHeaders = (headers = {}) => {
|
||||
const sanitized = { ...headers };
|
||||
const sensitiveKeys = ['authorization', 'x-shopify-access-token', 'api-key', 'secret'];
|
||||
|
||||
Object.keys(sanitized).forEach(key => {
|
||||
if (sensitiveKeys.includes(key.toLowerCase())) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
}
|
||||
});
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export const sanitizePayload = (data) => {
|
||||
if (!data || typeof data !== 'object') return data;
|
||||
const clone = JSON.parse(JSON.stringify(data));
|
||||
const sensitiveFields = ['password', 'secret', 'token', 'accessToken', 'client_secret'];
|
||||
|
||||
const redactRecursive = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
Object.keys(obj).forEach(k => {
|
||||
if (sensitiveFields.includes(k)) {
|
||||
obj[k] = '[REDACTED]';
|
||||
} else if (typeof obj[k] === 'object') {
|
||||
redactRecursive(obj[k]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
redactRecursive(clone);
|
||||
return clone;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import crypto from 'crypto';
|
||||
import { models } from '../../database/models.js';
|
||||
|
||||
export const outboxService = {
|
||||
async recordEvent(tenantId, aggregateType, aggregateId, eventType, payload, transaction = null) {
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
const idempotencyKey = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${tenantId}:${aggregateType}:${aggregateId}:${eventType}:${payloadStr}`)
|
||||
.digest('hex');
|
||||
|
||||
return await models.OutboxEvent.create(
|
||||
{
|
||||
tenant_id: tenantId,
|
||||
aggregate_type: aggregateType || 'PRODUCT',
|
||||
aggregate_id: aggregateId,
|
||||
event_type: eventType,
|
||||
payload: payload,
|
||||
status: 'pending',
|
||||
event_version: payload.version || 1,
|
||||
idempotency_key: idempotencyKey,
|
||||
scheduled_at: new Date()
|
||||
},
|
||||
transaction ? { transaction } : {}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export const QUEUE_NAMES = {
|
||||
INTEGRATION_SYNC: 'integration-sync',
|
||||
OUTBOX_DISPATCH: 'outbox-dispatch',
|
||||
WEBHOOK_DISPATCH: 'webhook-dispatch',
|
||||
RETRY_PROCESSING: 'retry-processing'
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import { getRedisClient, isRedisReady } from '../cache/redis.client.js';
|
||||
import { QUEUE_NAMES } from './queue.constants.js';
|
||||
import winston from 'winston';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
class QueueService {
|
||||
constructor() {
|
||||
this.queues = new Map();
|
||||
this.workers = new Map();
|
||||
this.processors = new Map();
|
||||
}
|
||||
|
||||
getQueue(queueName) {
|
||||
if (this.queues.has(queueName)) {
|
||||
return this.queues.get(queueName);
|
||||
}
|
||||
|
||||
if (!isRedisReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const redisConnection = getRedisClient();
|
||||
const queue = new Queue(queueName, {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000
|
||||
},
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500
|
||||
}
|
||||
});
|
||||
|
||||
this.queues.set(queueName, queue);
|
||||
return queue;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to initialize queue ${queueName}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async addJob(queueName, jobName, payload, options = {}) {
|
||||
const queue = this.getQueue(queueName);
|
||||
if (!queue) {
|
||||
// In-memory fallback execution if Redis is offline
|
||||
const processor = this.processors.get(queueName);
|
||||
if (processor) {
|
||||
logger.info(`[QueueFallback] Executing job "${jobName}" in-memory on ${queueName} (Redis offline)`);
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await processor({ id: `in_mem_${Date.now()}`, data: payload });
|
||||
} catch (err) {
|
||||
logger.error(`[QueueFallback] Execution error on "${jobName}": ${err.message}`);
|
||||
}
|
||||
});
|
||||
return { id: `in_mem_${Date.now()}` };
|
||||
}
|
||||
logger.warn(`Skipping job enqueuing for ${queueName} as queue and processor are unavailable`);
|
||||
return null;
|
||||
}
|
||||
return await queue.add(jobName, payload, options);
|
||||
}
|
||||
|
||||
registerWorker(queueName, processor, concurrency = 5) {
|
||||
// Always store processor for in-memory fallback
|
||||
this.processors.set(queueName, processor);
|
||||
|
||||
if (!isRedisReady()) {
|
||||
logger.info(`Redis offline — registered in-memory fallback worker for ${queueName}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const redisConnection = getRedisClient();
|
||||
const worker = new Worker(queueName, processor, {
|
||||
connection: redisConnection,
|
||||
concurrency
|
||||
});
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
logger.info(`Job ${job.id} completed on queue ${queueName}`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
logger.error(`Job ${job?.id} failed on queue ${queueName}: ${err.message}`);
|
||||
});
|
||||
|
||||
this.workers.set(queueName, worker);
|
||||
return worker;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to register worker for ${queueName}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const queueService = new QueueService();
|
||||
@@ -0,0 +1,47 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12; // 96 bits for GCM
|
||||
const DEFAULT_KEY = process.env.ENCRYPTION_SECRET || 'antigravity_default_secret_key_32_bytes_len!!';
|
||||
|
||||
const getMasterKey = () => {
|
||||
return crypto.createHash('sha256').update(DEFAULT_KEY).digest();
|
||||
};
|
||||
|
||||
export const secretService = {
|
||||
encrypt(plaintext) {
|
||||
if (!plaintext) return '';
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, getMasterKey(), iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag().toString('hex');
|
||||
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
|
||||
},
|
||||
|
||||
decrypt(ciphertext) {
|
||||
if (!ciphertext) return '';
|
||||
try {
|
||||
const parts = ciphertext.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid ciphertext format');
|
||||
}
|
||||
|
||||
const [ivHex, authTagHex, encryptedText] = parts;
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, getMasterKey(), iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to decrypt credentials: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { secretService } from './shared/infrastructure/secrets/secret.service.js';
|
||||
import { shopifyMapper } from './features/integrations/adapters/shopify/shopify.mapper.js';
|
||||
import { ShopifyAdapter } from './features/integrations/adapters/shopify/shopify.adapter.js';
|
||||
|
||||
console.log('=== 1. Testing AES-256-GCM Secret Service ===');
|
||||
const sampleToken = 'shpat_1234567890abcdef1234567890abcdef';
|
||||
const encrypted = secretService.encrypt(sampleToken);
|
||||
console.log('Encrypted Token:', encrypted);
|
||||
const decrypted = secretService.decrypt(encrypted);
|
||||
console.log('Decrypted Token:', decrypted);
|
||||
if (decrypted === sampleToken) {
|
||||
console.log('✅ Secret Service Encryption/Decryption PASSED!');
|
||||
} else {
|
||||
console.error('❌ Secret Service Encryption Failed');
|
||||
}
|
||||
|
||||
console.log('\n=== 2. Testing Shopify Mapper ===');
|
||||
const mockCanonicalProduct = {
|
||||
identity: { id: '3a25f11e-5539-4000-9211-8c9f5fe37d99', code: 'HM-FULL-SLEEVES' },
|
||||
content: { name: 'H&M Full Sleeves T-shirt', description: 'High quality cotton shirt', status: 'active' },
|
||||
taxonomy: { brand: { name: 'H&M' }, category: { name: 'Apparel' } },
|
||||
attributes: { color: 'Blue', size: 'M' },
|
||||
variants: [
|
||||
{ id: 'v1', sku: 'HM-BLUE-M', price: 29.99, costPrice: 15.00 }
|
||||
],
|
||||
version: 1
|
||||
};
|
||||
|
||||
const shopifyInput = shopifyMapper.toProductInput(mockCanonicalProduct);
|
||||
console.log('Mapped Shopify Product Input:', JSON.stringify(shopifyInput, null, 2));
|
||||
|
||||
if (shopifyInput.title === 'H&M Full Sleeves T-shirt' && shopifyInput.status === 'ACTIVE') {
|
||||
console.log('✅ Shopify Mapper PASSED!');
|
||||
} else {
|
||||
console.error('❌ Shopify Mapper Failed');
|
||||
}
|
||||
|
||||
console.log('\n=== 3. Testing Shopify Adapter Instantiation ===');
|
||||
const adapter = new ShopifyAdapter({
|
||||
shop_domain: 'technova1.myshopify.com',
|
||||
access_token: sampleToken
|
||||
});
|
||||
|
||||
if (adapter && adapter.client) {
|
||||
console.log('✅ Shopify Adapter Instantiation PASSED!');
|
||||
} else {
|
||||
console.error('❌ Shopify Adapter Instantiation Failed');
|
||||
}
|
||||
Reference in New Issue
Block a user