9 Commits
78 changed files with 5291 additions and 482 deletions
+468
View File
@@ -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",
+3
View File
@@ -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;
+3 -7
View File
@@ -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
});
}
};
+10 -29
View File
@@ -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,
+294 -183
View File
@@ -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
};
}
+22 -25
View File
@@ -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);
}
};
+12
View File
@@ -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: {
+8
View File
@@ -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);
}
+23
View File
@@ -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);
+57
View File
@@ -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
View File
@@ -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}`);
}
}
};
+48
View File
@@ -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');
}