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"
},
"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

View file

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

View file

@ -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 };

View file

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

View file

@ -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(

View file

@ -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

View file

@ -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();
}
}

View file

@ -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`;

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_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'),

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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",

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 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 }} />

View file

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