Compare commits

...

9 commits

23 changed files with 5662 additions and 740 deletions

View file

@ -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=="
}
}
}

View file

@ -9,14 +9,18 @@
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/client-sqs": "^3.799.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parser": "^3.2.0", "csv-parser": "^3.2.0",
"csv-writer": "^1.6.0", "csv-writer": "^1.6.0",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"express": "^4.18.2", "express": "^4.18.2",
"ioredis": "^5.6.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.2", "multer": "^1.4.5-lts.2",
"multer-s3": "^3.0.1",
"nodemailer": "^6.9.1", "nodemailer": "^6.9.1",
"pg": "^8.10.0", "pg": "^8.10.0",
"pg-hstore": "^2.3.4", "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

View file

@ -3,6 +3,7 @@ const dotenv = require('dotenv');
// Load environment variables // Load environment variables
dotenv.config(); dotenv.config();
const config = { const config = {
// Server configuration // Server configuration
port: process.env.PORT || 4000, port: process.env.PORT || 4000,
@ -15,7 +16,9 @@ const config = {
user: process.env.DB_USER || 'postgres', user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres', password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'ecommerce', 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 // Email configuration
@ -65,6 +68,16 @@ const config = {
protocol: process.env.ENVIRONMENT === 'prod' ? 'https' : 'http', protocol: process.env.ENVIRONMENT === 'prod' ? 'https' : 'http',
apiDomain: process.env.ENVIRONMENT === 'prod' ? (process.env.API_PROD_URL || 'api.rocks.2many.ca') : 'localhost:4000', apiDomain: process.env.ENVIRONMENT === 'prod' ? (process.env.API_PROD_URL || 'api.rocks.2many.ca') : 'localhost:4000',
analyticApiKey: process.env.SITE_ANALYTIC_API || '', 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 // Update carriers allowed
if (carriersAllowed && carriersAllowed.value) config.shipping.carriersAllowed = carriersAllowed.value.split(','); if (carriersAllowed && carriersAllowed.value) config.shipping.carriersAllowed = carriersAllowed.value.split(',');
} }
// Update site settings if they exist in DB // Update site settings if they exist in DB
const siteSettings = settings.filter(s => s.category === 'site'); const siteSettings = settings.filter(s => s.category === 'site');
if (siteSettings.length > 0) { if (siteSettings.length > 0) {
@ -154,6 +166,33 @@ config.updateFromDatabase = (settings) => {
const siteProtocol = siteSettings.find(s => s.key === 'site_protocol'); const siteProtocol = siteSettings.find(s => s.key === 'site_protocol');
const siteApiDomain = siteSettings.find(s => s.key === 'site_api_domain'); const siteApiDomain = siteSettings.find(s => s.key === 'site_api_domain');
const analyticApiKey = siteSettings.find(s => s.key === 'site_analytics_api_key'); 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 (siteDomain && siteDomain.value) config.site.domain = siteDomain.value;
if (siteProtocol && siteProtocol.value) config.site.protocol = siteProtocol.value; if (siteProtocol && siteProtocol.value) config.site.protocol = siteProtocol.value;

View file

@ -1,25 +1,73 @@
const { Pool } = require('pg'); const { Pool } = require('pg');
const config = require('../config') const config = require('../config')
// Create a pool instance let writePool, readPool;
const pool = new Pool({
// Always create write pool
writePool = new Pool({
user: config.db.user, user: config.db.user,
password: config.db.password, password: config.db.password,
host: config.db.host, host: config.db.host,
port: config.db.port, port: config.db.port,
database: config.db.database database: config.db.database,
max: config.db.maxConnections,
}); });
// Helper function for running queries // Create read pool only in cloud mode
const query = async (text, params) => { 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 start = Date.now();
const res = await pool.query(text, params); const res = await pool.query(text, params);
const duration = Date.now() - start; 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; return res;
}; };
module.exports = {
query, // Old Query function
pool // 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 };

View file

@ -13,6 +13,7 @@ const fs = require('fs');
// services // services
const notificationService = require('./services/notificationService'); const notificationService = require('./services/notificationService');
const emailService = require('./services/emailService'); const emailService = require('./services/emailService');
const storageService = require('./services/storageService');
// routes // routes
const stripePaymentRoutes = require('./routes/stripePayment'); const stripePaymentRoutes = require('./routes/stripePayment');
@ -59,45 +60,46 @@ if (!fs.existsSync(blogImagesDir)) {
} }
// Configure storage // Configure storage
const storage = multer.diskStorage({ // const storage = multer.diskStorage({
destination: (req, file, cb) => { // destination: (req, file, cb) => {
// Determine destination based on upload type // // Determine destination based on upload type
if (req.originalUrl.includes('/product')) { // if (req.originalUrl.includes('/product')) {
cb(null, productImagesDir); // cb(null, productImagesDir);
} else { // } else {
cb(null, uploadsDir); // cb(null, uploadsDir);
} // }
}, // },
filename: (req, file, cb) => { // filename: (req, file, cb) => {
// Create unique filename with original extension // // Create unique filename with original extension
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); // const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const fileExt = path.extname(file.originalname); // const fileExt = path.extname(file.originalname);
const safeName = path.basename(file.originalname, fileExt) // const safeName = path.basename(file.originalname, fileExt)
.toLowerCase() // .toLowerCase()
.replace(/[^a-z0-9]/g, '-'); // .replace(/[^a-z0-9]/g, '-');
cb(null, `${safeName}-${uniqueSuffix}${fileExt}`); // cb(null, `${safeName}-${uniqueSuffix}${fileExt}`);
} // }
}); // });
// File filter to only allow images // // File filter to only allow images
const fileFilter = (req, file, cb) => { // const fileFilter = (req, file, cb) => {
// Accept only image files // // Accept only image files
if (file.mimetype.startsWith('image/')) { // if (file.mimetype.startsWith('image/')) {
cb(null, true); // cb(null, true);
} else { // } else {
cb(new Error('Only image files are allowed!'), false); // cb(new Error('Only image files are allowed!'), false);
} // }
}; // };
// Create the multer instance // Create the multer instance
const upload = multer({ // const upload = multer({
storage, // storage,
fileFilter, // fileFilter,
limits: { // limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit // fileSize: 5 * 1024 * 1024 // 5MB limit
} // }
}); // });
const upload = storageService.getUploadMiddleware();
pool.connect() pool.connect()
.then(async () => { .then(async () => {
@ -197,11 +199,16 @@ app.post('/api/image/upload', upload.single('image'), (req, res) => {
message: 'No image file provided' message: 'No image file provided'
}); });
} }
const imagePath = req.file.path ? `/uploads/${req.file.filename}` : req.file.location;
res.json({ res.json({
success: true, 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) => { 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 // 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({ res.json({
success: true, success: true,
imagePath, imagePath: storageService.getImageUrl(imagePath),
filename: req.file.filename 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) // 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 // Get the relative paths to the images
const imagePaths = req.files.map(file => ({ const imagePaths = req.files.map(file => {
imagePath: `/uploads/products/${file.filename}`, const imagePath = file.path ?
filename: file.filename `/uploads/products/${file.filename}` :
})); file.location;
return {
imagePath: storageService.getImageUrl(imagePath),
filename: file.filename || path.basename(file.location)
};
});
res.json({ res.json({
success: true, success: true,
images: imagePaths 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) // 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 // Check if file exists
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return res.status(404).json({ return res.status(404).json({
error: true, error: true,
message: 'Image not found' message: 'Image not found'
}); });
}
// Delete the file
fs.unlinkSync(filePath);
} }
// Delete the file
fs.unlinkSync(filePath);
res.json({ res.json({
success: true, success: true,
@ -346,6 +385,7 @@ app.use((err, req, res, next) => {
// Start server // Start server
app.listen(port, () => { app.listen(port, () => {
console.log(`Server running on port ${port} in ${config.environment} environment`); console.log(`Server running on port ${port} in ${config.environment} environment`);
console.log(`Deployment mode: ${config.site.deployment || 'self-hosted'}`);
}); });
module.exports = app; module.exports = app;

View file

@ -68,7 +68,6 @@ module.exports = (pool, query) => {
router.post('/login-request', async (req, res, next) => { router.post('/login-request', async (req, res, next) => {
const { email } = req.body; const { email } = req.body;
console.log('/login-request') console.log('/login-request')
console.log(JSON.stringify(config, null, 4))
try { try {
// Check if user exists // Check if user exists
const userResult = await query( const userResult = await query(

View file

@ -6,17 +6,19 @@ const csv = require('csv-parser');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { createObjectCsvWriter } = require('csv-writer'); const { createObjectCsvWriter } = require('csv-writer');
const storageService = require('../services/storageService');
// Configure multer for file uploads // Configure multer for file uploads
const upload = multer({ // const upload = multer({
dest: path.join(__dirname, '../uploads/temp'), // dest: path.join(__dirname, '../uploads/temp'),
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit // limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
}); // });
module.exports = (pool, query, authMiddleware) => { module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes // Apply authentication middleware to all routes
router.use(authMiddleware); router.use(authMiddleware);
const upload = storageService.getUploadMiddleware();
/** /**
* Get all mailing lists * Get all mailing lists
* GET /api/admin/mailing-lists * GET /api/admin/mailing-lists
@ -512,16 +514,45 @@ module.exports = (pool, query, authMiddleware) => {
// Process the CSV file // Process the CSV file
const results = []; 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 = () => { const processFile = () => {
return new Promise((resolve, reject) => { 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) fs.createReadStream(file.path)
.pipe(csv()) .pipe(csv())
.on('data', (data) => results.push(data)) .on('data', (data) => results.push(data))
.on('end', () => { .on('end', () => {
// Clean up temp file // Clean up temp file - only for local storage
fs.unlink(file.path, (err) => { if (file.path) {
if (err) console.error('Error deleting temp file:', err); fs.unlink(file.path, (err) => {
}); if (err) console.error('Error deleting temp file:', err);
});
}
resolve(results); resolve(results);
}) })
.on('error', reject); .on('error', reject);
@ -683,6 +714,10 @@ module.exports = (pool, query, authMiddleware) => {
// Create a temp file for CSV // Create a temp file for CSV
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `subscribers-${listName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.csv`; 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); const filePath = path.join(__dirname, '../uploads/temp', fileName);
// Create CSV writer // Create CSV writer

View file

@ -9,150 +9,79 @@ module.exports = (pool, query, authMiddleware) => {
// Create a new product with multiple images // Create a new product with multiple images
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
try { try {
const { // Check for array
name, let products = [];
description, if (req.body.products) {
categoryName, products = req.body.products;
price, } else {
stockQuantity, const {
weightGrams, name,
lengthCm, description,
widthCm, categoryName,
heightCm, price,
origin, stockQuantity,
age, weightGrams,
materialType, lengthCm,
color, widthCm,
images, heightCm,
tags stockNotification,
} = req.body; origin,
age,
// Validate required fields materialType,
if (!name || !description || !categoryName || !price || !stockQuantity) { color,
return res.status(400).json({ images,
error: true, tags
message: 'Required fields missing: name, description, categoryName, price, and stockQuantity are mandatory' } = 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 // Execute all product creation promises concurrently
const client = await pool.connect(); const results = await Promise.all(productPromises);
try { // Separate successes and errors
await client.query('BEGIN'); const success = results.filter(result => !result.error).map(result => ({
message: 'Product created successfully',
// Get category ID by name product: result.product,
const categoryResult = await client.query( code: 201
'SELECT id FROM product_categories WHERE name = $1', }));
[categoryName]
); const errs = results.filter(result => result.error);
if (categoryResult.rows.length === 0) { res.status(201).json({
return res.status(404).json({ message: errs.length > 0 ?
error: true, (success.length > 0 ? 'Some Products failed to create, check errors array' : "Failed to create Products") :
message: `Category "${categoryName}" not found` 'Products created successfully',
}); success,
} errs
});
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();
}
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); });
router.post('/:id/stock-notification', async (req, res, next) => { router.post('/:id/stock-notification', async (req, res, next) => {
const client = await pool.connect();
try { try {
const { id } = req.params; const { id } = req.params;
const { enabled, email, threshold } = req.body; const { enabled, email, threshold } = req.body;
@ -164,6 +93,7 @@ module.exports = (pool, query, authMiddleware) => {
message: 'Admin access required' message: 'Admin access required'
}); });
} }
await client.query('BEGIN');
// Check if product exists // Check if product exists
const productCheck = await query( const productCheck = await query(
@ -186,22 +116,21 @@ module.exports = (pool, query, authMiddleware) => {
}; };
// Update product with notification settings // Update product with notification settings
const result = await query( const result = await addThresholdNotification(id, enabled, email, threshold, client);
`UPDATE products await client.query('COMMIT');
SET stock_notification = $1
WHERE id = $2
RETURNING *`,
[JSON.stringify(notificationSettings), id]
);
res.json({ res.json({
message: 'Stock notification settings updated successfully', message: 'Stock notification settings updated successfully',
product: result.rows[0] product: result.rows[0]
}); });
} catch (error) { } catch (error) {
await client.query('ROLLBACK');
next(error); next(error);
} finally {
client.release();
} }
}); });
// Update an existing product // Update an existing product
router.put('/:id', async (req, res, next) => { router.put('/:id', async (req, res, next) => {
@ -215,6 +144,7 @@ module.exports = (pool, query, authMiddleware) => {
stockQuantity, stockQuantity,
weightGrams, weightGrams,
lengthCm, lengthCm,
stockNotification,
widthCm, widthCm,
heightCm, heightCm,
origin, origin,
@ -344,6 +274,10 @@ module.exports = (pool, query, authMiddleware) => {
updateValues.push(color); updateValues.push(color);
valueIndex++; valueIndex++;
} }
if (stockNotification) {
await addThresholdNotification(id, stockNotification.enabled, stockNotification.email, stockNotification.threshold, client);
}
if (updateFields.length > 0) { if (updateFields.length > 0) {
const updateQuery = ` const updateQuery = `
@ -485,4 +419,179 @@ module.exports = (pool, query, authMiddleware) => {
}); });
return router; 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();
}
}

View file

@ -224,9 +224,11 @@ module.exports = (pool, query, authMiddleware) => {
} else if (category === 'database') { } else if (category === 'database') {
if (key === 'db_host') config.db.host = value; if (key === 'db_host') config.db.host = value;
if (key === 'db_user') config.db.user = 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_password') config.db.password = value;
if (key === 'db_name') config.db.database = value; if (key === 'db_name') config.db.database = value;
if (key === 'db_port') config.db.port = parseInt(value, 10); 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') { } else if (category === 'email') {
if (key === 'smtp_host') config.email.host = value; if (key === 'smtp_host') config.email.host = value;
if (key === 'smtp_port') config.email.port = parseInt(value, 10); 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_api_domain') config.site.apiDomain = value;
if (key === 'site_analytics_api_key') config.site.analyticApiKey = value; if (key === 'site_analytics_api_key') config.site.analyticApiKey = value;
if (key === 'site_environment') config.environment = 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 += '# Server configuration\n';
envContent += `PORT=${config.port}\n`; envContent += `PORT=${config.port}\n`;
envContent += `NODE_ENV=${config.nodeEnv}\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 // Database configuration
envContent += '# Database configuration\n'; envContent += '# Database configuration\n';
envContent += `DB_HOST=${config.db.host}\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_USER=${config.db.user}\n`;
envContent += `DB_PASSWORD=${config.db.password}\n`; envContent += `DB_PASSWORD=${config.db.password}\n`;
envContent += `DB_NAME=${config.db.database}\n`; envContent += `DB_NAME=${config.db.database}\n`;
envContent += `DB_PORT=${config.db.port}\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_USER=${config.db.user}\n`;
envContent += `POSTGRES_PASSWORD=${config.db.password}\n`; envContent += `POSTGRES_PASSWORD=${config.db.password}\n`;
envContent += `POSTGRES_DB=${config.db.database}\n\n`; envContent += `POSTGRES_DB=${config.db.database}\n\n`;

View 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;

View 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;

View 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
View 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);
});

View file

@ -32,7 +32,18 @@ VALUES
('site_api_domain', NULL, 'site'), ('site_api_domain', NULL, 'site'),
('site_protocol', NULL, 'site'), ('site_protocol', NULL, 'site'),
('site_environment', 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 -- Payment Settings
('currency', 'CAD', 'payment'), ('currency', 'CAD', 'payment'),

View file

@ -24,7 +24,7 @@ services:
env_file: env_file:
- ./backend/.env - ./backend/.env
ports: ports:
- "4000:4000" - "${PORT:-4000}:4000"
volumes: volumes:
- ./backend:/app - ./backend:/app
- /app/node_modules - /app/node_modules
@ -32,6 +32,9 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
redis:
condition: service_started
required: false
networks: networks:
- app-network - app-network
@ -55,8 +58,50 @@ services:
networks: networks:
- app-network - 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: volumes:
postgres_data: postgres_data:
redis_data:
networks: networks:
app-network: app-network:

3982
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -23,12 +23,14 @@
"axios": "^1.6.2", "axios": "^1.6.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"papaparse": "^5.5.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-email-editor": "^1.7.11", "react-email-editor": "^1.7.11",
"react-redux": "^9.0.2", "react-redux": "^9.0.2",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"recharts": "^2.10.3" "recharts": "^2.10.3",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.37", "@types/react": "^18.2.37",

View 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'
);
}
})
};

View file

@ -31,6 +31,7 @@ import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import ImageUploader from '@components/ImageUploader'; import ImageUploader from '@components/ImageUploader';
import apiClient from '@services/api'; import apiClient from '@services/api';
import { useAuth } from '@hooks/reduxHooks'; import { useAuth } from '@hooks/reduxHooks';
import { useCategories, useTags, useProduct, useCreateProduct, useUpdateProduct, useSaveStockNotification } from '@hooks/productAdminHooks';
const ProductEditPage = () => { const ProductEditPage = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
@ -75,106 +76,26 @@ const ProductEditPage = () => {
}); });
// Fetch categories // Fetch categories
const { data: categories, isLoading: categoriesLoading } = useQuery({ const { data: categories, isLoading: categoriesLoading } = useCategories();
queryKey: ['categories'],
queryFn: async () => {
const response = await apiClient.get('/products/categories/all');
return response.data;
}
});
// Fetch all available tags // Fetch all available tags
const { data: allTags, isLoading: tagsLoading } = useQuery({ const { data: allTags, isLoading: tagsLoading } = useTags();
queryKey: ['tags'],
queryFn: async () => {
const response = await apiClient.get('/products/tags/all');
return response.data;
}
});
// Fetch product data if editing // Fetch product data if editing
const { const {
data: product, data: product,
isLoading: productLoading, isLoading: productLoading,
error: productError error: productError
} = useQuery({ } = useProduct(id === 'new' ? null : id, isNewProduct);
queryKey: ['product', id],
queryFn: async () => {
const response = await apiClient.get(`/products/${id}`);
return response.data[0];
},
enabled: !isNewProduct
});
// Create product mutation // Create product mutation
const createProduct = useMutation({ const createProduct = useCreateProduct();
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'
});
}
});
// Update product mutation // Update product mutation
const updateProduct = useMutation({ const updateProduct = useUpdateProduct(id === 'new' ? null : id);
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'
});
}
});
// Save stock notification settings // Save stock notification settings
const saveStockNotification = useMutation({ const saveStockNotification = useSaveStockNotification(id === 'new' ? null : id);
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'
});
}
});
// Handle form changes // Handle form changes
const handleChange = (e) => { const handleChange = (e) => {
@ -323,35 +244,34 @@ const ProductEditPage = () => {
}; };
// Add notification data if enabled // Add notification data if enabled
if (notificationEnabled && !isNewProduct) {
if (notificationEnabled) {
productData.stockNotification = { productData.stockNotification = {
enabled: true, enabled: true,
email: notificationEmail, email: notificationEmail,
threshold: stockThreshold threshold: stockThreshold
}; };
} }
if (isNewProduct) { if (isNewProduct) {
createProduct.mutate(productData); createProduct.mutate(productData);
} else { } else {
updateProduct.mutate({ id, productData }); 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 // Handle notification close
@ -545,78 +465,77 @@ const ProductEditPage = () => {
</Grid> </Grid>
{/* Stock Notification Section */} {/* Stock Notification Section */}
{!isNewProduct && (
<> <>
<Grid item xs={12}> <Grid item xs={12}>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Stock Level Notifications Stock Level Notifications
</Typography> </Typography>
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}> <Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
<CardContent> <CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<NotificationsActiveIcon color="primary" sx={{ mr: 1 }} /> <NotificationsActiveIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="subtitle1"> <Typography variant="subtitle1">
Get notified when stock is running low Get notified when stock is running low
</Typography> </Typography>
</Box> </Box>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
checked={notificationEnabled} checked={notificationEnabled}
onChange={handleNotificationToggle} onChange={handleNotificationToggle}
name="notificationEnabled" name="notificationEnabled"
color="primary" 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> </Grid>
)} <Grid item xs={12} md={6}>
</CardContent> <TextField
</Card> fullWidth
</Grid> 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}> <Grid item xs={12}>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />

View file

@ -21,19 +21,27 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle DialogTitle,
Stack,
Tooltip
} from '@mui/material'; } from '@mui/material';
import { import {
Edit as EditIcon, Edit as EditIcon,
Delete as DeleteIcon, Delete as DeleteIcon,
Add as AddIcon, Add as AddIcon,
Search as SearchIcon, 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'; } from '@mui/icons-material';
import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../../services/api'; import apiClient from '../../services/api';
import ProductImage from '../../components/ProductImage'; import ProductImage from '../../components/ProductImage';
import * as XLSX from 'xlsx';
import Papa from 'papaparse';
const AdminProductsPage = () => { const AdminProductsPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -43,6 +51,18 @@ const AdminProductsPage = () => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [productToDelete, setProductToDelete] = useState(null); 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 // Fetch products
const { const {
@ -75,6 +95,23 @@ const AdminProductsPage = () => {
setProductToDelete(null); 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 // Handle search change
const handleSearchChange = (event) => { const handleSearchChange = (event) => {
@ -122,6 +159,336 @@ const AdminProductsPage = () => {
const handleEditClick = (productId) => { const handleEditClick = (productId) => {
navigate(`/admin/products/${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 // Filter and paginate products
const filteredProducts = products || []; const filteredProducts = products || [];
@ -155,15 +522,48 @@ const AdminProductsPage = () => {
Products Products
</Typography> </Typography>
<Button <Stack direction="row" spacing={2}>
variant="contained" <Tooltip title="Download a template for bulk product uploads">
color="primary" <Button
startIcon={<AddIcon />} variant="outlined"
component={RouterLink} color="primary"
to="/admin/products/new" startIcon={<DownloadIcon />}
> onClick={handleDownloadTemplate}
Add Product >
</Button> 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> </Box>
{/* Search */} {/* Search */}
@ -301,6 +701,190 @@ const AdminProductsPage = () => {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </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> </Box>
); );
}; };

23
start.sh Executable file
View 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"