Compare commits
9 commits
6bb9bd40dd
...
a06863562c
| Author | SHA1 | Date | |
|---|---|---|---|
| a06863562c | |||
| 3bab4e4c56 | |||
| 5d0ce42592 | |||
| 96334a595f | |||
| 2f32cb7deb | |||
| 7b45659a50 | |||
| c065835d7a | |||
| d2f0d9767b | |||
| 6a37e8d1f0 |
23 changed files with 5662 additions and 740 deletions
337
backend/package-lock.json
generated
337
backend/package-lock.json
generated
|
|
@ -1,337 +0,0 @@
|
|||
{
|
||||
"name": "rocks-2many-backend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
|
||||
},
|
||||
"asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"axios": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
|
||||
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
||||
},
|
||||
"busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"requires": {
|
||||
"streamsearch": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"requires": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"requires": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"concat-stream": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||
"requires": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^2.2.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||
},
|
||||
"csv-parser": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
|
||||
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA=="
|
||||
},
|
||||
"csv-writer": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-writer/-/csv-writer-1.6.0.tgz",
|
||||
"integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g=="
|
||||
},
|
||||
"delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
|
||||
},
|
||||
"dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"requires": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
|
||||
},
|
||||
"es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
|
||||
},
|
||||
"es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"requires": {
|
||||
"es-errors": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"requires": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="
|
||||
},
|
||||
"form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"requires": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"mime-types": "^2.1.12"
|
||||
}
|
||||
},
|
||||
"function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
|
||||
},
|
||||
"get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"requires": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"requires": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
|
||||
},
|
||||
"has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
|
||||
},
|
||||
"has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"requires": {
|
||||
"has-symbols": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||
},
|
||||
"math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
|
||||
},
|
||||
"media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
|
||||
},
|
||||
"mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
|
||||
},
|
||||
"mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"requires": {
|
||||
"mime-db": "1.52.0"
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"requires": {
|
||||
"minimist": "^1.2.6"
|
||||
}
|
||||
},
|
||||
"multer": {
|
||||
"version": "1.4.5-lts.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
||||
"requires": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.0.0",
|
||||
"concat-stream": "^1.5.2",
|
||||
"mkdirp": "^0.5.4",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.4",
|
||||
"xtend": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
|
||||
},
|
||||
"process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||
},
|
||||
"proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"requires": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"slugify": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz",
|
||||
"integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="
|
||||
},
|
||||
"streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
|
||||
},
|
||||
"string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"requires": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"requires": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
}
|
||||
},
|
||||
"typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
|
||||
},
|
||||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,14 +9,18 @@
|
|||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/client-sqs": "^3.799.0",
|
||||
"axios": "^1.9.0",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
"csv-writer": "^1.6.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"ioredis": "^5.6.1",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"multer-s3": "^3.0.1",
|
||||
"nodemailer": "^6.9.1",
|
||||
"pg": "^8.10.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 684 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 684 KiB |
|
|
@ -3,6 +3,7 @@ const dotenv = require('dotenv');
|
|||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
|
||||
const config = {
|
||||
// Server configuration
|
||||
port: process.env.PORT || 4000,
|
||||
|
|
@ -15,7 +16,9 @@ const config = {
|
|||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'ecommerce',
|
||||
port: process.env.DB_PORT || 5432
|
||||
port: process.env.DB_PORT || 5432,
|
||||
readHost: process.env.DB_READ_HOST || process.env.DB_HOST || 'db',
|
||||
maxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '20'),
|
||||
},
|
||||
|
||||
// Email configuration
|
||||
|
|
@ -65,6 +68,16 @@ const config = {
|
|||
protocol: process.env.ENVIRONMENT === 'prod' ? 'https' : 'http',
|
||||
apiDomain: process.env.ENVIRONMENT === 'prod' ? (process.env.API_PROD_URL || 'api.rocks.2many.ca') : 'localhost:4000',
|
||||
analyticApiKey: process.env.SITE_ANALYTIC_API || '',
|
||||
deployment: process.env.DEPLOYMENT_MODE || 'self-hosted',
|
||||
redisHost: process.env.REDIS_HOST || '',
|
||||
redisTLS: process.env.REDIS_TLS || '',
|
||||
awsRegion: process.env.AWS_REGION || '',
|
||||
awsS3Bucket: process.env.S3_BUCKET || '',
|
||||
cdnDomain: process.env.CDN_DOMAIN || '',
|
||||
awsQueueUrl: process.env.SQS_QUEUE_URL || '',
|
||||
sessionSecret: process.env.SESSION_SECRET || '',
|
||||
redisPort: process.env.REDIS_PORT || '',
|
||||
redisPassword: process.env.REDIS_PASSWORD || ''
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -146,7 +159,6 @@ config.updateFromDatabase = (settings) => {
|
|||
// Update carriers allowed
|
||||
if (carriersAllowed && carriersAllowed.value) config.shipping.carriersAllowed = carriersAllowed.value.split(',');
|
||||
}
|
||||
|
||||
// Update site settings if they exist in DB
|
||||
const siteSettings = settings.filter(s => s.category === 'site');
|
||||
if (siteSettings.length > 0) {
|
||||
|
|
@ -154,6 +166,33 @@ config.updateFromDatabase = (settings) => {
|
|||
const siteProtocol = siteSettings.find(s => s.key === 'site_protocol');
|
||||
const siteApiDomain = siteSettings.find(s => s.key === 'site_api_domain');
|
||||
const analyticApiKey = siteSettings.find(s => s.key === 'site_analytics_api_key');
|
||||
const redisHost = siteSettings.find(s => s.key === 'site_redis_host');
|
||||
const redisTLS = siteSettings.find(s => s.key === 'site_redis_tls');
|
||||
const awsRegion = siteSettings.find(s => s.key === 'site_aws_region');
|
||||
const awsS3Bucket = siteSettings.find(s => s.key === 'site_aws_s3_bucket');
|
||||
const cdnDomain = siteSettings.find(s => s.key === 'site_cdn_domain');
|
||||
const deployment = siteSettings.find(s => s.key === 'site_deployment');
|
||||
const awsQueueUrl = siteSettings.find(s => s.key === 'site_aws_queue_url');
|
||||
const readHost = siteSettings.find(s => s.key === 'site_read_host');
|
||||
const maxConnections = siteSettings.find(s => s.key === 'site_db_max_connections');
|
||||
const sessionSecret = siteSettings.find(s => s.key === 'site_session_secret');
|
||||
const redisPort = siteSettings.find(s => s.key === 'site_redis_port');
|
||||
const redisPassword = siteSettings.find(s => s.key === 'site_redis_password');
|
||||
|
||||
if (redisHost && redisHost.value) config.site.redisHost = redisHost.value;
|
||||
if (redisTLS && redisTLS.value) config.site.redisTLS = redisHost.value;
|
||||
if (awsRegion && awsRegion.value) config.site.awsRegion = awsRegion.value;
|
||||
if (awsS3Bucket && awsS3Bucket.value) config.site.awsS3Bucket = awsS3Bucket.value;
|
||||
if (cdnDomain && cdnDomain.value) config.site.cdnDomain = cdnDomain.value;
|
||||
if (deployment && deployment.value) config.site.deployment = deployment.value;
|
||||
if (awsQueueUrl && awsQueueUrl.value) config.site.awsQueueUrl = awsQueueUrl.value;
|
||||
if (readHost && readHost.value) config.db.readHost = readHost.value;
|
||||
if (maxConnections && maxConnections.value) config.db.maxConnections = maxConnections.value;
|
||||
if (sessionSecret && sessionSecret.value) config.site.sessionSecret = sessionSecret.value;
|
||||
if (redisPort && redisPort.value) config.site.redisPort = redisPort.value;
|
||||
if (redisPassword && redisPassword.value) config.site.redisPassword = redisPassword.value;
|
||||
|
||||
|
||||
|
||||
if (siteDomain && siteDomain.value) config.site.domain = siteDomain.value;
|
||||
if (siteProtocol && siteProtocol.value) config.site.protocol = siteProtocol.value;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,73 @@
|
|||
const { Pool } = require('pg');
|
||||
const config = require('../config')
|
||||
|
||||
// Create a pool instance
|
||||
const pool = new Pool({
|
||||
let writePool, readPool;
|
||||
|
||||
// Always create write pool
|
||||
writePool = new Pool({
|
||||
user: config.db.user,
|
||||
password: config.db.password,
|
||||
host: config.db.host,
|
||||
port: config.db.port,
|
||||
database: config.db.database
|
||||
database: config.db.database,
|
||||
max: config.db.maxConnections,
|
||||
});
|
||||
|
||||
// Helper function for running queries
|
||||
const query = async (text, params) => {
|
||||
// Create read pool only in cloud mode
|
||||
if (config.site.deployment === 'cloud') {
|
||||
readPool = new Pool({
|
||||
user: config.db.user,
|
||||
password: config.db.password,
|
||||
host: config.db.readHost,
|
||||
port: config.db.port,
|
||||
database: config.db.database,
|
||||
max: config.db.maxConnections,
|
||||
});
|
||||
} else {
|
||||
// In self-hosted mode, use the same pool for reads and writes
|
||||
readPool = writePool;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Helper function that automatically routes queries
|
||||
const query = async (text, params, options = {}) => {
|
||||
const { forceWrite = false } = options;
|
||||
|
||||
const isWrite = forceWrite ||
|
||||
text.trim().toUpperCase().startsWith('INSERT') ||
|
||||
text.trim().toUpperCase().startsWith('UPDATE') ||
|
||||
text.trim().toUpperCase().startsWith('DELETE') ||
|
||||
text.trim().toUpperCase().includes('FOR UPDATE');
|
||||
|
||||
const pool = isWrite ? writePool : readPool;
|
||||
const start = Date.now();
|
||||
const res = await pool.query(text, params);
|
||||
const duration = Date.now() - start;
|
||||
console.log('Executed query', { text, duration, rows: res.rowCount });
|
||||
|
||||
if (config.site.deployment === 'cloud') {
|
||||
console.log('Executed query', {
|
||||
text,
|
||||
duration,
|
||||
rows: res.rowCount,
|
||||
pool: isWrite ? 'write' : 'read'
|
||||
});
|
||||
} else {
|
||||
console.log('Executed query', { text, duration, rows: res.rowCount });
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
query,
|
||||
pool
|
||||
};
|
||||
|
||||
// Old Query function
|
||||
// const query = async (text, params) => {
|
||||
// const start = Date.now();
|
||||
// const res = await pool.query(text, params);
|
||||
// const duration = Date.now() - start;
|
||||
// console.log('Executed query', { text, duration, rows: res.rowCount });
|
||||
// return res;
|
||||
// };
|
||||
|
||||
|
||||
module.exports = { query, pool: writePool };
|
||||
|
|
@ -13,6 +13,7 @@ const fs = require('fs');
|
|||
// services
|
||||
const notificationService = require('./services/notificationService');
|
||||
const emailService = require('./services/emailService');
|
||||
const storageService = require('./services/storageService');
|
||||
|
||||
// routes
|
||||
const stripePaymentRoutes = require('./routes/stripePayment');
|
||||
|
|
@ -59,45 +60,46 @@ if (!fs.existsSync(blogImagesDir)) {
|
|||
}
|
||||
|
||||
// Configure storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
// Determine destination based on upload type
|
||||
if (req.originalUrl.includes('/product')) {
|
||||
cb(null, productImagesDir);
|
||||
} else {
|
||||
cb(null, uploadsDir);
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Create unique filename with original extension
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const fileExt = path.extname(file.originalname);
|
||||
const safeName = path.basename(file.originalname, fileExt)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-');
|
||||
// const storage = multer.diskStorage({
|
||||
// destination: (req, file, cb) => {
|
||||
// // Determine destination based on upload type
|
||||
// if (req.originalUrl.includes('/product')) {
|
||||
// cb(null, productImagesDir);
|
||||
// } else {
|
||||
// cb(null, uploadsDir);
|
||||
// }
|
||||
// },
|
||||
// filename: (req, file, cb) => {
|
||||
// // Create unique filename with original extension
|
||||
// const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
// const fileExt = path.extname(file.originalname);
|
||||
// const safeName = path.basename(file.originalname, fileExt)
|
||||
// .toLowerCase()
|
||||
// .replace(/[^a-z0-9]/g, '-');
|
||||
|
||||
cb(null, `${safeName}-${uniqueSuffix}${fileExt}`);
|
||||
}
|
||||
});
|
||||
// cb(null, `${safeName}-${uniqueSuffix}${fileExt}`);
|
||||
// }
|
||||
// });
|
||||
|
||||
// File filter to only allow images
|
||||
const fileFilter = (req, file, cb) => {
|
||||
// Accept only image files
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed!'), false);
|
||||
}
|
||||
};
|
||||
// // File filter to only allow images
|
||||
// const fileFilter = (req, file, cb) => {
|
||||
// // Accept only image files
|
||||
// if (file.mimetype.startsWith('image/')) {
|
||||
// cb(null, true);
|
||||
// } else {
|
||||
// cb(new Error('Only image files are allowed!'), false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// Create the multer instance
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||
}
|
||||
});
|
||||
// const upload = multer({
|
||||
// storage,
|
||||
// fileFilter,
|
||||
// limits: {
|
||||
// fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||
// }
|
||||
// });
|
||||
const upload = storageService.getUploadMiddleware();
|
||||
|
||||
pool.connect()
|
||||
.then(async () => {
|
||||
|
|
@ -197,11 +199,16 @@ app.post('/api/image/upload', upload.single('image'), (req, res) => {
|
|||
message: 'No image file provided'
|
||||
});
|
||||
}
|
||||
const imagePath = req.file.path ? `/uploads/${req.file.filename}` : req.file.location;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
imagePath: `/uploads/${req.file.filename}`
|
||||
imagePath: storageService.getImageUrl(imagePath)
|
||||
});
|
||||
// res.json({
|
||||
// success: true,
|
||||
// imagePath: `/uploads/${req.file.filename}`
|
||||
// });
|
||||
});
|
||||
|
||||
app.get('/api/public-file/:filename', (req, res) => {
|
||||
|
|
@ -249,13 +256,22 @@ app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('
|
|||
}
|
||||
|
||||
// Get the relative path to the image
|
||||
const imagePath = `/uploads/products/${req.file.filename}`;
|
||||
const imagePath = req.file.path ?
|
||||
`/uploads/products/${req.file.filename}` :
|
||||
req.file.location;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
imagePath,
|
||||
filename: req.file.filename
|
||||
imagePath: storageService.getImageUrl(imagePath),
|
||||
filename: req.file.filename || path.basename(req.file.location)
|
||||
});
|
||||
// const imagePath = `/uploads/products/${req.file.filename}`;
|
||||
|
||||
// res.json({
|
||||
// success: true,
|
||||
// imagePath,
|
||||
// filename: req.file.filename
|
||||
// });
|
||||
});
|
||||
|
||||
// Upload multiple product images (admin only)
|
||||
|
|
@ -269,15 +285,30 @@ app.post('/api/image/products', adminAuthMiddleware(pool, query), upload.array('
|
|||
}
|
||||
|
||||
// Get the relative paths to the images
|
||||
const imagePaths = req.files.map(file => ({
|
||||
imagePath: `/uploads/products/${file.filename}`,
|
||||
filename: file.filename
|
||||
}));
|
||||
const imagePaths = req.files.map(file => {
|
||||
const imagePath = file.path ?
|
||||
`/uploads/products/${file.filename}` :
|
||||
file.location;
|
||||
|
||||
return {
|
||||
imagePath: storageService.getImageUrl(imagePath),
|
||||
filename: file.filename || path.basename(file.location)
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
images: imagePaths
|
||||
});
|
||||
// const imagePaths = req.files.map(file => ({
|
||||
// imagePath: `/uploads/products/${file.filename}`,
|
||||
// filename: file.filename
|
||||
// }));
|
||||
|
||||
// res.json({
|
||||
// success: true,
|
||||
// images: imagePaths
|
||||
// });
|
||||
});
|
||||
|
||||
// Delete product image (admin only)
|
||||
|
|
@ -293,18 +324,26 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re
|
|||
});
|
||||
}
|
||||
|
||||
const filePath = path.join(__dirname, '../public/uploads/products', filename);
|
||||
if (config.site.deployment === 'cloud' && config.site.awsS3Bucket) {
|
||||
// Implementation for S3 deletion would go here
|
||||
// For now, we'll just log and continue
|
||||
console.log('S3 file deletion not implemented yet');
|
||||
} else {
|
||||
// Delete from local filesystem
|
||||
const filePath = path.join(__dirname, '../public/uploads/products', filename);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Image not found'
|
||||
});
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Image not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -346,6 +385,7 @@ app.use((err, req, res, next) => {
|
|||
// Start server
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on port ${port} in ${config.environment} environment`);
|
||||
console.log(`Deployment mode: ${config.site.deployment || 'self-hosted'}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
|
@ -68,7 +68,6 @@ module.exports = (pool, query) => {
|
|||
router.post('/login-request', async (req, res, next) => {
|
||||
const { email } = req.body;
|
||||
console.log('/login-request')
|
||||
console.log(JSON.stringify(config, null, 4))
|
||||
try {
|
||||
// Check if user exists
|
||||
const userResult = await query(
|
||||
|
|
|
|||
|
|
@ -6,17 +6,19 @@ const csv = require('csv-parser');
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createObjectCsvWriter } = require('csv-writer');
|
||||
const storageService = require('../services/storageService');
|
||||
|
||||
// Configure multer for file uploads
|
||||
const upload = multer({
|
||||
dest: path.join(__dirname, '../uploads/temp'),
|
||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||
});
|
||||
// const upload = multer({
|
||||
// dest: path.join(__dirname, '../uploads/temp'),
|
||||
// limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||
// });
|
||||
|
||||
module.exports = (pool, query, authMiddleware) => {
|
||||
// Apply authentication middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
const upload = storageService.getUploadMiddleware();
|
||||
/**
|
||||
* Get all mailing lists
|
||||
* GET /api/admin/mailing-lists
|
||||
|
|
@ -512,16 +514,45 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
|
||||
// Process the CSV file
|
||||
const results = [];
|
||||
// const processFile = () => {
|
||||
// return new Promise((resolve, reject) => {
|
||||
// fs.createReadStream(file.path)
|
||||
// .pipe(csv())
|
||||
// .on('data', (data) => results.push(data))
|
||||
// .on('end', () => {
|
||||
// // Clean up temp file
|
||||
// fs.unlink(file.path, (err) => {
|
||||
// if (err) console.error('Error deleting temp file:', err);
|
||||
// });
|
||||
// resolve(results);
|
||||
// })
|
||||
// .on('error', reject);
|
||||
// });
|
||||
// };
|
||||
const processFile = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// For S3 uploads, file.path won't exist, but file.location will
|
||||
const filePath = file.path || file.location;
|
||||
|
||||
// If S3 storage, we need to download the file first
|
||||
if (!file.path && file.location) {
|
||||
// Implementation for S3 file would go here
|
||||
// For now, we'll reject as this would need a different approach
|
||||
reject(new Error('S3 file processing not implemented'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Local file processing
|
||||
fs.createReadStream(file.path)
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', () => {
|
||||
// Clean up temp file
|
||||
fs.unlink(file.path, (err) => {
|
||||
if (err) console.error('Error deleting temp file:', err);
|
||||
});
|
||||
// Clean up temp file - only for local storage
|
||||
if (file.path) {
|
||||
fs.unlink(file.path, (err) => {
|
||||
if (err) console.error('Error deleting temp file:', err);
|
||||
});
|
||||
}
|
||||
resolve(results);
|
||||
})
|
||||
.on('error', reject);
|
||||
|
|
@ -683,6 +714,10 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
// Create a temp file for CSV
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const fileName = `subscribers-${listName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.csv`;
|
||||
const tempDir = path.join(__dirname, '../uploads/temp');
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
const filePath = path.join(__dirname, '../uploads/temp', fileName);
|
||||
|
||||
// Create CSV writer
|
||||
|
|
|
|||
|
|
@ -9,150 +9,79 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
// Create a new product with multiple images
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
categoryName,
|
||||
price,
|
||||
stockQuantity,
|
||||
weightGrams,
|
||||
lengthCm,
|
||||
widthCm,
|
||||
heightCm,
|
||||
origin,
|
||||
age,
|
||||
materialType,
|
||||
color,
|
||||
images,
|
||||
tags
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !description || !categoryName || !price || !stockQuantity) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Required fields missing: name, description, categoryName, price, and stockQuantity are mandatory'
|
||||
// Check for array
|
||||
let products = [];
|
||||
if (req.body.products) {
|
||||
products = req.body.products;
|
||||
} else {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
categoryName,
|
||||
price,
|
||||
stockQuantity,
|
||||
weightGrams,
|
||||
lengthCm,
|
||||
widthCm,
|
||||
heightCm,
|
||||
stockNotification,
|
||||
origin,
|
||||
age,
|
||||
materialType,
|
||||
color,
|
||||
images,
|
||||
tags
|
||||
} = req.body;
|
||||
|
||||
products.push({
|
||||
name,
|
||||
description,
|
||||
categoryName,
|
||||
price,
|
||||
stockQuantity,
|
||||
weightGrams,
|
||||
lengthCm,
|
||||
widthCm,
|
||||
heightCm,
|
||||
stockNotification,
|
||||
origin,
|
||||
age,
|
||||
materialType,
|
||||
color,
|
||||
images: images || [],
|
||||
tags
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare promises array for concurrent execution
|
||||
const productPromises = products.map(product => createProduct(product, pool));
|
||||
|
||||
// Begin transaction
|
||||
const client = await pool.connect();
|
||||
// Execute all product creation promises concurrently
|
||||
const results = await Promise.all(productPromises);
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Get category ID by name
|
||||
const categoryResult = await client.query(
|
||||
'SELECT id FROM product_categories WHERE name = $1',
|
||||
[categoryName]
|
||||
);
|
||||
|
||||
if (categoryResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: `Category "${categoryName}" not found`
|
||||
});
|
||||
}
|
||||
|
||||
const categoryId = categoryResult.rows[0].id;
|
||||
|
||||
// Create product
|
||||
const productId = uuidv4();
|
||||
await client.query(
|
||||
`INSERT INTO products (
|
||||
id, name, description, category_id, price, stock_quantity,
|
||||
weight_grams, length_cm, width_cm, height_cm,
|
||||
origin, age, material_type, color
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
||||
[
|
||||
productId, name, description, categoryId, price, stockQuantity,
|
||||
weightGrams || null, lengthCm || null, widthCm || null, heightCm || null,
|
||||
origin || null, age || null, materialType || null, color || null
|
||||
]
|
||||
);
|
||||
|
||||
// Add images if provided
|
||||
if (images && images.length > 0) {
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const { path, isPrimary = (i === 0) } = images[i];
|
||||
|
||||
await client.query(
|
||||
'INSERT INTO product_images (product_id, image_path, display_order, is_primary) VALUES ($1, $2, $3, $4)',
|
||||
[productId, path, i, isPrimary]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add tags if provided
|
||||
if (tags && tags.length > 0) {
|
||||
for (const tagName of tags) {
|
||||
// Get tag ID
|
||||
let tagResult = await client.query(
|
||||
'SELECT id FROM tags WHERE name = $1',
|
||||
[tagName]
|
||||
);
|
||||
|
||||
let tagId;
|
||||
|
||||
// If tag doesn't exist, create it
|
||||
if (tagResult.rows.length === 0) {
|
||||
const newTagResult = await client.query(
|
||||
'INSERT INTO tags (name) VALUES ($1) RETURNING id',
|
||||
[tagName]
|
||||
);
|
||||
tagId = newTagResult.rows[0].id;
|
||||
} else {
|
||||
tagId = tagResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Add tag to product
|
||||
await client.query(
|
||||
'INSERT INTO product_tags (product_id, tag_id) VALUES ($1, $2)',
|
||||
[productId, tagId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Get complete product with images and tags
|
||||
const productQuery = `
|
||||
SELECT p.*,
|
||||
pc.name as category_name,
|
||||
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
|
||||
json_agg(json_build_object(
|
||||
'id', pi.id,
|
||||
'path', pi.image_path,
|
||||
'isPrimary', pi.is_primary,
|
||||
'displayOrder', pi.display_order
|
||||
)) FILTER (WHERE pi.id IS NOT NULL) AS images
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN product_tags pt ON p.id = pt.product_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
LEFT JOIN product_images pi ON p.id = pi.product_id
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id, pc.name
|
||||
`;
|
||||
|
||||
const product = await query(productQuery, [productId]);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Product created successfully',
|
||||
product: product.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
// Separate successes and errors
|
||||
const success = results.filter(result => !result.error).map(result => ({
|
||||
message: 'Product created successfully',
|
||||
product: result.product,
|
||||
code: 201
|
||||
}));
|
||||
|
||||
const errs = results.filter(result => result.error);
|
||||
|
||||
res.status(201).json({
|
||||
message: errs.length > 0 ?
|
||||
(success.length > 0 ? 'Some Products failed to create, check errors array' : "Failed to create Products") :
|
||||
'Products created successfully',
|
||||
success,
|
||||
errs
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/stock-notification', async (req, res, next) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { enabled, email, threshold } = req.body;
|
||||
|
|
@ -164,6 +93,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Check if product exists
|
||||
const productCheck = await query(
|
||||
|
|
@ -186,22 +116,21 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
};
|
||||
|
||||
// Update product with notification settings
|
||||
const result = await query(
|
||||
`UPDATE products
|
||||
SET stock_notification = $1
|
||||
WHERE id = $2
|
||||
RETURNING *`,
|
||||
[JSON.stringify(notificationSettings), id]
|
||||
);
|
||||
const result = await addThresholdNotification(id, enabled, email, threshold, client);
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.json({
|
||||
message: 'Stock notification settings updated successfully',
|
||||
product: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
next(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Update an existing product
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
|
|
@ -215,6 +144,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
stockQuantity,
|
||||
weightGrams,
|
||||
lengthCm,
|
||||
stockNotification,
|
||||
widthCm,
|
||||
heightCm,
|
||||
origin,
|
||||
|
|
@ -344,6 +274,10 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
updateValues.push(color);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (stockNotification) {
|
||||
await addThresholdNotification(id, stockNotification.enabled, stockNotification.email, stockNotification.threshold, client);
|
||||
}
|
||||
|
||||
if (updateFields.length > 0) {
|
||||
const updateQuery = `
|
||||
|
|
@ -485,4 +419,179 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
async function addThresholdNotification(id, enabled, email, threshold ,client) {
|
||||
// Store notification settings as JSONB
|
||||
const notificationSettings = {
|
||||
enabled,
|
||||
email: email || null,
|
||||
threshold: threshold || 0
|
||||
};
|
||||
|
||||
// Update product with notification settings
|
||||
const result = await client.query(
|
||||
`UPDATE products
|
||||
SET stock_notification = $1
|
||||
WHERE id = $2
|
||||
RETURNING *`,
|
||||
[JSON.stringify(notificationSettings), id]
|
||||
);
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
async function createProduct(product, pool) {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
categoryName,
|
||||
price,
|
||||
stockQuantity,
|
||||
weightGrams,
|
||||
lengthCm,
|
||||
widthCm,
|
||||
heightCm,
|
||||
stockNotification,
|
||||
origin,
|
||||
age,
|
||||
materialType,
|
||||
color,
|
||||
images,
|
||||
tags
|
||||
} = product;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !description || !categoryName || !price || !stockQuantity) {
|
||||
return {
|
||||
error: true,
|
||||
message: 'Required fields missing: name, description, categoryName, price, and stockQuantity are mandatory',
|
||||
code: 400
|
||||
};
|
||||
}
|
||||
|
||||
// Get a client from the pool for this specific product
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Get category ID by name
|
||||
const categoryResult = await client.query(
|
||||
'SELECT id FROM product_categories WHERE name = $1',
|
||||
[categoryName]
|
||||
);
|
||||
|
||||
if (categoryResult.rows.length === 0) {
|
||||
return {
|
||||
error: true,
|
||||
message: `Category "${categoryName}" not found`,
|
||||
code: 404
|
||||
};
|
||||
}
|
||||
|
||||
const categoryId = categoryResult.rows[0].id;
|
||||
|
||||
// Create product
|
||||
const productId = uuidv4();
|
||||
await client.query(
|
||||
`INSERT INTO products (
|
||||
id, name, description, category_id, price, stock_quantity,
|
||||
weight_grams, length_cm, width_cm, height_cm,
|
||||
origin, age, material_type, color
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
||||
[
|
||||
productId, name, description, categoryId, price, stockQuantity,
|
||||
weightGrams || null, lengthCm || null, widthCm || null, heightCm || null,
|
||||
origin || null, age || null, materialType || null, color || null
|
||||
]
|
||||
);
|
||||
|
||||
// Add images if provided
|
||||
if (images && images.length > 0) {
|
||||
const imagePromises = images.map((image, i) => {
|
||||
const { path, isPrimary = (i === 0) } = image;
|
||||
return client.query(
|
||||
'INSERT INTO product_images (product_id, image_path, display_order, is_primary) VALUES ($1, $2, $3, $4)',
|
||||
[productId, path, i, isPrimary]
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(imagePromises);
|
||||
}
|
||||
|
||||
// Add tags if provided
|
||||
if (tags && tags.length > 0) {
|
||||
for (const tagName of tags) {
|
||||
// Get tag ID
|
||||
let tagResult = await client.query(
|
||||
'SELECT id FROM tags WHERE name = $1',
|
||||
[tagName]
|
||||
);
|
||||
|
||||
let tagId;
|
||||
|
||||
// If tag doesn't exist, create it
|
||||
if (tagResult.rows.length === 0) {
|
||||
const newTagResult = await client.query(
|
||||
'INSERT INTO tags (name) VALUES ($1) RETURNING id',
|
||||
[tagName]
|
||||
);
|
||||
tagId = newTagResult.rows[0].id;
|
||||
} else {
|
||||
tagId = tagResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Add tag to product
|
||||
await client.query(
|
||||
'INSERT INTO product_tags (product_id, tag_id) VALUES ($1, $2)',
|
||||
[productId, tagId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get complete product with images and tags
|
||||
const productQuery = `
|
||||
SELECT p.*,
|
||||
pc.name as category_name,
|
||||
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
|
||||
json_agg(json_build_object(
|
||||
'id', pi.id,
|
||||
'path', pi.image_path,
|
||||
'isPrimary', pi.is_primary,
|
||||
'displayOrder', pi.display_order
|
||||
)) FILTER (WHERE pi.id IS NOT NULL) AS images
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN product_tags pt ON p.id = pt.product_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
LEFT JOIN product_images pi ON p.id = pi.product_id
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id, pc.name
|
||||
`;
|
||||
|
||||
const productResult = await client.query(productQuery, [productId]);
|
||||
|
||||
// Add threshold notification if provided
|
||||
if (stockNotification) {
|
||||
await addThresholdNotification(productId, stockNotification.enabled, stockNotification.email, stockNotification.threshold, client);
|
||||
}
|
||||
|
||||
|
||||
await client.query('COMMIT');
|
||||
return {
|
||||
error: false,
|
||||
product: productResult.rows[0]
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
return {
|
||||
error: true,
|
||||
message: `Error creating product: ${error.message}`,
|
||||
code: 500
|
||||
};
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
|
@ -224,9 +224,11 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
} else if (category === 'database') {
|
||||
if (key === 'db_host') config.db.host = value;
|
||||
if (key === 'db_user') config.db.user = value;
|
||||
if (key === 'site_read_host') config.db.readHost = value;
|
||||
if (key === 'db_password') config.db.password = value;
|
||||
if (key === 'db_name') config.db.database = value;
|
||||
if (key === 'db_port') config.db.port = parseInt(value, 10);
|
||||
if (key === 'site_db_max_connections') config.db.maxConnections = parseInt(value, 10);
|
||||
} else if (category === 'email') {
|
||||
if (key === 'smtp_host') config.email.host = value;
|
||||
if (key === 'smtp_port') config.email.port = parseInt(value, 10);
|
||||
|
|
@ -260,6 +262,17 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
if (key === 'site_api_domain') config.site.apiDomain = value;
|
||||
if (key === 'site_analytics_api_key') config.site.analyticApiKey = value;
|
||||
if (key === 'site_environment') config.environment = value;
|
||||
if (key === 'site_deployment') config.site.deployment = value;
|
||||
if (key === 'site_redis_host') config.site.redisHost = value;
|
||||
if (key === 'site_redis_tls') config.site.redisTLS = value;
|
||||
if (key === 'site_aws_region') config.site.awsRegion = value;
|
||||
if (key === 'site_aws_s3_bucket') config.site.awsS3Bucket = value;
|
||||
if (key === 'site_cdn_domain') config.site.cdnDomain = value;
|
||||
if (key === 'site_aws_queue_url') config.site.awsQueueUrl = value;
|
||||
if (key === 'site_session_secret') config.site.sessionSecret = value;
|
||||
if (key === 'site_redis_port') config.site.redisPort = value;
|
||||
if (key === 'site_redis_password') config.site.redisPassword = value;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -281,15 +294,27 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
envContent += '# Server configuration\n';
|
||||
envContent += `PORT=${config.port}\n`;
|
||||
envContent += `NODE_ENV=${config.nodeEnv}\n`;
|
||||
envContent += `ENVIRONMENT=${config.environment}\n\n`;
|
||||
envContent += `ENVIRONMENT=${config.environment}\n`;
|
||||
envContent += `DEPLOYMENT_MODE=${config.site.deployment}\n`;
|
||||
envContent += `REDIS_HOST=${config.site.redisHost}\n`;
|
||||
envContent += `REDIS_TLS=${config.site.redisTLS}\n`;
|
||||
envContent += `REDIS_PORT=${config.site.redisPort}\n`;
|
||||
envContent += `REDIS_PASSWORD=${config.site.redisPassword}\n`;
|
||||
envContent += `AWS_REGION=${config.site.awsRegion}\n`;
|
||||
envContent += `S3_BUCKET=${config.site.awsS3Bucket}\n`;
|
||||
envContent += `CDN_DOMAIN=${config.site.cdnDomain}\n`;
|
||||
envContent += `SQS_QUEUE_URL=${config.site.awsQueueUrl}\n`;
|
||||
envContent += `SESSION_SECRET=${config.site.sessionSecret}\n\n`;
|
||||
|
||||
// Database configuration
|
||||
envContent += '# Database configuration\n';
|
||||
envContent += `DB_HOST=${config.db.host}\n`;
|
||||
envContent += `DB_READ_HOST=${config.db.readHost}\n`;
|
||||
envContent += `DB_USER=${config.db.user}\n`;
|
||||
envContent += `DB_PASSWORD=${config.db.password}\n`;
|
||||
envContent += `DB_NAME=${config.db.database}\n`;
|
||||
envContent += `DB_PORT=${config.db.port}\n`;
|
||||
envContent += `DB_MAX_CONNECTIONS=${config.db.maxConnections}\n`;
|
||||
envContent += `POSTGRES_USER=${config.db.user}\n`;
|
||||
envContent += `POSTGRES_PASSWORD=${config.db.password}\n`;
|
||||
envContent += `POSTGRES_DB=${config.db.database}\n\n`;
|
||||
|
|
|
|||
58
backend/src/services/cacheService.js
Normal file
58
backend/src/services/cacheService.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const Redis = require('ioredis');
|
||||
const config = require('../config');
|
||||
|
||||
class CacheService {
|
||||
constructor() {
|
||||
this.enabled = config.site.deployment === 'cloud' && config.site.redisHost
|
||||
this.client = null;
|
||||
|
||||
if (this.enabled) {
|
||||
this.client = new Redis({
|
||||
host: config.site.redisHost,
|
||||
port: config.site.redisPort,
|
||||
password: config.site.redisPassword,
|
||||
tls: config.site.redisTLS ? {} : undefined,
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
console.error('Redis Client Error', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async get(key) {
|
||||
if (!this.enabled) return null;
|
||||
|
||||
try {
|
||||
const value = await this.client.get(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch (error) {
|
||||
console.error('Cache get error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key, value, ttlSeconds = 300) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
try {
|
||||
await this.client.setex(key, ttlSeconds, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error('Cache set error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
try {
|
||||
await this.client.del(key);
|
||||
} catch (error) {
|
||||
console.error('Cache delete error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
const cacheService = new CacheService();
|
||||
module.exports = cacheService;
|
||||
54
backend/src/services/queueService.js
Normal file
54
backend/src/services/queueService.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// services/queueService.js
|
||||
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');
|
||||
const config = require('../config');
|
||||
|
||||
class QueueService {
|
||||
constructor() {
|
||||
this.enabled = config.aws.sqs.enabled;
|
||||
this.sqsClient = null;
|
||||
|
||||
if (this.enabled) {
|
||||
this.sqsClient = new SQSClient({ region: config.aws.region });
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(queueUrl, messageBody) {
|
||||
if (!this.enabled) {
|
||||
// In self-hosted mode, execute immediately
|
||||
console.log('Direct execution (no queue):', messageBody);
|
||||
await this._processMessageDirectly(messageBody);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new SendMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MessageBody: JSON.stringify(messageBody)
|
||||
});
|
||||
|
||||
await this.sqsClient.send(command);
|
||||
} catch (error) {
|
||||
console.error('Queue send error:', error);
|
||||
// Fallback to direct processing
|
||||
await this._processMessageDirectly(messageBody);
|
||||
}
|
||||
}
|
||||
|
||||
async _processMessageDirectly(messageBody) {
|
||||
// Direct processing for self-hosted mode
|
||||
const emailService = require('./emailService');
|
||||
|
||||
switch(messageBody.type) {
|
||||
case 'LOW_STOCK_ALERT':
|
||||
await emailService.sendLowStockAlert(messageBody);
|
||||
break;
|
||||
case 'ORDER_CONFIRMATION':
|
||||
await emailService.sendOrderConfirmation(messageBody);
|
||||
break;
|
||||
// Add other message types
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queueService = new QueueService();
|
||||
module.exports = queueService;
|
||||
83
backend/src/services/storageService.js
Normal file
83
backend/src/services/storageService.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// services/storageService.js
|
||||
const multer = require('multer');
|
||||
const multerS3 = require('multer-s3');
|
||||
const { S3Client } = require('@aws-sdk/client-s3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const config = require('../config');
|
||||
|
||||
class StorageService {
|
||||
constructor() {
|
||||
this.mode = config.site.deployment;
|
||||
this.s3Client = null;
|
||||
|
||||
if (this.mode === 'cloud' && config.site.awsS3Bucket) {
|
||||
this.s3Client = new S3Client({ region: config.site.awsRegion });
|
||||
}
|
||||
}
|
||||
|
||||
getUploadMiddleware() {
|
||||
if (this.mode === 'cloud' && config.site.awsS3Bucket) {
|
||||
// Cloud mode: Use S3
|
||||
return multer({
|
||||
storage: multerS3({
|
||||
s3: this.s3Client,
|
||||
bucket: config.site.awsS3Bucket,
|
||||
acl: 'public-read',
|
||||
key: (req, file, cb) => {
|
||||
const folder = req.path.includes('/product') ? 'products' : 'blog';
|
||||
cb(null, `${folder}/${Date.now()}-${file.originalname}`);
|
||||
}
|
||||
}),
|
||||
fileFilter: this._fileFilter,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }
|
||||
});
|
||||
} else {
|
||||
// Self-hosted mode: Use local storage
|
||||
return multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../public/uploads');
|
||||
const folder = req.path.includes('/product') ? 'products' : 'blog';
|
||||
const finalPath = path.join(uploadDir, folder);
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(finalPath)) {
|
||||
fs.mkdirSync(finalPath, { recursive: true });
|
||||
}
|
||||
|
||||
cb(null, finalPath);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, `${Date.now()}-${file.originalname}`);
|
||||
}
|
||||
}),
|
||||
fileFilter: this._fileFilter,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_fileFilter(req, file, cb) {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed!'), false);
|
||||
}
|
||||
}
|
||||
|
||||
getImageUrl(path) {
|
||||
if (!path) return null;
|
||||
|
||||
if (this.mode === 'cloud' && config.site.cdnDomain) {
|
||||
// Use CloudFront CDN in cloud mode
|
||||
return `https://${config.site.cdnDomain}${path}`;
|
||||
} else {
|
||||
// Use direct path in self-hosted mode
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const storageService = new StorageService();
|
||||
module.exports = storageService;
|
||||
93
backend/src/worker.js
Normal file
93
backend/src/worker.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
const { pool, query } = require('./db');
|
||||
const config = require('./config');
|
||||
const notificationService = require('./services/notificationService');
|
||||
const queueService = require('./services/queueService');
|
||||
const { Consumer } = require('sqs-consumer');
|
||||
const { SQSClient } = require('@aws-sdk/client-sqs');
|
||||
|
||||
console.log('Starting worker process...');
|
||||
console.log(`Environment: ${process.env.ENVIRONMENT || 'beta'}`);
|
||||
console.log(`Deployment mode: ${process.env.DEPLOYMENT_MODE || 'self-hosted'}`);
|
||||
|
||||
// Worker initialization
|
||||
async function initWorker() {
|
||||
try {
|
||||
await pool.connect();
|
||||
console.log('Worker connected to database');
|
||||
|
||||
// Set up processing intervals for database-based notifications
|
||||
const interval = process.env.ENVIRONMENT === 'prod' ? 10 * 60 * 1000 : 2 * 60 * 1000;
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
console.log('Processing low stock notifications...');
|
||||
const processedCount = await notificationService.processLowStockNotifications(pool, query);
|
||||
console.log(`Processed ${processedCount} low stock notifications`);
|
||||
} catch (error) {
|
||||
console.error('Error processing low stock notifications:', error);
|
||||
}
|
||||
}, interval);
|
||||
|
||||
// For cloud mode, add SQS message consumption here
|
||||
if (config.aws && config.aws.sqs && config.aws.sqs.enabled && config.aws.sqs.queueUrl) {
|
||||
console.log(`Starting SQS consumer for queue: ${config.aws.sqs.queueUrl}`);
|
||||
|
||||
// Create SQS consumer
|
||||
const consumer = Consumer.create({
|
||||
queueUrl: config.aws.sqs.queueUrl,
|
||||
handleMessage: async (message) => {
|
||||
try {
|
||||
console.log('Processing SQS message:', message.MessageId);
|
||||
const messageBody = JSON.parse(message.Body);
|
||||
|
||||
// Use the direct processing method from queueService
|
||||
await queueService._processMessageDirectly(messageBody);
|
||||
|
||||
console.log('Successfully processed message:', message.MessageId);
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', message.MessageId, error);
|
||||
throw error; // Rethrow to handle message as failed
|
||||
}
|
||||
},
|
||||
sqs: new SQSClient({ region: config.aws.region }),
|
||||
batchSize: 10,
|
||||
visibilityTimeout: 60,
|
||||
waitTimeSeconds: 20
|
||||
});
|
||||
|
||||
consumer.on('error', (err) => {
|
||||
console.error('SQS consumer error:', err.message);
|
||||
});
|
||||
|
||||
consumer.on('processing_error', (err) => {
|
||||
console.error('SQS message processing error:', err.message);
|
||||
});
|
||||
|
||||
consumer.start();
|
||||
console.log('SQS consumer started');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Worker initialization error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the worker
|
||||
initWorker().catch(err => {
|
||||
console.error('Unhandled worker error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('Worker received SIGTERM, shutting down gracefully');
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Worker received SIGINT, shutting down gracefully');
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
@ -32,7 +32,18 @@ VALUES
|
|||
('site_api_domain', NULL, 'site'),
|
||||
('site_protocol', NULL, 'site'),
|
||||
('site_environment', NULL, 'site'),
|
||||
('site_analytics_api_key', NULL, 'site'),
|
||||
('site_deployment', NULL, 'site'),
|
||||
('site_redis_host', NULL, 'site'),
|
||||
('site_redis_tls', NULL, 'site'),
|
||||
('site_aws_region', NULL, 'site'),
|
||||
('site_aws_s3_bucket', NULL, 'site'),
|
||||
('site_cdn_domain', NULL, 'site'),
|
||||
('site_aws_queue_url', NULL, 'site'),
|
||||
('site_read_host', NULL, 'site'),
|
||||
('site_db_max_connections', NULL, 'site'),
|
||||
('site_session_secret', NULL, 'site'),
|
||||
('site_redis_port', NULL, 'site'),
|
||||
('site_redis_password', NULL, 'site'),
|
||||
|
||||
-- Payment Settings
|
||||
('currency', 'CAD', 'payment'),
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ services:
|
|||
env_file:
|
||||
- ./backend/.env
|
||||
ports:
|
||||
- "4000:4000"
|
||||
- "${PORT:-4000}:4000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
|
|
@ -32,6 +32,9 @@ services:
|
|||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
required: false
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
|
|
@ -55,8 +58,50 @@ services:
|
|||
networks:
|
||||
- app-network
|
||||
|
||||
# Redis service - only active in cloud mode
|
||||
redis:
|
||||
image: redis:alpine
|
||||
command: ["sh", "-c", "redis-server ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- app-network
|
||||
profiles:
|
||||
- cloud
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "${REDIS_PASSWORD:+--pass}", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Background worker for SQS and job processing - only active in cloud mode
|
||||
worker:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
command: node src/worker.js
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
- WORKER_MODE=true
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
required: false
|
||||
networks:
|
||||
- app-network
|
||||
profiles:
|
||||
- cloud
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
|
|
|
|||
3982
frontend/package-lock.json
generated
Normal file
3982
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,12 +23,14 @@
|
|||
"axios": "^1.6.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-email-editor": "^1.7.11",
|
||||
"react-redux": "^9.0.2",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"recharts": "^2.10.3"
|
||||
"recharts": "^2.10.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
|
|
|
|||
106
frontend/src/hooks/productAdminHooks.js
Normal file
106
frontend/src/hooks/productAdminHooks.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '@services/api';
|
||||
import { useNotification } from './reduxHooks';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
|
||||
|
||||
// Fetch categories
|
||||
export const useCategories = () => {
|
||||
return useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/products/categories/all');
|
||||
return response.data;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Fetch all available tags
|
||||
export const useTags = () => {
|
||||
return useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/products/tags/all');
|
||||
return response.data;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Fetch product data if editing
|
||||
export const useProduct = (id, isNewProduct) => {
|
||||
return useQuery({
|
||||
queryKey: ['product', id],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get(`/products/${id}`);
|
||||
return response.data[0];
|
||||
},
|
||||
enabled: !isNewProduct
|
||||
})
|
||||
};
|
||||
|
||||
// Create product mutation
|
||||
export const useCreateProduct = () => {
|
||||
const notification = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
return useMutation({
|
||||
mutationFn: async (productData) => {
|
||||
return await apiClient.post('/admin/products', productData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||
notification.showNotification('Product created successfully', 'success');
|
||||
// Redirect after a short delay
|
||||
setTimeout(() => {
|
||||
navigate('/admin/products');
|
||||
}, 1500);
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
`Failed to create product: ${error.message}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Update product mutation
|
||||
export const useUpdateProduct = (id) => {
|
||||
const notification = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, productData }) => {
|
||||
return await apiClient.put(`/admin/products/${id}`, productData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['product', id] });
|
||||
notification.showNotification('Product updated successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
`Failed to update product: ${error.message}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Save stock notification settings
|
||||
export const useSaveStockNotification = (id) => {
|
||||
const notification = useNotification();
|
||||
return useMutation({
|
||||
mutationFn: async (notificationData) => {
|
||||
return await apiClient.post(`/admin/products/${id}/stock-notification`, notificationData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
notification.showNotification('Stock notification settings saved!', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
`Failed to save notification settings: ${error.message}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
|
@ -31,6 +31,7 @@ import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
|||
import ImageUploader from '@components/ImageUploader';
|
||||
import apiClient from '@services/api';
|
||||
import { useAuth } from '@hooks/reduxHooks';
|
||||
import { useCategories, useTags, useProduct, useCreateProduct, useUpdateProduct, useSaveStockNotification } from '@hooks/productAdminHooks';
|
||||
|
||||
const ProductEditPage = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
|
@ -75,106 +76,26 @@ const ProductEditPage = () => {
|
|||
});
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories, isLoading: categoriesLoading } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/products/categories/all');
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
||||
|
||||
// Fetch all available tags
|
||||
const { data: allTags, isLoading: tagsLoading } = useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/products/tags/all');
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
const { data: allTags, isLoading: tagsLoading } = useTags();
|
||||
|
||||
// Fetch product data if editing
|
||||
const {
|
||||
data: product,
|
||||
isLoading: productLoading,
|
||||
error: productError
|
||||
} = useQuery({
|
||||
queryKey: ['product', id],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get(`/products/${id}`);
|
||||
return response.data[0];
|
||||
},
|
||||
enabled: !isNewProduct
|
||||
});
|
||||
} = useProduct(id === 'new' ? null : id, isNewProduct);
|
||||
|
||||
// Create product mutation
|
||||
const createProduct = useMutation({
|
||||
mutationFn: async (productData) => {
|
||||
return await apiClient.post('/admin/products', productData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Product created successfully!',
|
||||
severity: 'success'
|
||||
});
|
||||
// Redirect after a short delay
|
||||
setTimeout(() => {
|
||||
navigate('/admin/products');
|
||||
}, 1500);
|
||||
},
|
||||
onError: (error) => {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: `Failed to create product: ${error.message}`,
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
const createProduct = useCreateProduct();
|
||||
|
||||
// Update product mutation
|
||||
const updateProduct = useMutation({
|
||||
mutationFn: async ({ id, productData }) => {
|
||||
return await apiClient.put(`/admin/products/${id}`, productData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['product', id] });
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Product updated successfully!',
|
||||
severity: 'success'
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: `Failed to update product: ${error.message}`,
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
const updateProduct = useUpdateProduct(id === 'new' ? null : id);
|
||||
|
||||
// Save stock notification settings
|
||||
const saveStockNotification = useMutation({
|
||||
mutationFn: async (notificationData) => {
|
||||
return await apiClient.post(`/admin/products/${id}/stock-notification`, notificationData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Stock notification settings saved!',
|
||||
severity: 'success'
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: `Failed to save notification settings: ${error.message}`,
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
const saveStockNotification = useSaveStockNotification(id === 'new' ? null : id);
|
||||
|
||||
// Handle form changes
|
||||
const handleChange = (e) => {
|
||||
|
|
@ -323,35 +244,34 @@ const ProductEditPage = () => {
|
|||
};
|
||||
|
||||
// Add notification data if enabled
|
||||
if (notificationEnabled && !isNewProduct) {
|
||||
|
||||
if (notificationEnabled) {
|
||||
productData.stockNotification = {
|
||||
enabled: true,
|
||||
email: notificationEmail,
|
||||
threshold: stockThreshold
|
||||
};
|
||||
}
|
||||
|
||||
if (isNewProduct) {
|
||||
createProduct.mutate(productData);
|
||||
} else {
|
||||
updateProduct.mutate({ id, productData });
|
||||
|
||||
// Save notification settings separately
|
||||
if (notificationEnabled) {
|
||||
saveStockNotification.mutate({
|
||||
enabled: true,
|
||||
email: notificationEmail,
|
||||
threshold: stockThreshold
|
||||
});
|
||||
} else {
|
||||
// Disable notifications if checkbox is unchecked
|
||||
saveStockNotification.mutate({
|
||||
enabled: false,
|
||||
email: '',
|
||||
threshold: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
// Save notification settings separately
|
||||
// if (notificationEnabled) {
|
||||
// saveStockNotification.mutate({
|
||||
// enabled: true,
|
||||
// email: notificationEmail,
|
||||
// threshold: stockThreshold
|
||||
// });
|
||||
// } else {
|
||||
// // Disable notifications if checkbox is unchecked
|
||||
// saveStockNotification.mutate({
|
||||
// enabled: false,
|
||||
// email: '',
|
||||
// threshold: 0
|
||||
// });
|
||||
// }
|
||||
};
|
||||
|
||||
// Handle notification close
|
||||
|
|
@ -545,78 +465,77 @@ const ProductEditPage = () => {
|
|||
</Grid>
|
||||
|
||||
{/* Stock Notification Section */}
|
||||
{!isNewProduct && (
|
||||
<>
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Stock Level Notifications
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<>
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Stock Level Notifications
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<NotificationsActiveIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1">
|
||||
Get notified when stock is running low
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<NotificationsActiveIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="subtitle1">
|
||||
Get notified when stock is running low
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={notificationEnabled}
|
||||
onChange={handleNotificationToggle}
|
||||
name="notificationEnabled"
|
||||
color="primary"
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={notificationEnabled}
|
||||
onChange={handleNotificationToggle}
|
||||
name="notificationEnabled"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Enable stock level notifications"
|
||||
/>
|
||||
|
||||
{notificationEnabled && (
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Notification Email"
|
||||
name="notificationEmail"
|
||||
type="email"
|
||||
value={notificationEmail}
|
||||
onChange={handleNotificationEmailChange}
|
||||
error={!!errors.notificationEmail}
|
||||
helperText={errors.notificationEmail}
|
||||
required
|
||||
/>
|
||||
}
|
||||
label="Enable stock level notifications"
|
||||
/>
|
||||
|
||||
{notificationEnabled && (
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Notification Email"
|
||||
name="notificationEmail"
|
||||
type="email"
|
||||
value={notificationEmail}
|
||||
onChange={handleNotificationEmailChange}
|
||||
error={!!errors.notificationEmail}
|
||||
helperText={errors.notificationEmail}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Stock Threshold"
|
||||
name="stockThreshold"
|
||||
type="number"
|
||||
value={stockThreshold}
|
||||
onChange={handleStockThresholdChange}
|
||||
error={!!errors.stockThreshold}
|
||||
helperText={errors.stockThreshold || "You'll be notified when stock falls below this number"}
|
||||
required
|
||||
InputProps={{
|
||||
inputProps: {
|
||||
min: 1,
|
||||
max: formData.stockQuantity ? parseInt(formData.stockQuantity) : 999
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Stock Threshold"
|
||||
name="stockThreshold"
|
||||
type="number"
|
||||
value={stockThreshold}
|
||||
onChange={handleStockThresholdChange}
|
||||
error={!!errors.stockThreshold}
|
||||
helperText={errors.stockThreshold || "You'll be notified when stock falls below this number"}
|
||||
required
|
||||
InputProps={{
|
||||
inputProps: {
|
||||
min: 1,
|
||||
max: formData.stockQuantity ? parseInt(formData.stockQuantity) : 999
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
|
|
|||
|
|
@ -21,19 +21,27 @@ import {
|
|||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle
|
||||
DialogTitle,
|
||||
Stack,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Add as AddIcon,
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon
|
||||
Clear as ClearIcon,
|
||||
FileDownload as DownloadIcon,
|
||||
Upload as UploadIcon,
|
||||
CloudUpload as CloudUploadIcon,
|
||||
CheckCircleOutline as CheckCircleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../../services/api';
|
||||
import ProductImage from '../../components/ProductImage';
|
||||
import * as XLSX from 'xlsx';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
const AdminProductsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -43,6 +51,18 @@ const AdminProductsPage = () => {
|
|||
const [search, setSearch] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState(null);
|
||||
|
||||
// New states for upload functionality
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState(null);
|
||||
const [parsedProducts, setParsedProducts] = useState([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||
const [uploadResults, setUploadResults] = useState(null);
|
||||
|
||||
const fileInputRef = React.useRef(null);
|
||||
|
||||
// Fetch products
|
||||
const {
|
||||
|
|
@ -75,6 +95,23 @@ const AdminProductsPage = () => {
|
|||
setProductToDelete(null);
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk upload mutation
|
||||
const bulkUploadProducts = useMutation({
|
||||
mutationFn: async (productData) => {
|
||||
return await apiClient.post('/admin/products', { products: productData });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||
setUploadSuccess(true);
|
||||
setUploadResults(data.data);
|
||||
setIsUploading(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
setUploadError(error.message || 'Failed to upload products');
|
||||
setIsUploading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle search change
|
||||
const handleSearchChange = (event) => {
|
||||
|
|
@ -122,6 +159,336 @@ const AdminProductsPage = () => {
|
|||
const handleEditClick = (productId) => {
|
||||
navigate(`/admin/products/${productId}`);
|
||||
};
|
||||
|
||||
// Handle download template
|
||||
const handleDownloadTemplate = () => {
|
||||
// Create template headers
|
||||
const headers = [
|
||||
'name',
|
||||
'description',
|
||||
'categoryName',
|
||||
'price',
|
||||
'stockQuantity',
|
||||
'weightGrams',
|
||||
'lengthCm',
|
||||
'widthCm',
|
||||
'heightCm',
|
||||
'origin',
|
||||
'age',
|
||||
'materialType',
|
||||
'color',
|
||||
'stockNotification_enabled',
|
||||
'stockNotification_email',
|
||||
'stockNotification_threshold',
|
||||
'tags' // Comma-separated tags
|
||||
];
|
||||
|
||||
// Create example data row
|
||||
const exampleRow = [
|
||||
'Example Product',
|
||||
'This is a product description',
|
||||
'Rock', // Valid category name
|
||||
'19.99',
|
||||
'10',
|
||||
'500',
|
||||
'15',
|
||||
'10',
|
||||
'5',
|
||||
'Brazil',
|
||||
'Recent',
|
||||
'Quartz',
|
||||
'Purple',
|
||||
'FALSE', // stockNotification_enabled
|
||||
'email@example.com', // stockNotification_email
|
||||
'5', // stockNotification_threshold
|
||||
'Rare,Polished' // Comma-separated tags
|
||||
];
|
||||
|
||||
// Create workbook
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Products Template');
|
||||
|
||||
// Add column widths for better readability
|
||||
const colWidths = [];
|
||||
headers.forEach(() => colWidths.push({ wch: 15 }));
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
// Generate Excel file
|
||||
XLSX.writeFile(workbook, 'products_upload_template.xlsx');
|
||||
};
|
||||
|
||||
// Handle export all products
|
||||
const handleExportAllProducts = () => {
|
||||
if (!products || products.length === 0) {
|
||||
// Show notification if no products to export
|
||||
alert("No products to export");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create headers (same as template, for consistency)
|
||||
const headers = [
|
||||
'name',
|
||||
'description',
|
||||
'categoryName',
|
||||
'price',
|
||||
'stockQuantity',
|
||||
'weightGrams',
|
||||
'lengthCm',
|
||||
'widthCm',
|
||||
'heightCm',
|
||||
'origin',
|
||||
'age',
|
||||
'materialType',
|
||||
'color',
|
||||
'stockNotification_enabled',
|
||||
'stockNotification_email',
|
||||
'stockNotification_threshold',
|
||||
'tags'
|
||||
];
|
||||
|
||||
// Format the product data for Excel
|
||||
const formattedData = products.map(product => {
|
||||
// Extract stock notification values if they exist
|
||||
let stockNotificationEnabled = 'FALSE';
|
||||
let stockNotificationEmail = '';
|
||||
let stockNotificationThreshold = '';
|
||||
|
||||
if (product.stock_notification) {
|
||||
try {
|
||||
const notification =
|
||||
typeof product.stock_notification === 'string'
|
||||
? JSON.parse(product.stock_notification)
|
||||
: product.stock_notification;
|
||||
|
||||
stockNotificationEnabled = notification.enabled ? 'TRUE' : 'FALSE';
|
||||
stockNotificationEmail = notification.email || '';
|
||||
stockNotificationThreshold = notification.threshold || '';
|
||||
} catch (e) {
|
||||
console.error('Error parsing stock notification:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tags array to comma-separated string if it exists
|
||||
const tagsString = Array.isArray(product.tags)
|
||||
? product.tags.join(',')
|
||||
: (product.tags || '');
|
||||
|
||||
// Return array in the same order as headers
|
||||
return [
|
||||
product.name || '',
|
||||
product.description || '',
|
||||
product.category_name || '',
|
||||
product.price || '',
|
||||
product.stock_quantity || '',
|
||||
product.weight_grams || '',
|
||||
product.length_cm || '',
|
||||
product.width_cm || '',
|
||||
product.height_cm || '',
|
||||
product.origin || '',
|
||||
product.age || '',
|
||||
product.material_type || '',
|
||||
product.color || '',
|
||||
stockNotificationEnabled,
|
||||
stockNotificationEmail,
|
||||
stockNotificationThreshold,
|
||||
tagsString
|
||||
];
|
||||
});
|
||||
|
||||
// Add headers as first row
|
||||
const data = [headers, ...formattedData];
|
||||
|
||||
// Create Excel workbook and add the data
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(data);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Products');
|
||||
|
||||
// Add column widths for better readability
|
||||
const colWidths = [];
|
||||
headers.forEach(() => colWidths.push({ wch: 15 }));
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
// Generate filename with current date
|
||||
const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||
const filename = `products_export_${date}.xlsx`;
|
||||
|
||||
// Write Excel file and trigger download
|
||||
XLSX.writeFile(workbook, filename);
|
||||
};
|
||||
|
||||
// Handle open upload dialog
|
||||
const handleOpenUploadDialog = () => {
|
||||
resetUploadState();
|
||||
setUploadDialogOpen(true);
|
||||
};
|
||||
|
||||
// Handle close upload dialog
|
||||
const handleCloseUploadDialog = () => {
|
||||
resetUploadState();
|
||||
setUploadDialogOpen(false);
|
||||
};
|
||||
|
||||
// Reset upload states
|
||||
const resetUploadState = () => {
|
||||
setUploadFile(null);
|
||||
setUploadProgress(0);
|
||||
setUploadError(null);
|
||||
setParsedProducts([]);
|
||||
setIsUploading(false);
|
||||
setUploadSuccess(false);
|
||||
setUploadResults(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = (e) => {
|
||||
setUploadError(null);
|
||||
setUploadSuccess(false);
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploadFile(file);
|
||||
|
||||
// Determine file type and parse accordingly
|
||||
const fileExtension = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (fileExtension === 'csv') {
|
||||
parseCsvFile(file);
|
||||
} else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
|
||||
parseExcelFile(file);
|
||||
} else {
|
||||
setUploadError('Unsupported file format. Please upload a CSV or Excel file.');
|
||||
}
|
||||
};
|
||||
|
||||
// Parse CSV file
|
||||
const parseCsvFile = (file) => {
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
if (results.errors.length > 0) {
|
||||
setUploadError(`Error parsing CSV: ${results.errors[0].message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedProducts = formatParsedProducts(results.data);
|
||||
setParsedProducts(formattedProducts);
|
||||
},
|
||||
error: (error) => {
|
||||
setUploadError(`Error parsing CSV: ${error.message}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Parse Excel file
|
||||
const parseExcelFile = (file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target.result);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
|
||||
// Get first sheet
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
// Convert to JSON
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: null });
|
||||
|
||||
const formattedProducts = formatParsedProducts(jsonData);
|
||||
setParsedProducts(formattedProducts);
|
||||
} catch (error) {
|
||||
setUploadError(`Error parsing Excel file: ${error.message}`);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setUploadError('Error reading file');
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
};
|
||||
|
||||
// Format parsed products to match API expectations
|
||||
const formatParsedProducts = (rawProducts) => {
|
||||
return rawProducts.map(product => {
|
||||
// Extract stock notification fields and combine them
|
||||
const stockNotification = {
|
||||
enabled: product.stockNotification_enabled === 'TRUE' || product.stockNotification_enabled === true,
|
||||
email: product.stockNotification_email || null,
|
||||
threshold: product.stockNotification_threshold ? parseInt(product.stockNotification_threshold) : null
|
||||
};
|
||||
|
||||
// Process tags (convert from comma-separated string to array)
|
||||
let tags = [];
|
||||
if (product.tags) {
|
||||
if (typeof product.tags === 'string') {
|
||||
tags = product.tags.split(',').map(tag => tag.trim()).filter(tag => tag);
|
||||
} else if (Array.isArray(product.tags)) {
|
||||
tags = product.tags;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert numeric values
|
||||
const price = product.price ? parseFloat(product.price) : null;
|
||||
const stockQuantity = product.stockQuantity ? parseInt(product.stockQuantity) : null;
|
||||
const weightGrams = product.weightGrams ? parseFloat(product.weightGrams) : null;
|
||||
const lengthCm = product.lengthCm ? parseFloat(product.lengthCm) : null;
|
||||
const widthCm = product.widthCm ? parseFloat(product.widthCm) : null;
|
||||
const heightCm = product.heightCm ? parseFloat(product.heightCm) : null;
|
||||
|
||||
return {
|
||||
name: product.name || '',
|
||||
description: product.description || '',
|
||||
categoryName: product.categoryName || '',
|
||||
price: price,
|
||||
stockQuantity: stockQuantity,
|
||||
weightGrams: weightGrams,
|
||||
lengthCm: lengthCm,
|
||||
widthCm: widthCm,
|
||||
heightCm: heightCm,
|
||||
origin: product.origin || null,
|
||||
age: product.age || null,
|
||||
materialType: product.materialType || null,
|
||||
color: product.color || null,
|
||||
stockNotification: stockNotification,
|
||||
tags: tags,
|
||||
images: [] // No images in bulk upload template
|
||||
};
|
||||
}).filter(product =>
|
||||
// Filter out products with missing required fields
|
||||
product.name &&
|
||||
product.description &&
|
||||
product.categoryName &&
|
||||
product.price !== null &&
|
||||
product.stockQuantity !== null
|
||||
);
|
||||
};
|
||||
|
||||
// Submit bulk upload
|
||||
const handleSubmitUpload = () => {
|
||||
if (parsedProducts.length === 0) {
|
||||
setUploadError('No valid products found in the file. Please check your data.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
|
||||
// Submit products in batches of 20 to respect connection limits
|
||||
const batchSize = 20;
|
||||
const batches = [];
|
||||
|
||||
for (let i = 0; i < parsedProducts.length; i += batchSize) {
|
||||
const batch = parsedProducts.slice(i, i + batchSize);
|
||||
batches.push(batch);
|
||||
}
|
||||
|
||||
// Process first batch
|
||||
bulkUploadProducts.mutate(parsedProducts);
|
||||
};
|
||||
|
||||
// Filter and paginate products
|
||||
const filteredProducts = products || [];
|
||||
|
|
@ -155,15 +522,48 @@ const AdminProductsPage = () => {
|
|||
Products
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
component={RouterLink}
|
||||
to="/admin/products/new"
|
||||
>
|
||||
Add Product
|
||||
</Button>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Tooltip title="Download a template for bulk product uploads">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Export all current products to Excel">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleExportAllProducts}
|
||||
>
|
||||
Export All Products
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Upload products in bulk from a CSV or Excel file">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<UploadIcon />}
|
||||
onClick={handleOpenUploadDialog}
|
||||
>
|
||||
Upload Bulk Products
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
component={RouterLink}
|
||||
to="/admin/products/new"
|
||||
>
|
||||
Add Product
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Search */}
|
||||
|
|
@ -301,6 +701,190 @@ const AdminProductsPage = () => {
|
|||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Upload Dialog */}
|
||||
<Dialog
|
||||
open={uploadDialogOpen}
|
||||
onClose={handleCloseUploadDialog}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Upload Products</DialogTitle>
|
||||
<DialogContent>
|
||||
{!uploadSuccess ? (
|
||||
<>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
Upload a CSV or Excel file containing your product data. Make sure the file follows the required format.
|
||||
</DialogContentText>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{/* Drag and drop area */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
border: '2px dashed',
|
||||
borderColor: theme => uploadFile ? theme.palette.success.main : theme.palette.divider,
|
||||
borderRadius: 1,
|
||||
p: 3,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme => uploadFile ? 'rgba(76, 175, 80, 0.08)' : 'transparent',
|
||||
cursor: isUploading ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: theme => !isUploading && theme.palette.primary.main,
|
||||
backgroundColor: theme => !isUploading && 'rgba(25, 118, 210, 0.04)'
|
||||
}
|
||||
}}
|
||||
component="label"
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
if (isUploading) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
// Update the file input to reflect the dragged file
|
||||
if (fileInputRef.current) {
|
||||
// Create a DataTransfer object
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(files[0]);
|
||||
fileInputRef.current.files = dataTransfer.files;
|
||||
|
||||
// Trigger the change event handler
|
||||
const event = new Event('change', { bubbles: true });
|
||||
fileInputRef.current.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
if (!isUploading) {
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(25, 118, 210, 0.04)';
|
||||
}
|
||||
}}
|
||||
onDragLeave={e => {
|
||||
e.preventDefault();
|
||||
if (!uploadFile) {
|
||||
e.currentTarget.style.borderColor = '';
|
||||
e.currentTarget.style.backgroundColor = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv, .xlsx, .xls"
|
||||
hidden
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{uploadFile ? (
|
||||
<>
|
||||
<CheckCircleIcon sx={{ color: 'success.main', fontSize: 48, mb: 1 }} />
|
||||
<Typography variant="body1" align="center" gutterBottom>
|
||||
File selected: {uploadFile.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" align="center">
|
||||
Drag and drop a different file or click to change
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloudUploadIcon sx={{ fontSize: 48, color: 'action.active', mb: 1 }} />
|
||||
<Typography variant="body1" align="center" gutterBottom>
|
||||
Drag and drop your CSV or Excel file here
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" align="center">
|
||||
or click to select a file
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{parsedProducts.length > 0 && (
|
||||
<Alert severity="info" sx={{ mt: 2, width: '100%' }}>
|
||||
Found {parsedProducts.length} valid products in the file.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<Alert severity="error" sx={{ mt: 2, width: '100%' }}>
|
||||
{uploadError}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
Upload successful!
|
||||
</Alert>
|
||||
|
||||
{uploadResults && (
|
||||
<>
|
||||
<Typography variant="subtitle1" sx={{ mt: 2, mb: 1 }}>
|
||||
Upload Results:
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2">
|
||||
{uploadResults.success?.length || 0} products created successfully
|
||||
</Typography>
|
||||
|
||||
{uploadResults.errs?.length > 0 && (
|
||||
<>
|
||||
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
|
||||
{uploadResults.errs.length} products failed to create
|
||||
</Typography>
|
||||
|
||||
<Paper variant="outlined" sx={{ mt: 1, p: 1, maxHeight: 150, overflow: 'auto' }}>
|
||||
{uploadResults.errs.map((err, index) => (
|
||||
<Typography key={index} variant="caption" display="block" color="error">
|
||||
Error: {err.message}
|
||||
</Typography>
|
||||
))}
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!uploadSuccess ? (
|
||||
<>
|
||||
<Button onClick={handleCloseUploadDialog} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmitUpload}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
disabled={isUploading || parsedProducts.length === 0}
|
||||
startIcon={isUploading && <CircularProgress size={20} color="inherit" />}
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Upload Products'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={handleCloseUploadDialog} color="primary">
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
23
start.sh
Executable file
23
start.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/bin/bash
|
||||
# start.sh
|
||||
|
||||
MODE=${1:-self-hosted}
|
||||
|
||||
if [ "$MODE" == "cloud" ]; then
|
||||
echo "Starting in CLOUD mode"
|
||||
# Make sure .env has cloud settings
|
||||
grep -q "DEPLOYMENT_MODE=cloud" ./backend/.env || \
|
||||
sed -i 's/DEPLOYMENT_MODE=.*/DEPLOYMENT_MODE=cloud/' ./backend/.env
|
||||
|
||||
# Start with cloud profile
|
||||
docker compose --profile cloud up -d --build
|
||||
else
|
||||
echo "Starting in SELF-HOSTED mode"
|
||||
# Make sure .env has self-hosted settings
|
||||
grep -q "DEPLOYMENT_MODE=self-hosted" ./backend/.env || \
|
||||
sed -i 's/DEPLOYMENT_MODE=.*/DEPLOYMENT_MODE=self-hosted/' ./backend/.env
|
||||
|
||||
# Start without extra services
|
||||
docker compose up -d --build
|
||||
fi
|
||||
echo "Deployment complete in $MODE mode"
|
||||
Loading…
Reference in a new issue