Compare commits
12 Commits
cf5bbf91d5
...
97f011a24b
| Author | SHA1 | Date | |
|---|---|---|---|
| 97f011a24b | |||
| cdbddddaec | |||
| 94993c3cbc | |||
| 9c3ec2a953 | |||
| e6a670c67b | |||
| e9d4f9dc20 | |||
| 16fece8ca9 | |||
| d4b54efa17 | |||
| 2988ecab0b | |||
| f415138103 | |||
| c4010e8c5e | |||
| 1daf9bda72 |
Generated
+318
-63
@@ -12,6 +12,7 @@
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"googleapis": "^173.0.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"jszip": "^3.10.1",
|
||||
"next": "14.2.35",
|
||||
@@ -929,7 +930,6 @@
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
@@ -945,7 +945,6 @@
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -956,7 +955,6 @@
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
@@ -1947,7 +1945,6 @@
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@@ -3405,7 +3402,6 @@
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
@@ -3457,7 +3453,6 @@
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3465,7 +3460,6 @@
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -4293,7 +4287,6 @@
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
@@ -4359,6 +4352,15 @@
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"dev": true,
|
||||
@@ -4491,6 +4493,12 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -4595,7 +4603,6 @@
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -4607,7 +4614,6 @@
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -4846,7 +4852,6 @@
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -4857,7 +4862,6 @@
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
@@ -4911,7 +4915,6 @@
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -4964,6 +4967,15 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
@@ -5028,7 +5040,6 @@
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -5253,7 +5264,6 @@
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
@@ -5266,9 +5276,17 @@
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.20.0",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
||||
@@ -5302,7 +5320,6 @@
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/empathic": {
|
||||
@@ -5362,7 +5379,6 @@
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5370,7 +5386,6 @@
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5473,7 +5488,6 @@
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -6016,6 +6030,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "3.23.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||
@@ -6115,6 +6135,29 @@
|
||||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"dev": true,
|
||||
@@ -6192,7 +6235,6 @@
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
@@ -6205,6 +6247,18 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
@@ -6230,7 +6284,6 @@
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -6263,6 +6316,34 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz",
|
||||
"integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
@@ -6303,7 +6384,6 @@
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -6343,7 +6423,6 @@
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
@@ -6411,7 +6490,6 @@
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.3.10",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
@@ -6443,7 +6521,6 @@
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -6451,7 +6528,6 @@
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
@@ -6492,9 +6568,112 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "10.7.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz",
|
||||
"integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.1.4",
|
||||
"gcp-metadata": "8.1.2",
|
||||
"google-logging-utils": "1.1.3",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/google-logging-utils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis": {
|
||||
"version": "173.0.0",
|
||||
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-173.0.0.tgz",
|
||||
"integrity": "sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.2.0",
|
||||
"googleapis-common": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.2.tgz",
|
||||
"integrity": "sha512-5MXeQzIZaqCH7B+HJWqhQm946VARpZep6acbWSr/fcgF2cQANq7allgX+i/G0EqF0WyUxB277gtWMzRYHMl9tg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"gaxios": "7.1.3",
|
||||
"google-auth-library": "10.5.0",
|
||||
"google-logging-utils": "1.1.3",
|
||||
"qs": "^6.7.0",
|
||||
"url-template": "^2.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common/node_modules/gaxios": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
|
||||
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"rimraf": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common/node_modules/google-auth-library": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
|
||||
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.0.0",
|
||||
"gcp-metadata": "^8.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"gtoken": "^8.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common/node_modules/rimraf": {
|
||||
"version": "5.0.10",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
|
||||
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^10.3.7"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -6526,6 +6705,19 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gtoken": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
|
||||
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
||||
@@ -6594,7 +6786,6 @@
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -6619,7 +6810,6 @@
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -6689,7 +6879,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
@@ -7014,7 +7203,6 @@
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7273,7 +7461,6 @@
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
@@ -7365,7 +7552,6 @@
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "2.3.6",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
@@ -8533,6 +8719,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"dev": true,
|
||||
@@ -8628,6 +8823,27 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"dev": true,
|
||||
@@ -8744,7 +8960,6 @@
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
@@ -8809,7 +9024,6 @@
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -8894,7 +9108,6 @@
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -8908,7 +9121,6 @@
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
@@ -9123,6 +9335,26 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-exports-info": {
|
||||
"version": "1.6.0",
|
||||
"dev": true,
|
||||
@@ -9148,6 +9380,24 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -9217,7 +9467,6 @@
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -9671,7 +9920,6 @@
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -9684,7 +9932,6 @@
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
@@ -10181,6 +10428,21 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"dev": true,
|
||||
@@ -10768,7 +11030,6 @@
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -10779,7 +11040,6 @@
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -10787,7 +11047,6 @@
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -10805,7 +11064,6 @@
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -10820,7 +11078,6 @@
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
@@ -10837,7 +11094,6 @@
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
@@ -10855,7 +11111,6 @@
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -11042,7 +11297,6 @@
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
@@ -11059,7 +11313,6 @@
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -11072,12 +11325,10 @@
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -11088,7 +11339,6 @@
|
||||
},
|
||||
"node_modules/string-width/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
@@ -11480,7 +11730,6 @@
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -11492,7 +11741,6 @@
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -12279,6 +12527,12 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-template": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
|
||||
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
|
||||
"license": "BSD"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"license": "MIT"
|
||||
@@ -12353,6 +12607,15 @@
|
||||
"makeerror": "1.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
@@ -12416,7 +12679,6 @@
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
@@ -12526,7 +12788,6 @@
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
@@ -12543,7 +12804,6 @@
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -12559,12 +12819,10 @@
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -12577,7 +12835,6 @@
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -12588,7 +12845,6 @@
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -12599,7 +12855,6 @@
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"googleapis": "^173.0.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"jszip": "^3.10.1",
|
||||
"next": "14.2.35",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { GET } from '@/app/api/sync/history/route'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
syncRun: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
const mockFindMany = prisma.syncRun.findMany as jest.Mock
|
||||
|
||||
describe('GET /api/sync/history', () => {
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
it('retourne une liste vide si aucun SyncRun', async () => {
|
||||
mockFindMany.mockResolvedValue([])
|
||||
const req = new NextRequest('http://localhost/api/sync/history')
|
||||
const res = await GET(req)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
it('retourne les syncs formatées', async () => {
|
||||
const fakeRun = {
|
||||
id: 'clid1',
|
||||
createdAt: new Date('2026-06-08T10:00:00Z'),
|
||||
status: 'success',
|
||||
created: 3,
|
||||
updated: 1,
|
||||
deactivated: 0,
|
||||
errors: 0,
|
||||
log: JSON.stringify([{ ref: 'REF-1', action: 'create', status: 'success', message: '✓ REF-1 créé' }]),
|
||||
user: { name: 'Mathieu' },
|
||||
}
|
||||
mockFindMany.mockResolvedValue([fakeRun])
|
||||
|
||||
const req = new NextRequest('http://localhost/api/sync/history')
|
||||
const res = await GET(req)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const data = await res.json()
|
||||
expect(data).toHaveLength(1)
|
||||
expect(data[0].id).toBe('clid1')
|
||||
expect(data[0].userName).toBe('Mathieu')
|
||||
expect(data[0].status).toBe('success')
|
||||
expect(data[0].created).toBe(3)
|
||||
expect(data[0].log).toHaveLength(1)
|
||||
expect(data[0].log[0].ref).toBe('REF-1')
|
||||
expect(data[0].createdAt).toBe('2026-06-08T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('retourne 500 si Prisma échoue', async () => {
|
||||
mockFindMany.mockRejectedValue(new Error('DB error'))
|
||||
const req = new NextRequest('http://localhost/api/sync/history')
|
||||
const res = await GET(req)
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { GET } from '@/app/api/sync/preview/route'
|
||||
import { NextRequest } from 'next/server'
|
||||
import type { SyncProduct, ShopifyProductFull } from '@/types/sync'
|
||||
|
||||
jest.mock('@/lib/googleSheets', () => ({
|
||||
readSheetProducts: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/lib/shopifySync', () => ({
|
||||
fetchAllShopifyProducts: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/lib/syncDiff', () => ({
|
||||
computeDiff: jest.fn(),
|
||||
}))
|
||||
|
||||
import { readSheetProducts } from '@/lib/googleSheets'
|
||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||
import { computeDiff } from '@/lib/syncDiff'
|
||||
|
||||
const mockSheets = readSheetProducts as jest.Mock
|
||||
const mockShopify = fetchAllShopifyProducts as jest.Mock
|
||||
const mockDiff = computeDiff as jest.Mock
|
||||
|
||||
describe('GET /api/sync/preview', () => {
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
it('retourne le DiffResult complet', async () => {
|
||||
const fakeSheetProducts: SyncProduct[] = [{ ref: 'REF-1', handle: 'ref-1', title: 'T', description: '', price: '10.00', stock: 1, status: 'active' }]
|
||||
const fakeShopifyProducts: ShopifyProductFull[] = []
|
||||
const fakeDiff = {
|
||||
changes: [{ type: 'create', ref: 'REF-1', sheetProduct: fakeSheetProducts[0] }],
|
||||
summary: { create: 1, update: 0, deactivate: 0, unchanged: 0 },
|
||||
}
|
||||
|
||||
mockSheets.mockResolvedValue(fakeSheetProducts)
|
||||
mockShopify.mockResolvedValue(fakeShopifyProducts)
|
||||
mockDiff.mockReturnValue(fakeDiff)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/sync/preview')
|
||||
const res = await GET(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.summary.create).toBe(1)
|
||||
expect(data.changes).toHaveLength(1)
|
||||
expect(mockDiff).toHaveBeenCalledWith(fakeSheetProducts, fakeShopifyProducts)
|
||||
})
|
||||
|
||||
it('retourne 500 si Google Sheets échoue', async () => {
|
||||
mockSheets.mockRejectedValue(new Error('Google Auth error'))
|
||||
const req = new NextRequest('http://localhost/api/sync/preview')
|
||||
const res = await GET(req)
|
||||
expect(res.status).toBe(500)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('retourne 500 si Shopify échoue', async () => {
|
||||
mockSheets.mockResolvedValue([])
|
||||
mockShopify.mockRejectedValue(new Error('Shopify error'))
|
||||
const req = new NextRequest('http://localhost/api/sync/preview')
|
||||
const res = await GET(req)
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { parseSheetRows } from '@/lib/googleSheets'
|
||||
|
||||
describe('parseSheetRows', () => {
|
||||
it('parse une ligne complète', () => {
|
||||
const rows = [['REF-1042', 'Carrelage sol', 'Beau carrelage', '29.99', '15', 'actif']]
|
||||
const result = parseSheetRows(rows)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
ref: 'REF-1042',
|
||||
handle: 'ref-1042',
|
||||
title: 'Carrelage sol',
|
||||
description: 'Beau carrelage',
|
||||
price: '29.99',
|
||||
stock: 15,
|
||||
status: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalise le handle en minuscules avec tirets', () => {
|
||||
const rows = [['ART 001/B', 'Produit', '', '10.00', '0', 'actif']]
|
||||
const [p] = parseSheetRows(rows)
|
||||
expect(p.handle).toBe('art-001-b')
|
||||
})
|
||||
|
||||
it('ignore les lignes sans référence', () => {
|
||||
const rows = [['', 'Titre vide', '', '0', '0', ''], ['REF-1', 'OK', '', '5.00', '1', 'actif']]
|
||||
expect(parseSheetRows(rows)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('gère un prix avec virgule (format français)', () => {
|
||||
const rows = [['REF-2', 'Test', '', '19,99', '0', 'actif']]
|
||||
expect(parseSheetRows(rows)[0].price).toBe('19.99')
|
||||
})
|
||||
|
||||
it('marque comme draft si la colonne statut contient "inac"', () => {
|
||||
const rows = [
|
||||
['REF-3', 'A', '', '1.00', '0', 'Inactif'],
|
||||
['REF-4', 'B', '', '1.00', '0', 'inactif'],
|
||||
['REF-5', 'C', '', '1.00', '0', 'actif'],
|
||||
['REF-6', 'D', '', '1.00', '0', ''],
|
||||
]
|
||||
const results = parseSheetRows(rows)
|
||||
expect(results[0].status).toBe('draft')
|
||||
expect(results[1].status).toBe('draft')
|
||||
expect(results[2].status).toBe('active')
|
||||
expect(results[3].status).toBe('active')
|
||||
})
|
||||
|
||||
it('utilise la référence comme titre si le titre est vide', () => {
|
||||
const rows = [['REF-7', '', '', '5.00', '0', 'actif']]
|
||||
expect(parseSheetRows(rows)[0].title).toBe('REF-7')
|
||||
})
|
||||
|
||||
it('accepte des lignes avec colonnes manquantes', () => {
|
||||
const rows = [['REF-8']]
|
||||
const [p] = parseSheetRows(rows)
|
||||
expect(p.ref).toBe('REF-8')
|
||||
expect(p.price).toBe('0.00')
|
||||
expect(p.stock).toBe(0)
|
||||
expect(p.status).toBe('active')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { computeDiff } from '@/lib/syncDiff'
|
||||
import type { SyncProduct, ShopifyProductFull } from '@/types/sync'
|
||||
|
||||
const sheet = (ref: string, overrides: Partial<SyncProduct> = {}): SyncProduct => ({
|
||||
ref,
|
||||
handle: ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
|
||||
title: `Produit ${ref}`,
|
||||
description: 'Desc',
|
||||
price: '10.00',
|
||||
stock: 5,
|
||||
status: 'active',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const shopify = (ref: string, shopifyId = '111', overrides: Partial<ShopifyProductFull> = {}): ShopifyProductFull => ({
|
||||
shopifyId,
|
||||
...sheet(ref, overrides),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('computeDiff', () => {
|
||||
it('retourne vide si aucun produit des deux côtés', () => {
|
||||
const { changes, summary } = computeDiff([], [])
|
||||
expect(changes).toHaveLength(0)
|
||||
expect(summary).toEqual({ create: 0, update: 0, deactivate: 0, unchanged: 0 })
|
||||
})
|
||||
|
||||
it('marque comme create si produit présent dans Sheets mais absent de Shopify', () => {
|
||||
const { changes, summary } = computeDiff([sheet('REF-1')], [])
|
||||
expect(changes).toHaveLength(1)
|
||||
expect(changes[0].type).toBe('create')
|
||||
expect(changes[0].ref).toBe('REF-1')
|
||||
expect(summary.create).toBe(1)
|
||||
})
|
||||
|
||||
it('marque comme unchanged si produits identiques', () => {
|
||||
const s = sheet('REF-2')
|
||||
const sp = shopify('REF-2')
|
||||
const { changes, summary } = computeDiff([s], [sp])
|
||||
expect(changes[0].type).toBe('unchanged')
|
||||
expect(summary.unchanged).toBe(1)
|
||||
})
|
||||
|
||||
it('marque comme update si le titre diffère', () => {
|
||||
const s = sheet('REF-3', { title: 'Nouveau titre' })
|
||||
const sp = shopify('REF-3', '222', { title: 'Ancien titre' })
|
||||
const { changes } = computeDiff([s], [sp])
|
||||
expect(changes[0].type).toBe('update')
|
||||
expect(changes[0].changedFields).toContainEqual({
|
||||
field: 'title',
|
||||
from: 'Ancien titre',
|
||||
to: 'Nouveau titre',
|
||||
})
|
||||
})
|
||||
|
||||
it('marque comme update si le prix diffère', () => {
|
||||
const s = sheet('REF-4', { price: '25.00' })
|
||||
const sp = shopify('REF-4', '333', { price: '20.00' })
|
||||
const { changes } = computeDiff([s], [sp])
|
||||
expect(changes[0].type).toBe('update')
|
||||
expect(changes[0].changedFields).toContainEqual({ field: 'price', from: '20.00', to: '25.00' })
|
||||
})
|
||||
|
||||
it('marque comme update si le statut diffère', () => {
|
||||
const s = sheet('REF-5', { status: 'draft' })
|
||||
const sp = shopify('REF-5', '444', { status: 'active' })
|
||||
const { changes } = computeDiff([s], [sp])
|
||||
expect(changes[0].type).toBe('update')
|
||||
})
|
||||
|
||||
it('marque comme deactivate si produit ACTIF dans Shopify absent des Sheets', () => {
|
||||
const sp = shopify('REF-6', '555', { status: 'active' })
|
||||
const { changes, summary } = computeDiff([], [sp])
|
||||
expect(changes[0].type).toBe('deactivate')
|
||||
expect(summary.deactivate).toBe(1)
|
||||
})
|
||||
|
||||
it("ne marque PAS comme deactivate si le produit Shopify est déjà draft", () => {
|
||||
const sp = shopify('REF-7', '666', { status: 'draft' })
|
||||
const { changes } = computeDiff([], [sp])
|
||||
expect(changes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('calcule le résumé correctement sur un mix de changements', () => {
|
||||
const sheetProducts = [sheet('REF-A'), sheet('REF-B', { title: 'Modifié' }), sheet('REF-C')]
|
||||
const shopifyProducts = [
|
||||
shopify('REF-B', '10', { title: 'Original' }),
|
||||
shopify('REF-C', '11'),
|
||||
shopify('REF-D', '12', { status: 'active' }),
|
||||
]
|
||||
const { summary } = computeDiff(sheetProducts, shopifyProducts)
|
||||
expect(summary.create).toBe(1) // REF-A
|
||||
expect(summary.update).toBe(1) // REF-B
|
||||
expect(summary.unchanged).toBe(1) // REF-C
|
||||
expect(summary.deactivate).toBe(1) // REF-D
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,222 @@
|
||||
'use client'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { SyncStepper } from '@/components/sync/SyncStepper'
|
||||
import { DiffTable } from '@/components/sync/DiffTable'
|
||||
import { SyncProgress } from '@/components/sync/SyncProgress'
|
||||
import { SyncHistory } from '@/components/sync/SyncHistory'
|
||||
import type { DiffResult, SyncChange, SyncHistoryEntry } from '@/types/sync'
|
||||
|
||||
type Step = 1 | 2 | 3 | 4
|
||||
|
||||
export default function SyncPage() {
|
||||
const [step, setStep] = useState<Step>(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [diff, setDiff] = useState<DiffResult | null>(null)
|
||||
const [history, setHistory] = useState<SyncHistoryEntry[]>([])
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||
|
||||
// Log streaming state
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
const [summary, setSummary] = useState<{
|
||||
created: number; updated: number; deactivated: number; errors: number; status: string
|
||||
} | null>(null)
|
||||
|
||||
// Étape 1 → 2 : lancer le preview
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sync/preview')
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
setDiff(data)
|
||||
setStep(2)
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Étape 3 → 4 : lancer la sync en streaming
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!diff) return
|
||||
setStep(4)
|
||||
setLog([])
|
||||
setProgress(null)
|
||||
setSummary(null)
|
||||
|
||||
const actionableChanges: SyncChange[] = diff.changes.filter((c) => c.type !== 'unchanged')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sync/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ changes: actionableChanges }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines.filter(Boolean)) {
|
||||
try {
|
||||
const event = JSON.parse(line)
|
||||
if (event.type === 'progress') {
|
||||
setProgress({ current: event.current, total: event.total })
|
||||
setLog((prev) => [...prev, event.message])
|
||||
} else if (event.type === 'done') {
|
||||
setSummary(event.summary)
|
||||
setHistoryLoaded(false) // force reload history
|
||||
}
|
||||
} catch { /* ligne incomplète */ }
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setLog((prev) => [...prev, `❌ Erreur : ${(err as Error).message}`])
|
||||
}
|
||||
}, [diff])
|
||||
|
||||
// Charger l'historique au besoin
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (historyLoaded) return
|
||||
try {
|
||||
const res = await fetch('/api/sync/history')
|
||||
if (res.ok) {
|
||||
setHistory(await res.json())
|
||||
setHistoryLoaded(true)
|
||||
}
|
||||
} catch { /* silencieux */ }
|
||||
}, [historyLoaded])
|
||||
|
||||
const actionableCount = diff
|
||||
? diff.summary.create + diff.summary.update + diff.summary.deactivate
|
||||
: 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="🔄 Sync Sheets" />
|
||||
<div className="p-6"><p className="text-gray-400 text-sm">Module Sync — à implémenter (Plan 3)</p></div>
|
||||
|
||||
<div className="p-6 flex flex-col gap-6 flex-1 overflow-y-auto">
|
||||
{/* Stepper */}
|
||||
<SyncStepper currentStep={step} />
|
||||
|
||||
{/* Étape 1 — Analyser */}
|
||||
{step === 1 && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<p className="text-slate-400 text-sm text-center max-w-md">
|
||||
Lit Google Sheets et compare avec le catalogue Shopify actuel pour générer l'aperçu des changements.
|
||||
</p>
|
||||
{error && (
|
||||
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-4 py-2 max-w-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold px-6 py-3 rounded-xl text-sm transition-colors"
|
||||
>
|
||||
{loading ? '⏳ Analyse en cours…' : '🔍 Analyser le Sheets'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Étape 2 — Aperçu diff */}
|
||||
{step === 2 && diff && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<DiffTable diff={diff} />
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => { setStep(1); setDiff(null) }}
|
||||
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg"
|
||||
>
|
||||
← Relancer l'analyse
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(3)}
|
||||
disabled={actionableCount === 0}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white font-semibold rounded-lg"
|
||||
>
|
||||
Confirmer →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Étape 3 — Confirmation */}
|
||||
{step === 3 && diff && (
|
||||
<div className="flex flex-col items-center gap-6 py-8">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 text-center max-w-sm">
|
||||
<p className="text-lg font-semibold text-white mb-2">
|
||||
{actionableCount} changement{actionableCount > 1 ? 's' : ''} vont être appliqués à Shopify
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
{diff.summary.create > 0 && `${diff.summary.create} créations · `}
|
||||
{diff.summary.update > 0 && `${diff.summary.update} mises à jour · `}
|
||||
{diff.summary.deactivate > 0 && `${diff.summary.deactivate} désactivations`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
className="px-6 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
|
||||
>
|
||||
✅ Confirmer et synchroniser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Étape 4 — Progression */}
|
||||
{step === 4 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SyncProgress log={log} progress={progress} summary={summary} />
|
||||
{summary && (
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => { setStep(1); setDiff(null); setSummary(null); setLog([]) }}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg"
|
||||
>
|
||||
↺ Nouvelle sync
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historique */}
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={loadHistory}
|
||||
className="text-sm text-slate-500 hover:text-slate-300 mb-3"
|
||||
>
|
||||
{historyLoaded ? '📋 Historique des syncs' : '📋 Charger l\'historique'}
|
||||
</button>
|
||||
{historyLoaded && <SyncHistory entries={history} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
createShopifyProduct,
|
||||
updateShopifyProduct,
|
||||
deactivateShopifyProduct,
|
||||
getFirstVariantId,
|
||||
} from '@/lib/shopifySync'
|
||||
import type { SyncChange, SyncLogEntry, SyncStreamEvent } from '@/types/sync'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||
}
|
||||
|
||||
let changes: SyncChange[]
|
||||
try {
|
||||
const body = await req.json()
|
||||
changes = body.changes
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Corps JSON invalide' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!Array.isArray(changes) || changes.length === 0) {
|
||||
return NextResponse.json({ error: 'Aucun changement à appliquer' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userId = (session.user as { id: string }).id
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (event: SyncStreamEvent) => {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
|
||||
}
|
||||
|
||||
const log: SyncLogEntry[] = []
|
||||
let created = 0, updated = 0, deactivated = 0, errors = 0
|
||||
const total = changes.filter((c) => c.type !== 'unchanged').length
|
||||
let current = 0
|
||||
|
||||
for (const change of changes) {
|
||||
if (change.type === 'unchanged') continue
|
||||
current++
|
||||
|
||||
try {
|
||||
if (change.type === 'create') {
|
||||
await createShopifyProduct(change.sheetProduct!)
|
||||
created++
|
||||
const msg = `✓ ${change.ref} créé`
|
||||
log.push({ ref: change.ref, action: 'create', status: 'success', message: msg })
|
||||
send({ type: 'progress', current, total, message: msg })
|
||||
|
||||
} else if (change.type === 'update') {
|
||||
const variantId = await getFirstVariantId(change.shopifyProduct!.shopifyId)
|
||||
await updateShopifyProduct(
|
||||
change.shopifyProduct!.shopifyId,
|
||||
change.sheetProduct!,
|
||||
variantId,
|
||||
)
|
||||
updated++
|
||||
const fields = change.changedFields?.map((f) => f.field).join(', ') ?? ''
|
||||
const msg = `✓ ${change.ref} mis à jour (${fields})`
|
||||
log.push({ ref: change.ref, action: 'update', status: 'success', message: msg })
|
||||
send({ type: 'progress', current, total, message: msg })
|
||||
|
||||
} else if (change.type === 'deactivate') {
|
||||
await deactivateShopifyProduct(change.shopifyProduct!.shopifyId)
|
||||
deactivated++
|
||||
const msg = `✓ ${change.ref} désactivé`
|
||||
log.push({ ref: change.ref, action: 'deactivate', status: 'success', message: msg })
|
||||
send({ type: 'progress', current, total, message: msg })
|
||||
}
|
||||
} catch (err) {
|
||||
errors++
|
||||
const msg = `✗ ${change.ref} : ${(err as Error).message}`
|
||||
log.push({ ref: change.ref, action: change.type as SyncLogEntry['action'], status: 'error', message: msg })
|
||||
send({ type: 'progress', current, total, message: msg })
|
||||
}
|
||||
}
|
||||
|
||||
const syncStatus = errors === 0 ? 'success' : errors === total ? 'error' : 'partial'
|
||||
try {
|
||||
await prisma.syncRun.create({
|
||||
data: {
|
||||
userId,
|
||||
status: syncStatus,
|
||||
created,
|
||||
updated,
|
||||
deactivated,
|
||||
errors,
|
||||
log: JSON.stringify(log),
|
||||
},
|
||||
})
|
||||
} catch (dbErr) {
|
||||
console.error('Erreur sauvegarde SyncRun:', dbErr)
|
||||
}
|
||||
|
||||
send({
|
||||
type: 'done',
|
||||
summary: { created, updated, deactivated, errors, status: syncStatus },
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { SyncHistoryEntry, SyncLogEntry } from '@/types/sync'
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
try {
|
||||
const runs = await prisma.syncRun.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
include: { user: { select: { name: true } } },
|
||||
})
|
||||
|
||||
const entries: SyncHistoryEntry[] = runs.map((run) => ({
|
||||
id: run.id,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
userName: run.user.name,
|
||||
status: run.status as SyncHistoryEntry['status'],
|
||||
created: run.created,
|
||||
updated: run.updated,
|
||||
deactivated: run.deactivated,
|
||||
errors: run.errors,
|
||||
log: (() => {
|
||||
try { return JSON.parse(run.log) as SyncLogEntry[] } catch { return [] }
|
||||
})(),
|
||||
}))
|
||||
|
||||
return NextResponse.json(entries)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readSheetProducts } from '@/lib/googleSheets'
|
||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||
import { computeDiff } from '@/lib/syncDiff'
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
try {
|
||||
const [sheetProducts, shopifyProducts] = await Promise.all([
|
||||
readSheetProducts(),
|
||||
fetchAllShopifyProducts(),
|
||||
])
|
||||
|
||||
const diff = computeDiff(sheetProducts, shopifyProducts)
|
||||
return NextResponse.json(diff)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import type { SyncChange, SyncChangeType, DiffResult } from '@/types/sync'
|
||||
|
||||
interface DiffTableProps {
|
||||
diff: DiffResult
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<SyncChangeType, { label: string; color: string; bg: string }> = {
|
||||
create: { label: '🟢 Nouveau', color: 'text-green-400', bg: 'bg-green-950/30' },
|
||||
update: { label: '🔵 Mis à jour', color: 'text-blue-400', bg: 'bg-blue-950/30' },
|
||||
deactivate: { label: '🔴 Désactivé', color: 'text-red-400', bg: 'bg-red-950/30' },
|
||||
unchanged: { label: '⚫ Inchangé', color: 'text-slate-500', bg: '' },
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function DiffTable({ diff }: DiffTableProps) {
|
||||
const [filter, setFilter] = useState<SyncChangeType | 'all'>('all')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const visible = diff.changes.filter((c) => c.type !== 'unchanged')
|
||||
const filtered = filter === 'all' ? visible : visible.filter((c) => c.type === filter)
|
||||
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Résumé */}
|
||||
<div className="flex gap-4 flex-wrap text-sm">
|
||||
{(['create', 'update', 'deactivate', 'unchanged'] as SyncChangeType[]).map((type) => (
|
||||
<span key={type} className={TYPE_CONFIG[type].color}>
|
||||
{TYPE_CONFIG[type].label} : {diff.summary[type]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(['all', 'create', 'update', 'deactivate'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => { setFilter(f); setPage(0) }}
|
||||
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
|
||||
filter === f
|
||||
? 'bg-indigo-600 border-indigo-500 text-white'
|
||||
: 'border-slate-700 text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Tous' : TYPE_CONFIG[f].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
{paginated.length === 0 ? (
|
||||
<p className="text-slate-600 text-sm py-4 text-center">Aucun changement dans cette catégorie.</p>
|
||||
) : (
|
||||
<div className="border border-slate-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-800 text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Ref</th>
|
||||
<th className="px-3 py-2 text-left">Type</th>
|
||||
<th className="px-3 py-2 text-left">Champs modifiés</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginated.map((change) => (
|
||||
<tr key={change.ref} className={`border-t border-slate-800 ${TYPE_CONFIG[change.type].bg}`}>
|
||||
<td className="px-3 py-2 font-mono text-slate-300">{change.ref}</td>
|
||||
<td className={`px-3 py-2 font-semibold ${TYPE_CONFIG[change.type].color}`}>
|
||||
{TYPE_CONFIG[change.type].label}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-400">
|
||||
{change.type === 'update' && change.changedFields?.map((f) => (
|
||||
<span key={f.field} className="inline-block mr-2">
|
||||
<span className="text-slate-500">{f.field} : </span>
|
||||
<span className="line-through text-red-400">{f.from}</span>
|
||||
<span className="text-slate-400"> → </span>
|
||||
<span className="text-green-400">{f.to}</span>
|
||||
</span>
|
||||
))}
|
||||
{change.type === 'create' && (
|
||||
<span className="text-green-400">{change.sheetProduct?.title}</span>
|
||||
)}
|
||||
{change.type === 'deactivate' && (
|
||||
<span className="text-red-400">{change.shopifyProduct?.title}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 text-xs bg-slate-800 rounded disabled:opacity-40 text-slate-300"
|
||||
>← Préc</button>
|
||||
<span className="text-xs text-slate-500 self-center">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1 text-xs bg-slate-800 rounded disabled:opacity-40 text-slate-300"
|
||||
>Suiv →</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import React from 'react'
|
||||
import type { SyncHistoryEntry } from '@/types/sync'
|
||||
|
||||
interface SyncHistoryProps {
|
||||
entries: SyncHistoryEntry[]
|
||||
}
|
||||
|
||||
const STATUS_STYLE = {
|
||||
success: 'text-green-400',
|
||||
partial: 'text-yellow-400',
|
||||
error: 'text-red-400',
|
||||
}
|
||||
|
||||
export function SyncHistory({ entries }: SyncHistoryProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-slate-600 text-sm text-center py-6">Aucune synchronisation effectuée.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-slate-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-800 text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Date</th>
|
||||
<th className="px-3 py-2 text-left">Utilisateur</th>
|
||||
<th className="px-3 py-2 text-right">Créés</th>
|
||||
<th className="px-3 py-2 text-right">MàJ</th>
|
||||
<th className="px-3 py-2 text-right">Désactivés</th>
|
||||
<th className="px-3 py-2 text-right">Erreurs</th>
|
||||
<th className="px-3 py-2 text-left">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<React.Fragment key={entry.id}>
|
||||
<tr
|
||||
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
||||
className="border-t border-slate-800 hover:bg-slate-800/50 cursor-pointer"
|
||||
>
|
||||
<td className="px-3 py-2 text-slate-400">
|
||||
{new Date(entry.createdAt).toLocaleString('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-300">{entry.userName}</td>
|
||||
<td className="px-3 py-2 text-right text-green-400">{entry.created}</td>
|
||||
<td className="px-3 py-2 text-right text-blue-400">{entry.updated}</td>
|
||||
<td className="px-3 py-2 text-right text-red-400">{entry.deactivated}</td>
|
||||
<td className="px-3 py-2 text-right text-yellow-400">{entry.errors || '—'}</td>
|
||||
<td className={`px-3 py-2 font-semibold ${STATUS_STYLE[entry.status]}`}>
|
||||
{entry.status}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedId === entry.id && (
|
||||
<tr className="border-t border-slate-800 bg-slate-950">
|
||||
<td colSpan={7} className="px-3 py-3">
|
||||
<div className="font-mono text-xs max-h-32 overflow-y-auto space-y-0.5">
|
||||
{entry.log.map((l, i) => (
|
||||
<p key={i} className={l.status === 'error' ? 'text-red-400' : 'text-slate-400'}>
|
||||
{l.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
interface SyncProgressProps {
|
||||
log: string[]
|
||||
progress: { current: number; total: number } | null
|
||||
summary: { created: number; updated: number; deactivated: number; errors: number; status: string } | null
|
||||
}
|
||||
|
||||
export function SyncProgress({ log, progress, summary }: SyncProgressProps) {
|
||||
const percent = progress && progress.total > 0
|
||||
? Math.round((progress.current / progress.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Barre de progression */}
|
||||
{progress && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-slate-400 mb-1">
|
||||
<span>{progress.current} / {progress.total} produits</span>
|
||||
<span>{percent}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-2 bg-indigo-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log */}
|
||||
<div className="bg-slate-950 rounded-xl border border-slate-800 p-3 h-48 overflow-y-auto font-mono text-xs">
|
||||
{log.length === 0 && (
|
||||
<p className="text-slate-600">Démarrage de la synchronisation…</p>
|
||||
)}
|
||||
{log.map((line, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={line.startsWith('✗') ? 'text-red-400' : 'text-green-400'}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Résultat final */}
|
||||
{summary && (
|
||||
<div className={`rounded-xl p-4 text-sm font-semibold border ${
|
||||
summary.status === 'success'
|
||||
? 'bg-green-950/30 border-green-800 text-green-300'
|
||||
: summary.status === 'partial'
|
||||
? 'bg-yellow-950/30 border-yellow-800 text-yellow-300'
|
||||
: 'bg-red-950/30 border-red-800 text-red-300'
|
||||
}`}>
|
||||
{summary.status === 'success' && '✅ '}
|
||||
{summary.status === 'partial' && '⚠️ '}
|
||||
{summary.status === 'error' && '❌ '}
|
||||
Sync terminée — {summary.created} créés, {summary.updated} mis à jour,{' '}
|
||||
{summary.deactivated} désactivés, {summary.errors} erreur{summary.errors > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, label: 'Analyse Sheets' },
|
||||
{ id: 2, label: 'Aperçu diff' },
|
||||
{ id: 3, label: 'Confirmation' },
|
||||
{ id: 4, label: 'Synchronisation' },
|
||||
]
|
||||
|
||||
interface SyncStepperProps {
|
||||
currentStep: number // 1–4
|
||||
}
|
||||
|
||||
export function SyncStepper({ currentStep }: SyncStepperProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-0">
|
||||
{STEPS.map((step, i) => {
|
||||
const done = currentStep > step.id
|
||||
const active = currentStep === step.id
|
||||
return (
|
||||
<div key={step.id} className="flex items-center">
|
||||
{i > 0 && (
|
||||
<div className={`h-0.5 w-12 ${done ? 'bg-indigo-500' : 'bg-slate-700'}`} />
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-colors ${
|
||||
done
|
||||
? 'bg-indigo-500 text-white'
|
||||
: active
|
||||
? 'bg-indigo-600 text-white ring-2 ring-indigo-400'
|
||||
: 'bg-slate-800 text-slate-500 border border-slate-700'
|
||||
}`}
|
||||
>
|
||||
{done ? '✓' : step.id}
|
||||
</div>
|
||||
<span className={`text-xs ${active ? 'text-slate-200 font-semibold' : 'text-slate-500'}`}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { SyncProduct } from '@/types/sync'
|
||||
|
||||
const API_VERSION = 'v4'
|
||||
|
||||
function normalizeHandle(ref: string): string {
|
||||
return ref
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse des lignes brutes Google Sheets en SyncProduct[].
|
||||
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
|
||||
* Exporté pour les tests unitaires.
|
||||
*/
|
||||
export function parseSheetRows(rows: string[][]): SyncProduct[] {
|
||||
return rows
|
||||
.map((row): SyncProduct | null => {
|
||||
const ref = row[0]?.trim()
|
||||
if (!ref) return null
|
||||
|
||||
const rawPrice = (row[3] ?? '').replace(',', '.').trim()
|
||||
const price = parseFloat(rawPrice || '0')
|
||||
|
||||
return {
|
||||
ref,
|
||||
handle: normalizeHandle(ref),
|
||||
title: row[1]?.trim() || ref,
|
||||
description: row[2]?.trim() || '',
|
||||
price: isNaN(price) ? '0.00' : price.toFixed(2),
|
||||
stock: parseInt(row[4] ?? '0', 10) || 0,
|
||||
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active',
|
||||
}
|
||||
})
|
||||
.filter((p): p is SyncProduct => p !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit tous les produits depuis Google Sheets via l'API v4.
|
||||
* Utilise un service account (GOOGLE_SERVICE_ACCOUNT_EMAIL + GOOGLE_PRIVATE_KEY).
|
||||
*/
|
||||
export async function readSheetProducts(): Promise<SyncProduct[]> {
|
||||
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
|
||||
const key = process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n')
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
const sheetName = process.env.GOOGLE_SHEETS_TAB ?? 'Produits'
|
||||
|
||||
if (!email || !key || !sheetId) {
|
||||
throw new Error('Variables GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_PRIVATE_KEY et GOOGLE_SHEETS_ID requises')
|
||||
}
|
||||
|
||||
const { google } = await import('googleapis')
|
||||
|
||||
const auth = new google.auth.GoogleAuth({
|
||||
credentials: { client_email: email, private_key: key },
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
|
||||
})
|
||||
|
||||
const sheets = google.sheets({ version: API_VERSION, auth })
|
||||
|
||||
const response = await sheets.spreadsheets.values.get({
|
||||
spreadsheetId: sheetId,
|
||||
range: `${sheetName}!A2:F`, // A2 = skip header row
|
||||
})
|
||||
|
||||
const rows = (response.data.values ?? []) as string[][]
|
||||
return parseSheetRows(rows)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { ShopifyProductFull, SyncProduct } from '@/types/sync'
|
||||
|
||||
const API_VERSION = '2024-01'
|
||||
|
||||
function getConfig() {
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||
if (!domain || !token) {
|
||||
throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||
}
|
||||
return {
|
||||
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
||||
headers: {
|
||||
'X-Shopify-Access-Token': token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface ShopifyVariant {
|
||||
id: number
|
||||
price: string
|
||||
sku: string
|
||||
inventory_quantity: number
|
||||
}
|
||||
|
||||
interface ShopifyAPIProduct {
|
||||
id: number
|
||||
title: string
|
||||
handle: string
|
||||
body_html: string
|
||||
status: string
|
||||
variants: ShopifyVariant[]
|
||||
}
|
||||
|
||||
function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
|
||||
return {
|
||||
shopifyId: String(p.id),
|
||||
ref: p.variants[0]?.sku || p.handle,
|
||||
handle: p.handle,
|
||||
title: p.title,
|
||||
description: p.body_html ?? '',
|
||||
price: p.variants[0]?.price ?? '0.00',
|
||||
stock: p.variants[0]?.inventory_quantity ?? 0,
|
||||
status: p.status === 'active' ? 'active' : 'draft',
|
||||
}
|
||||
}
|
||||
|
||||
function extractNextUrl(linkHeader: string | null): string | null {
|
||||
if (!linkHeader) return null
|
||||
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les produits Shopify (actifs + drafts), en paginant.
|
||||
* Limite : 250 par page via cursor-based pagination.
|
||||
*/
|
||||
export async function fetchAllShopifyProducts(): Promise<ShopifyProductFull[]> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
const all: ShopifyProductFull[] = []
|
||||
|
||||
let url: string | null =
|
||||
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=any`
|
||||
|
||||
while (url) {
|
||||
const res = await fetch(url, { headers })
|
||||
if (!res.ok) throw new Error(`Shopify fetch error: ${res.status}`)
|
||||
const data = await res.json()
|
||||
all.push(...(data.products as ShopifyAPIProduct[]).map(toSyncProduct))
|
||||
url = extractNextUrl(res.headers.get('Link'))
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau produit Shopify depuis un SyncProduct.
|
||||
* Retourne l'ID Shopify créé.
|
||||
*/
|
||||
export async function createShopifyProduct(product: SyncProduct): Promise<string> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
const body = {
|
||||
product: {
|
||||
title: product.title,
|
||||
body_html: product.description,
|
||||
handle: product.handle,
|
||||
status: product.status,
|
||||
variants: [{ price: product.price, sku: product.ref }],
|
||||
},
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/products.json`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`Shopify createProduct error: ${res.status} — ${err}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return String(data.product.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour un produit Shopify existant (titre, description, prix, statut).
|
||||
*/
|
||||
export async function updateShopifyProduct(
|
||||
shopifyId: string,
|
||||
product: SyncProduct,
|
||||
variantId: string,
|
||||
): Promise<void> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
const body = {
|
||||
product: {
|
||||
id: shopifyId,
|
||||
title: product.title,
|
||||
body_html: product.description,
|
||||
status: product.status,
|
||||
variants: [{ id: variantId, price: product.price }],
|
||||
},
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`Shopify updateProduct error: ${res.status} — ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passe un produit Shopify en draft (désactivation — jamais de suppression).
|
||||
*/
|
||||
export async function deactivateShopifyProduct(shopifyId: string): Promise<void> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
|
||||
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ product: { id: shopifyId, status: 'draft' } }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`Shopify deactivateProduct error: ${res.status} — ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'ID de la première variante d'un produit Shopify.
|
||||
* Nécessaire pour mettre à jour le prix (update variant).
|
||||
*/
|
||||
export async function getFirstVariantId(shopifyId: string): Promise<string> {
|
||||
const { baseUrl, headers } = getConfig()
|
||||
const res = await fetch(`${baseUrl}/products/${shopifyId}/variants.json?limit=1`, { headers })
|
||||
if (!res.ok) throw new Error(`Shopify getVariants error: ${res.status}`)
|
||||
const data = await res.json()
|
||||
const variant = data.variants?.[0]
|
||||
if (!variant) throw new Error(`Aucune variante pour le produit ${shopifyId}`)
|
||||
return String(variant.id)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SyncProduct, ShopifyProductFull, SyncChange, DiffResult } from '@/types/sync'
|
||||
|
||||
const COMPARABLE_FIELDS = ['title', 'description', 'price', 'status'] as const
|
||||
|
||||
function getChangedFields(
|
||||
sheet: SyncProduct,
|
||||
shopify: ShopifyProductFull,
|
||||
): Array<{ field: string; from: string; to: string }> {
|
||||
return COMPARABLE_FIELDS
|
||||
.filter((field) => sheet[field] !== shopify[field])
|
||||
.map((field) => ({
|
||||
field,
|
||||
from: String(shopify[field]),
|
||||
to: String(sheet[field]),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le diff entre les produits Sheets et Shopify.
|
||||
* Matching par handle (ref normalisée).
|
||||
*/
|
||||
export function computeDiff(
|
||||
sheetProducts: SyncProduct[],
|
||||
shopifyProducts: ShopifyProductFull[],
|
||||
): DiffResult {
|
||||
const shopifyMap = new Map(shopifyProducts.map((p) => [p.handle, p]))
|
||||
const sheetHandles = new Set(sheetProducts.map((p) => p.handle))
|
||||
|
||||
const changes: SyncChange[] = []
|
||||
|
||||
for (const sheet of sheetProducts) {
|
||||
const shopify = shopifyMap.get(sheet.handle)
|
||||
if (!shopify) {
|
||||
changes.push({ type: 'create', ref: sheet.ref, sheetProduct: sheet })
|
||||
} else {
|
||||
const changedFields = getChangedFields(sheet, shopify)
|
||||
if (changedFields.length > 0) {
|
||||
changes.push({
|
||||
type: 'update',
|
||||
ref: sheet.ref,
|
||||
sheetProduct: sheet,
|
||||
shopifyProduct: shopify,
|
||||
changedFields,
|
||||
})
|
||||
} else {
|
||||
changes.push({ type: 'unchanged', ref: sheet.ref, sheetProduct: sheet, shopifyProduct: shopify })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const shopify of shopifyProducts) {
|
||||
if (shopify.status === 'active' && !sheetHandles.has(shopify.handle)) {
|
||||
changes.push({ type: 'deactivate', ref: shopify.ref, shopifyProduct: shopify })
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
create: changes.filter((c) => c.type === 'create').length,
|
||||
update: changes.filter((c) => c.type === 'update').length,
|
||||
deactivate: changes.filter((c) => c.type === 'deactivate').length,
|
||||
unchanged: changes.filter((c) => c.type === 'unchanged').length,
|
||||
}
|
||||
|
||||
return { changes, summary }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export type SyncChangeType = 'create' | 'update' | 'deactivate' | 'unchanged'
|
||||
|
||||
/** Produit normalisé depuis Google Sheets */
|
||||
export interface SyncProduct {
|
||||
ref: string // ex: "REF-1042" (colonne A telle quelle)
|
||||
handle: string // ref normalisée en handle Shopify: "ref-1042"
|
||||
title: string // colonne B
|
||||
description: string // colonne C (texte brut, stocké en body_html)
|
||||
price: string // colonne D — "29.99" (2 décimales)
|
||||
stock: number // colonne E — info affichage uniquement (non synchronisé)
|
||||
status: 'active' | 'draft' // colonne F : "inactif"/"inactif" → draft, tout autre → active
|
||||
}
|
||||
|
||||
/** Produit Shopify enrichi pour la comparaison */
|
||||
export interface ShopifyProductFull extends SyncProduct {
|
||||
shopifyId: string // ID numérique Shopify (string)
|
||||
}
|
||||
|
||||
/** Un changement unitaire calculé par computeDiff */
|
||||
export interface SyncChange {
|
||||
type: SyncChangeType
|
||||
ref: string
|
||||
sheetProduct?: SyncProduct // présent pour create + update
|
||||
shopifyProduct?: ShopifyProductFull // présent pour update + deactivate
|
||||
changedFields?: Array<{ // présent pour update uniquement
|
||||
field: string
|
||||
from: string
|
||||
to: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** Résultat complet du diff */
|
||||
export interface DiffResult {
|
||||
changes: SyncChange[]
|
||||
summary: {
|
||||
create: number
|
||||
update: number
|
||||
deactivate: number
|
||||
unchanged: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Une entrée du log d'exécution */
|
||||
export interface SyncLogEntry {
|
||||
ref: string
|
||||
action: 'create' | 'update' | 'deactivate'
|
||||
status: 'success' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Un événement NDJSON streamé par /api/sync/execute */
|
||||
export type SyncStreamEvent =
|
||||
| { type: 'progress'; current: number; total: number; message: string }
|
||||
| { type: 'done'; summary: { created: number; updated: number; deactivated: number; errors: number; status: 'success' | 'partial' | 'error' } }
|
||||
|
||||
/** Une entrée d'historique (depuis SyncRun Prisma) */
|
||||
export interface SyncHistoryEntry {
|
||||
id: string
|
||||
createdAt: string // ISO string
|
||||
userName: string
|
||||
status: 'success' | 'partial' | 'error'
|
||||
created: number
|
||||
updated: number
|
||||
deactivated: number
|
||||
errors: number
|
||||
log: SyncLogEntry[]
|
||||
}
|
||||
Reference in New Issue
Block a user