diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..bb1272e --- /dev/null +++ b/.env.development @@ -0,0 +1,21 @@ +PORT=5000 +NODE_ENV=development +CORS_ORIGIN=http://localhost:5173 +JWT_SECRET=supersecretjwtkeythatislongandsecure +JWT_EXPIRES_IN=7d + +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USER=postgres +DB_PASS=Liyaqath@7890 +DB_NAME=Pim_Maskan +DB_DIALECT=postgres + +# IMPORTANT: Gmail SMTP requires an App Password, NOT your regular password. +# Go to: https://myaccount.google.com -> Security -> 2-Step Verification -> App Passwords +# Generate a 16-character App Password and paste it below (no spaces). +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_USER=pmahboob001@gmail.com +EMAIL_PASSWORD=hlwg yfgi xmkn lqmg +EMAIL_FROM=Maskan PIM diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..bb1272e --- /dev/null +++ b/.env.local @@ -0,0 +1,21 @@ +PORT=5000 +NODE_ENV=development +CORS_ORIGIN=http://localhost:5173 +JWT_SECRET=supersecretjwtkeythatislongandsecure +JWT_EXPIRES_IN=7d + +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USER=postgres +DB_PASS=Liyaqath@7890 +DB_NAME=Pim_Maskan +DB_DIALECT=postgres + +# IMPORTANT: Gmail SMTP requires an App Password, NOT your regular password. +# Go to: https://myaccount.google.com -> Security -> 2-Step Verification -> App Passwords +# Generate a 16-character App Password and paste it below (no spaces). +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_USER=pmahboob001@gmail.com +EMAIL_PASSWORD=hlwg yfgi xmkn lqmg +EMAIL_FROM=Maskan PIM diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..67fdab2 --- /dev/null +++ b/.env.test @@ -0,0 +1,21 @@ +PORT=5000 +NODE_ENV=test +CORS_ORIGIN=http://localhost:5173 +JWT_SECRET=supersecretjwtkeythatislongandsecure +JWT_EXPIRES_IN=7d + +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USER=postgres +DB_PASS=Liyaqath@7890 +DB_NAME=Pim_Maskan_Test +DB_DIALECT=postgres + +# IMPORTANT: Gmail SMTP requires an App Password, NOT your regular password. +# Go to: https://myaccount.google.com -> Security -> 2-Step Verification -> App Passwords +# Generate a 16-character App Password and paste it below (no spaces). +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_USER=pmahboob001@gmail.com +EMAIL_PASSWORD=hlwg yfgi xmkn lqmg +EMAIL_FROM=Maskan PIM diff --git a/logs/.3fa4d5e91883ce1217f62a49f94203b42caa131d-audit.json b/logs/.3fa4d5e91883ce1217f62a49f94203b42caa131d-audit.json new file mode 100644 index 0000000..9add8df --- /dev/null +++ b/logs/.3fa4d5e91883ce1217f62a49f94203b42caa131d-audit.json @@ -0,0 +1,20 @@ +{ + "keep": { + "days": true, + "amount": 14 + }, + "auditLog": "logs\\.3fa4d5e91883ce1217f62a49f94203b42caa131d-audit.json", + "files": [ + { + "date": 1782885016196, + "name": "logs\\combined-2026-07-01.log", + "hash": "62273be471fbf03240935eebb5eaf8487e7a3002b6cced421c983245646bc21a" + }, + { + "date": 1782968983048, + "name": "logs\\combined-2026-07-02.log", + "hash": "10138c0f35436a800870f14a317b542645e7976bcd44030300d436a715a20c36" + } + ], + "hashType": "sha256" +} \ No newline at end of file diff --git a/logs/.944c2a6e38c8046075bb4cb64c11b1f8d919853c-audit.json b/logs/.944c2a6e38c8046075bb4cb64c11b1f8d919853c-audit.json new file mode 100644 index 0000000..89c0263 --- /dev/null +++ b/logs/.944c2a6e38c8046075bb4cb64c11b1f8d919853c-audit.json @@ -0,0 +1,20 @@ +{ + "keep": { + "days": true, + "amount": 14 + }, + "auditLog": "logs\\.944c2a6e38c8046075bb4cb64c11b1f8d919853c-audit.json", + "files": [ + { + "date": 1782885016194, + "name": "logs\\error-2026-07-01.log", + "hash": "3dc68d8ca2e67e313b2363c163e011da72b07deaf5d0f5736607ec0850b50e9b" + }, + { + "date": 1782968983045, + "name": "logs\\error-2026-07-02.log", + "hash": "bf64fe21cbfba23af08772bef924ee896b52bcbf80bb4e87b6f7c5fb125e38aa" + } + ], + "hashType": "sha256" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2997f85..df7569a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,12 +19,15 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", + "nodemailer": "^9.0.3", "pg": "^8.13.1", "pg-hstore": "^2.3.4", "sequelize": "^6.37.5", "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "winston": "^3.19.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "nodemon": "^3.1.9", @@ -82,6 +85,26 @@ "openapi-types": ">=7" } }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -125,6 +148,16 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -170,6 +203,12 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -305,6 +344,12 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -617,6 +662,19 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -637,6 +695,48 @@ "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -943,6 +1043,12 @@ "dev": true, "license": "MIT" }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1150,6 +1256,21 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1181,6 +1302,12 @@ "node": ">= 0.8" } }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1577,6 +1704,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -1724,6 +1863,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -1778,6 +1923,29 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -2003,6 +2171,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -2092,6 +2269,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2125,6 +2311,15 @@ "node": ">= 0.8" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2513,6 +2708,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -2936,6 +3140,15 @@ "node": ">= 10.x" } }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3224,6 +3437,12 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3262,6 +3481,15 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -3390,6 +3618,88 @@ "node": ">= 8" } }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "license": "MIT", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/wkx": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", diff --git a/package.json b/package.json index f0da432..0e2a2c3 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,15 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", + "nodemailer": "^9.0.3", "pg": "^8.13.1", "pg-hstore": "^2.3.4", "sequelize": "^6.37.5", "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "winston": "^3.19.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "nodemon": "^3.1.9", diff --git a/src/features/attributes/attributes/attribute.repository.js b/src/features/attributes/attributes/attribute.repository.js index 4a385f8..825c9f8 100644 --- a/src/features/attributes/attributes/attribute.repository.js +++ b/src/features/attributes/attributes/attribute.repository.js @@ -1,16 +1,16 @@ -import { Attribute } from './attribute.model.js'; +import { models } from '../../../shared/database/models.js'; export class AttributeRepository { async findAll(options = {}) { - return await Attribute.findAll(options); + return await models.Attribute.findAll(options); } async findById(id, options = {}) { - return await Attribute.findByPk(id, options); + return await models.Attribute.findByPk(id, options); } async create(data, options = {}) { - return await Attribute.create(data, options); + return await models.Attribute.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/auditLogs/auditLogs/auditLog.repository.js b/src/features/auditLogs/auditLogs/auditLog.repository.js index 3b9c006..6d48b43 100644 --- a/src/features/auditLogs/auditLogs/auditLog.repository.js +++ b/src/features/auditLogs/auditLogs/auditLog.repository.js @@ -1,16 +1,16 @@ -import { AuditLog } from './auditLog.model.js'; +import { models } from '../../../shared/database/models.js'; export class AuditLogRepository { async findAll(options = {}) { - return await AuditLog.findAll(options); + return await models.AuditLog.findAll(options); } async findById(id, options = {}) { - return await AuditLog.findByPk(id, options); + return await models.AuditLog.findByPk(id, options); } async create(data, options = {}) { - return await AuditLog.create(data, options); + return await models.AuditLog.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/authentication/access/role.controller.js b/src/features/authentication/access/role.controller.js new file mode 100644 index 0000000..abe0b3a --- /dev/null +++ b/src/features/authentication/access/role.controller.js @@ -0,0 +1,69 @@ +import service from './role.service.js'; + +export class RoleController { + async getAll(req, res, next) { + try { + const records = await service.getAll(req.query); + return res.status(200).json({ success: true, data: records }); + } catch (error) { + next(error); + } + } + + async getByTenant(req, res, next) { + try { + const { tenantId } = req.params; + const records = await service.getByTenant(tenantId === 'platform' ? null : tenantId); + return res.status(200).json({ success: true, data: records }); + } catch (error) { + next(error); + } + } + + async getById(req, res, next) { + try { + const record = await service.getById(req.params.id); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async create(req, res, next) { + try { + const record = await service.create(req.body); + return res.status(201).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async update(req, res, next) { + try { + const record = await service.update(req.params.id, req.body); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async delete(req, res, next) { + try { + await service.delete(req.params.id); + return res.status(200).json({ success: true, message: 'Role deleted successfully' }); + } catch (error) { + next(error); + } + } + + async getPermissions(req, res, next) { + try { + const permissions = await service.getPermissionNodes(); + return res.status(200).json({ success: true, data: permissions }); + } catch (error) { + next(error); + } + } +} + +export default new RoleController(); diff --git a/src/features/authentication/access/role.repository.js b/src/features/authentication/access/role.repository.js new file mode 100644 index 0000000..8900d59 --- /dev/null +++ b/src/features/authentication/access/role.repository.js @@ -0,0 +1,129 @@ +import { models } from '../../../shared/database/models.js'; +import sequelize from '../../../shared/database/connection.js'; + +export class RoleRepository { + async findAll(options = {}) { + return await models.Role.findAll({ + include: [ + { + model: models.PermissionNode, + as: 'permissions', + through: { attributes: ['can_view', 'can_create', 'can_edit', 'can_delete', 'can_alter', 'can_import', 'can_export'] } + } + ], + ...options + }); + } + + async findById(id, options = {}) { + return await models.Role.findByPk(id, { + include: [ + { + model: models.PermissionNode, + as: 'permissions', + through: { attributes: ['can_view', 'can_create', 'can_edit', 'can_delete', 'can_alter', 'can_import', 'can_export'] } + } + ], + ...options + }); + } + + async create(roleData, permissions = []) { + const transaction = await sequelize.transaction(); + try { + const role = await models.Role.create(roleData, { transaction }); + + if (permissions && permissions.length > 0) { + const validPermissions = permissions.filter(p => p.node_id != null); + if (validPermissions.length > 0) { + const rolePermissions = validPermissions.map(p => ({ + role_id: role.id, + node_id: p.node_id, + can_view: p.can_view || false, + can_create: p.can_create || false, + can_edit: p.can_edit || false, + can_delete: p.can_delete || false, + can_alter: p.can_alter || false, + can_import: p.can_import || false, + can_export: p.can_export || false + })); + await models.RolePermission.bulkCreate(rolePermissions, { transaction }); + } + } + + await transaction.commit(); + return await this.findById(role.id); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + async update(id, roleData, permissions = []) { + const transaction = await sequelize.transaction(); + try { + const role = await models.Role.findByPk(id, { transaction }); + if (!role) return null; + + await role.update(roleData, { transaction }); + + // Sync permissions: Delete old permissions first + await models.RolePermission.destroy({ + where: { role_id: id }, + transaction + }); + + // Insert new permissions + if (permissions && permissions.length > 0) { + const validPermissions = permissions.filter(p => p.node_id != null); + if (validPermissions.length > 0) { + const rolePermissions = validPermissions.map(p => ({ + role_id: id, + node_id: p.node_id, + can_view: p.can_view || false, + can_create: p.can_create || false, + can_edit: p.can_edit || false, + can_delete: p.can_delete || false, + can_alter: p.can_alter || false, + can_import: p.can_import || false, + can_export: p.can_export || false + })); + // Use bulkCreate instead of bulkInsert on the model + await models.RolePermission.bulkCreate(rolePermissions, { transaction }); + } + } + + await transaction.commit(); + return await this.findById(id); + } catch (error) { + await transaction.rollback(); + console.error('BULK CREATE ERROR FULL:', error); + if (error.original) { + console.error('ORIGINAL ERROR:', error.original); + } + throw error; + } + } + + async delete(id) { + const transaction = await sequelize.transaction(); + try { + const role = await models.Role.findByPk(id, { transaction }); + if (!role) return false; + + // Delete associations first + await models.RolePermission.destroy({ where: { role_id: id }, transaction }); + await models.UserRole.destroy({ where: { role_id: id }, transaction }); + + await role.destroy({ transaction }); + + await transaction.commit(); + return true; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +} + +export default new RoleRepository(); diff --git a/src/features/authentication/access/role.routes.js b/src/features/authentication/access/role.routes.js new file mode 100644 index 0000000..79a4bef --- /dev/null +++ b/src/features/authentication/access/role.routes.js @@ -0,0 +1,57 @@ +import { Router } from 'express'; +import controller from './role.controller.js'; +import { authenticate } from '../../../shared/middleware/auth.middleware.js'; +import { authorize } from '../../../shared/middleware/permission.middleware.js'; + +const router = Router(); + +router.get( + '/', + authenticate, + authorize(['settings.roles.view']), + controller.getAll +); + +router.get( + '/by-tenant/:tenantId', + authenticate, + authorize([]), + controller.getByTenant +); + +router.get( + '/permissions', + authenticate, + authorize(['settings.roles.view']), + controller.getPermissions +); + +router.get( + '/:id', + authenticate, + authorize(['settings.roles.view']), + controller.getById +); + +router.post( + '/', + authenticate, + authorize(['settings.roles.create']), + controller.create +); + +router.put( + '/:id', + authenticate, + authorize(['settings.roles.edit']), + controller.update +); + +router.delete( + '/:id', + authenticate, + authorize(['settings.roles.delete']), + controller.delete +); + +export default router; diff --git a/src/features/authentication/access/role.service.js b/src/features/authentication/access/role.service.js new file mode 100644 index 0000000..46869fe --- /dev/null +++ b/src/features/authentication/access/role.service.js @@ -0,0 +1,75 @@ +import repository from './role.repository.js'; +import { models } from '../../../shared/database/models.js'; + +export class RoleService { + async getAll(query = {}) { + return await repository.findAll(); + } + + async getByTenant(tenantId) { + if (tenantId === 'all') { + return await repository.findAll(); + } + return await repository.findAll({ + where: tenantId + ? { tenant_id: tenantId } + : { tenant_id: null } + }); + } + + async getById(id) { + const record = await repository.findById(id); + if (!record) { + throw new Error('Role not found'); + } + return record; + } + + async create(data) { + const { role_name, description, permissions, tenant_id } = data; + // Generate role code from name: Admin Editor -> ADMIN_EDITOR + const role_code = role_name.toUpperCase().replace(/[^A-Z0-9]/g, '_'); + + return await repository.create({ + role_name, + role_code, + description, + tenant_id: tenant_id || null, + role_type: tenant_id ? 'tenant' : 'platform', + is_system_role: false, + status: true + }, permissions); + } + + async update(id, data) { + const { role_name, description, permissions, status } = data; + const roleData = {}; + if (role_name) { + roleData.role_name = role_name; + roleData.role_code = role_name.toUpperCase().replace(/[^A-Z0-9]/g, '_'); + } + if (description !== undefined) { + roleData.description = description; + } + if (status !== undefined) { + roleData.status = status; + } + + return await repository.update(id, roleData, permissions); + } + + async delete(id) { + const deleted = await repository.delete(id); + if (!deleted) { + throw new Error('Role not found'); + } + return true; + } + + // Get all permission nodes currently in database + async getPermissionNodes() { + return await models.PermissionNode.findAll(); + } +} + +export default new RoleService(); diff --git a/src/features/authentication/auth/auth.controller.js b/src/features/authentication/auth/auth.controller.js index a34eac5..557efbe 100644 --- a/src/features/authentication/auth/auth.controller.js +++ b/src/features/authentication/auth/auth.controller.js @@ -28,6 +28,44 @@ export class AuthController { next(error); } } + async acceptInvite(req, res, next) { + try { + const { email, tempPassword, newPassword } = req.body; + const result = await authService.acceptInvite({ email, tempPassword, newPassword }); + res.status(200).json({ + success: true, + message: 'Invitation accepted successfully', + data: result + }); + } catch (error) { + next(error); + } + } + + async forgotPassword(req, res, next) { + try { + const result = await authService.forgotPassword(req.body); + res.status(200).json({ + success: true, + message: result.message + }); + } catch (error) { + next(error); + } + } + + async resetPassword(req, res, next) { + try { + const result = await authService.resetPassword(req.body); + res.status(200).json({ + success: true, + message: result.message, + data: result + }); + } catch (error) { + next(error); + } + } } export default new AuthController(); diff --git a/src/features/authentication/auth/auth.repository.js b/src/features/authentication/auth/auth.repository.js index 0f96905..d7c9ec5 100644 --- a/src/features/authentication/auth/auth.repository.js +++ b/src/features/authentication/auth/auth.repository.js @@ -1,16 +1,16 @@ -import { Auth } from './auth.model.js'; +import { models } from '../../../shared/database/models.js'; export class AuthRepository { async findAll(options = {}) { - return await Auth.findAll(options); + return await models.Auth.findAll(options); } async findById(id, options = {}) { - return await Auth.findByPk(id, options); + return await models.Auth.findByPk(id, options); } async create(data, options = {}) { - return await Auth.create(data, options); + return await models.Auth.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/authentication/auth/auth.routes.js b/src/features/authentication/auth/auth.routes.js index e250682..28d29f3 100644 --- a/src/features/authentication/auth/auth.routes.js +++ b/src/features/authentication/auth/auth.routes.js @@ -64,4 +64,19 @@ router.post( controller.refreshToken ); +router.post( + '/accept-invite', + controller.acceptInvite +); + +router.post( + '/forgot-password', + controller.forgotPassword +); + +router.post( + '/reset-password', + controller.resetPassword +); + export default router; diff --git a/src/features/authentication/auth/auth.service.js b/src/features/authentication/auth/auth.service.js index 832b25a..5bd72da 100644 --- a/src/features/authentication/auth/auth.service.js +++ b/src/features/authentication/auth/auth.service.js @@ -1,7 +1,8 @@ import { models } from '../../../shared/database/models.js'; -const { User, Role, PermissionNode, Tenant } = models; import { generateToken, generateRefreshToken, verifyRefreshToken } from '../../../utils/helpers/jwt.utils.js'; import { ApiError } from '../../../utils/helpers/ApiError.utils.js'; +import { sendEmail } from '../../../shared/utils/email.js'; +import crypto from 'crypto'; const formatPermissions = (roles) => { const permissionMap = {}; @@ -29,6 +30,7 @@ const formatPermissions = (roles) => { }; export const login = async ({ email, password }) => { + const { User, Role, PermissionNode, Tenant } = models; const user = await User.findOne({ where: { email }, include: [ @@ -77,6 +79,13 @@ export const login = async ({ email, password }) => { const permissions = formatPermissions(user.roles); + const isSuperAdmin = user.roles.some(r => r.role_code === 'SUPER_ADMIN' && r.UserRole && r.UserRole.status === true); + if (isSuperAdmin) { + permissions['*'] = { + view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true + }; + } + return { user: { id: user.id, @@ -93,6 +102,8 @@ export const login = async ({ email, password }) => { }; export const refreshTokenAuth = async (oldRefreshToken) => { + const { User, Role } = models; + if (!oldRefreshToken) { throw new ApiError(401, 'Refresh token is required'); } @@ -128,7 +139,162 @@ export const refreshTokenAuth = async (oldRefreshToken) => { return { accessToken, refreshToken }; }; +export const acceptInvite = async ({ email, tempPassword, newPassword }) => { + const { User, Role, PermissionNode, Tenant } = models; + const user = await User.findOne({ + where: { email }, + include: [ + { + model: Role, + as: 'roles', + include: [ + { + model: PermissionNode, + as: 'permissions', + through: { attributes: ['can_view', 'can_create', 'can_edit', 'can_delete', 'can_alter', 'can_import', 'can_export'] } + } + ], + through: { attributes: ['status'] } + }, + { + model: Tenant, + as: 'tenant' + } + ] + }); + + if (!user) { + throw new ApiError(401, 'Invalid credentials'); + } + + const isMatch = await user.validatePassword(tempPassword); + if (!isMatch) { + throw new ApiError(401, 'Invalid temporary password'); + } + + // Update to new password + user.password_hash = newPassword; + user.last_login_at = new Date(); + await user.save(); + + return { + message: 'Password set successfully. Please login to continue.' + }; +}; + +export const forgotPassword = async ({ email }) => { + const { User } = models; + const user = await User.findOne({ where: { email } }); + if (!user) { + // Return success even if user doesn't exist for security + return { message: 'If the email exists, a reset code was sent' }; + } + + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const expiry = new Date(Date.now() + 15 * 60000); // 15 mins + + user.reset_otp = otp; + user.reset_otp_expiry = expiry; + await user.save(); + + const html = ` +
+

Password Reset

+

You requested a password reset. Your OTP code is:

+

${otp}

+

This code expires in 15 minutes.

+

If you did not request this, please ignore this email.

+
+ `; + + await sendEmail({ + to: email, + subject: 'Your Password Reset OTP', + html + }); + + return { message: 'If the email exists, a reset code was sent' }; +}; + +export const resetPassword = async ({ email, otp, newPassword }) => { + const { User, Role, PermissionNode, Tenant } = models; + const user = await User.findOne({ + where: { email, reset_otp: otp }, + include: [ + { + model: Role, + as: 'roles', + include: [ + { + model: PermissionNode, + as: 'permissions', + through: { attributes: ['can_view', 'can_create', 'can_edit', 'can_delete', 'can_alter', 'can_import', 'can_export'] } + } + ], + through: { attributes: ['status'] } + }, + { + model: Tenant, + as: 'tenant' + } + ] + }); + + if (!user) { + throw new ApiError(400, 'Invalid OTP or email'); + } + + if (!user.reset_otp_expiry || new Date() > user.reset_otp_expiry) { + throw new ApiError(400, 'OTP has expired'); + } + + user.password_hash = newPassword; + user.reset_otp = null; + user.reset_otp_expiry = null; + user.last_login_at = new Date(); + await user.save(); + + const roleIds = user.roles.map(r => r.id); + + const payload = { + user_id: user.id, + tenant_id: user.tenant_id, + user_type: user.user_type, + role_ids: roleIds + }; + + const accessToken = generateToken(payload); + const refreshToken = generateRefreshToken(payload); + + const permissions = formatPermissions(user.roles); + + const isSuperAdmin = user.roles.some(r => r.role_code === 'SUPER_ADMIN' && r.UserRole && r.UserRole.status === true); + if (isSuperAdmin) { + permissions['*'] = { + view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true + }; + } + + return { + message: 'Password reset successful', + user: { + id: user.id, + name: user.user_name, + email: user.email, + type: user.user_type, + tenant: user.tenant ? { id: user.tenant.id, name: user.tenant.tenant_name } : null, + roles: user.roles ? user.roles.filter(r => r.UserRole && r.UserRole.status === true).map(r => ({ id: r.id, name: r.role_name, code: r.role_code })) : [] + }, + accessToken, + refreshToken, + permissions + }; +}; + export default { login, - refreshToken: refreshTokenAuth + refreshToken: refreshTokenAuth, + acceptInvite, + forgotPassword, + resetPassword }; diff --git a/src/features/authentication/index.js b/src/features/authentication/index.js index b5319ce..3d50091 100644 --- a/src/features/authentication/index.js +++ b/src/features/authentication/index.js @@ -1,8 +1,12 @@ import { Router } from 'express'; import authRouter from './auth/auth.routes.js'; +import rolesRouter from './access/role.routes.js'; +import usersRouter from './users/user.routes.js'; const router = Router(); router.use('/auth', authRouter); +router.use('/roles', rolesRouter); +router.use('/users', usersRouter); export default router; diff --git a/src/features/authentication/users/user.controller.js b/src/features/authentication/users/user.controller.js new file mode 100644 index 0000000..1c71064 --- /dev/null +++ b/src/features/authentication/users/user.controller.js @@ -0,0 +1,50 @@ +import service from './user.service.js'; + +export class UserController { + async getAll(req, res, next) { + try { + const records = await service.getAll(req.query); + return res.status(200).json({ success: true, data: records }); + } catch (error) { + next(error); + } + } + + async getById(req, res, next) { + try { + const record = await service.getById(req.params.id); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async invite(req, res, next) { + try { + const record = await service.inviteUser(req.body, req.user); + return res.status(201).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async update(req, res, next) { + try { + const record = await service.update(req.params.id, req.body); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async delete(req, res, next) { + try { + await service.delete(req.params.id); + return res.status(200).json({ success: true, message: 'User deleted successfully' }); + } catch (error) { + next(error); + } + } +} + +export default new UserController(); diff --git a/src/features/authentication/users/user.model.js b/src/features/authentication/users/user.model.js index 0cead26..d61539f 100644 --- a/src/features/authentication/users/user.model.js +++ b/src/features/authentication/users/user.model.js @@ -31,7 +31,22 @@ export default (sequelize) => { }, password_hash: { type: DataTypes.STRING(255), - allowNull: false + allowNull: true, + validate: { + requirePasswordIfNotSaaS(value) { + if (!this.is_saas_user && !value) { + throw new Error('Password is required for native users'); + } + } + } + }, + is_saas_user: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + saas_user_id: { + type: DataTypes.STRING(255), + allowNull: true }, user_code: { type: DataTypes.STRING(50) @@ -45,6 +60,14 @@ export default (sequelize) => { last_login_at: { type: DataTypes.DATE }, + reset_otp: { + type: DataTypes.STRING(10), + allowNull: true + }, + reset_otp_expiry: { + type: DataTypes.DATE, + allowNull: true + }, status: { type: DataTypes.BOOLEAN, defaultValue: true diff --git a/src/features/authentication/users/user.repository.js b/src/features/authentication/users/user.repository.js new file mode 100644 index 0000000..1a6271c --- /dev/null +++ b/src/features/authentication/users/user.repository.js @@ -0,0 +1,110 @@ +import { models } from '../../../shared/database/models.js'; +import sequelize from '../../../shared/database/connection.js'; + +export class UserRepository { + async findAll(options = {}) { + return await models.User.findAll({ + attributes: { exclude: ['password_hash'] }, + include: [ + { + model: models.Role, + as: 'roles', + through: { attributes: ['status'] } + } + ], + ...options + }); + } + + async findById(id, options = {}) { + return await models.User.findByPk(id, { + attributes: { exclude: ['password_hash'] }, + include: [ + { + model: models.Role, + as: 'roles', + through: { attributes: ['status'] } + } + ], + ...options + }); + } + + async findByEmail(email) { + return await models.User.findOne({ + where: { email } + }); + } + + async create(userData, roleIds = []) { + const transaction = await sequelize.transaction(); + try { + const user = await models.User.create(userData, { transaction }); + + if (roleIds && roleIds.length > 0) { + const userRoles = roleIds.map(roleId => ({ + user_id: user.id, + role_id: roleId, + status: true + })); + await models.UserRole.bulkCreate(userRoles, { transaction }); + } + + await transaction.commit(); + return await this.findById(user.id); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + async update(id, userData, roleIds = null) { + const transaction = await sequelize.transaction(); + try { + const user = await models.User.findByPk(id, { transaction }); + if (!user) return null; + + await user.update(userData, { transaction }); + + if (Array.isArray(roleIds)) { + // Delete old role mappings + await models.UserRole.destroy({ where: { user_id: id }, transaction }); + + // Add new ones + if (roleIds.length > 0) { + const userRoles = roleIds.map(roleId => ({ + user_id: id, + role_id: roleId, + status: true + })); + await models.UserRole.bulkCreate(userRoles, { transaction }); + } + } + + await transaction.commit(); + return await this.findById(id); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + async delete(id) { + const transaction = await sequelize.transaction(); + try { + const user = await models.User.findByPk(id, { transaction }); + if (!user) return false; + + await models.UserRole.destroy({ where: { user_id: id }, transaction }); + await user.destroy({ transaction }); + + await transaction.commit(); + return true; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +} + +export default new UserRepository(); diff --git a/src/features/authentication/users/user.routes.js b/src/features/authentication/users/user.routes.js new file mode 100644 index 0000000..a1ca998 --- /dev/null +++ b/src/features/authentication/users/user.routes.js @@ -0,0 +1,43 @@ +import { Router } from 'express'; +import controller from './user.controller.js'; +import { authenticate } from '../../../shared/middleware/auth.middleware.js'; +import { authorize } from '../../../shared/middleware/permission.middleware.js'; + +const router = Router(); + +router.get( + '/', + authenticate, + authorize(['settings.users.view']), + controller.getAll +); + +router.get( + '/:id', + authenticate, + authorize(['settings.users.view']), + controller.getById +); + +router.post( + '/invite', + authenticate, + authorize(['settings.users.create']), + controller.invite +); + +router.put( + '/:id', + authenticate, + authorize(['settings.users.edit']), + controller.update +); + +router.delete( + '/:id', + authenticate, + authorize(['settings.users.delete']), + controller.delete +); + +export default router; diff --git a/src/features/authentication/users/user.service.js b/src/features/authentication/users/user.service.js new file mode 100644 index 0000000..8fcbdac --- /dev/null +++ b/src/features/authentication/users/user.service.js @@ -0,0 +1,92 @@ +import repository from './user.repository.js'; +import { sendInvitationEmail } from '../../../shared/utils/email.js'; +import bcrypt from 'bcrypt'; +import { models } from '../../../shared/database/models.js'; + +export class UserService { + async getAll(query = {}) { + return await repository.findAll(); + } + + async getById(id) { + const record = await repository.findById(id); + if (!record) { + throw new Error('User not found'); + } + return record; + } + + async inviteUser(data, userContext = {}) { + const { email, user_name, tenant_id, role_ids } = data; + + // Check if user already exists + const existingUser = await repository.findByEmail(email); + if (existingUser) { + throw new Error('User with this email already exists'); + } + + // Generate temporary password + const tempPassword = `Welcome@${Math.floor(100000 + Math.random() * 900000)}`; + + // Get the name of the first role for the email template + let roleName = 'Member'; + if (role_ids && role_ids.length > 0) { + const role = await models.Role.findByPk(role_ids[0]); + if (role) { + roleName = role.role_name; + } + } + + // Create user in the database + const user = await repository.create({ + email, + password_hash: tempPassword, + user_name: user_name || email.split('@')[0], + status: true, + tenant_id: tenant_id !== undefined ? tenant_id : (userContext.tenant_id || null) + }, role_ids); + + // Send invitation email + const inviteLink = `${process.env.CORS_ORIGIN || 'http://localhost:5173'}/accept-invite?email=${encodeURIComponent(email)}&temp=${encodeURIComponent(tempPassword)}`; + + // Attempt to get tenant name for organization + let organizationName = "Maskan"; + if (user.tenant_id) { + const tenant = await models.Tenant.findByPk(user.tenant_id); + if (tenant) { + organizationName = tenant.tenant_name; + } + } + + await sendInvitationEmail({ + to: email, + inviteLink, + roleName, + email, + recipientName: user.user_name || email.split('@')[0], + organizationName + }); + + return user; + } + + async update(id, data) { + const { user_name, phone, status, role_ids } = data; + const userData = {}; + if (user_name !== undefined) userData.user_name = user_name; + if (phone !== undefined) userData.phone = phone; + if (status !== undefined) userData.status = status; + + return await repository.update(id, userData, role_ids); + } + + async delete(id) { + const deleted = await repository.delete(id); + if (!deleted) { + throw new Error('User not found'); + } + return true; + } +} + +export default new UserService(); diff --git a/src/features/brands/brands/brand.repository.js b/src/features/brands/brands/brand.repository.js index 1c6fea2..fbc2380 100644 --- a/src/features/brands/brands/brand.repository.js +++ b/src/features/brands/brands/brand.repository.js @@ -1,16 +1,16 @@ -import { Brand } from './brand.model.js'; +import { models } from '../../../shared/database/models.js'; export class BrandRepository { async findAll(options = {}) { - return await Brand.findAll(options); + return await models.Brand.findAll(options); } async findById(id, options = {}) { - return await Brand.findByPk(id, options); + return await models.Brand.findByPk(id, options); } async create(data, options = {}) { - return await Brand.create(data, options); + return await models.Brand.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/catalogs/catalogs/catalog.repository.js b/src/features/catalogs/catalogs/catalog.repository.js index 6b7624c..1e80a45 100644 --- a/src/features/catalogs/catalogs/catalog.repository.js +++ b/src/features/catalogs/catalogs/catalog.repository.js @@ -1,16 +1,16 @@ -import { Catalog } from './catalog.model.js'; +import { models } from '../../../shared/database/models.js'; export class CatalogRepository { async findAll(options = {}) { - return await Catalog.findAll(options); + return await models.Catalog.findAll(options); } async findById(id, options = {}) { - return await Catalog.findByPk(id, options); + return await models.Catalog.findByPk(id, options); } async create(data, options = {}) { - return await Catalog.create(data, options); + return await models.Catalog.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/categories/categories/categorie.repository.js b/src/features/categories/categories/categorie.repository.js index 07aad65..73996b5 100644 --- a/src/features/categories/categories/categorie.repository.js +++ b/src/features/categories/categories/categorie.repository.js @@ -1,16 +1,16 @@ -import { Categorie } from './categorie.model.js'; +import { models } from '../../../shared/database/models.js'; export class CategorieRepository { async findAll(options = {}) { - return await Categorie.findAll(options); + return await models.Categorie.findAll(options); } async findById(id, options = {}) { - return await Categorie.findByPk(id, options); + return await models.Categorie.findByPk(id, options); } async create(data, options = {}) { - return await Categorie.create(data, options); + return await models.Categorie.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/channels/channels/channel.repository.js b/src/features/channels/channels/channel.repository.js index d0b7104..c86e9bc 100644 --- a/src/features/channels/channels/channel.repository.js +++ b/src/features/channels/channels/channel.repository.js @@ -1,16 +1,16 @@ -import { Channel } from './channel.model.js'; +import { models } from '../../../shared/database/models.js'; export class ChannelRepository { async findAll(options = {}) { - return await Channel.findAll(options); + return await models.Channel.findAll(options); } async findById(id, options = {}) { - return await Channel.findByPk(id, options); + return await models.Channel.findByPk(id, options); } async create(data, options = {}) { - return await Channel.create(data, options); + return await models.Channel.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/dashboard/metrics/metric.repository.js b/src/features/dashboard/metrics/metric.repository.js index 036f1b7..40a9c9f 100644 --- a/src/features/dashboard/metrics/metric.repository.js +++ b/src/features/dashboard/metrics/metric.repository.js @@ -1,16 +1,16 @@ -import { Metric } from './metric.model.js'; +import { models } from '../../../shared/database/models.js'; export class MetricRepository { async findAll(options = {}) { - return await Metric.findAll(options); + return await models.Metric.findAll(options); } async findById(id, options = {}) { - return await Metric.findByPk(id, options); + return await models.Metric.findByPk(id, options); } async create(data, options = {}) { - return await Metric.create(data, options); + return await models.Metric.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/exports/exports/export.repository.js b/src/features/exports/exports/export.repository.js index c2147bf..b7cb880 100644 --- a/src/features/exports/exports/export.repository.js +++ b/src/features/exports/exports/export.repository.js @@ -1,16 +1,16 @@ -import { Export } from './export.model.js'; +import { models } from '../../../shared/database/models.js'; export class ExportRepository { async findAll(options = {}) { - return await Export.findAll(options); + return await models.Export.findAll(options); } async findById(id, options = {}) { - return await Export.findByPk(id, options); + return await models.Export.findByPk(id, options); } async create(data, options = {}) { - return await Export.create(data, options); + return await models.Export.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/imports/imports/import.repository.js b/src/features/imports/imports/import.repository.js index c611bae..051b010 100644 --- a/src/features/imports/imports/import.repository.js +++ b/src/features/imports/imports/import.repository.js @@ -1,16 +1,16 @@ -import { Import } from './import.model.js'; +import { models } from '../../../shared/database/models.js'; export class ImportRepository { async findAll(options = {}) { - return await Import.findAll(options); + return await models.Import.findAll(options); } async findById(id, options = {}) { - return await Import.findByPk(id, options); + return await models.Import.findByPk(id, options); } async create(data, options = {}) { - return await Import.create(data, options); + return await models.Import.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/index.js b/src/features/index.js index c786481..e6cd96b 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -20,28 +20,30 @@ import notificationsRouter from './notifications/index.js'; import reportsRouter from './reports/index.js'; import settingsRouter from './settings/index.js'; import auditLogsRouter from './auditLogs/index.js'; +import unitsRouter from './units/index.js'; export default function registerRoutes(app) { - app.use('/api/v1/auth', authenticationRouter); -app.use('/api/v1/organization', organizationRouter); -app.use('/api/v1/dashboard', dashboardRouter); -app.use('/api/v1/products', productsRouter); -app.use('/api/v1/catalogs', catalogsRouter); -app.use('/api/v1/categories', categoriesRouter); -app.use('/api/v1/brands', brandsRouter); -app.use('/api/v1/attributes', attributesRouter); -app.use('/api/v1/media', mediaRouter); -app.use('/api/v1/pricing', pricingRouter); -app.use('/api/v1/inventory', inventoryRouter); -app.use('/api/v1/suppliers', suppliersRouter); -app.use('/api/v1/localization', localizationRouter); -app.use('/api/v1/workflows', workflowsRouter); -app.use('/api/v1/integrations', integrationsRouter); -app.use('/api/v1/channels', channelsRouter); -app.use('/api/v1/imports', importsRouter); -app.use('/api/v1/exports', exportsRouter); -app.use('/api/v1/notifications', notificationsRouter); -app.use('/api/v1/reports', reportsRouter); -app.use('/api/v1/settings', settingsRouter); -app.use('/api/v1/auditLogs', auditLogsRouter); + app.use('/api/v1', authenticationRouter); + app.use('/api/v1', organizationRouter); + app.use('/api/v1', dashboardRouter); + app.use('/api/v1', productsRouter); + app.use('/api/v1', catalogsRouter); + app.use('/api/v1', categoriesRouter); + app.use('/api/v1', brandsRouter); + app.use('/api/v1', attributesRouter); + app.use('/api/v1', mediaRouter); + app.use('/api/v1', pricingRouter); + app.use('/api/v1', inventoryRouter); + app.use('/api/v1', suppliersRouter); + app.use('/api/v1', localizationRouter); + app.use('/api/v1', workflowsRouter); + app.use('/api/v1', integrationsRouter); + app.use('/api/v1', channelsRouter); + app.use('/api/v1', importsRouter); + app.use('/api/v1', exportsRouter); + app.use('/api/v1', notificationsRouter); + app.use('/api/v1', reportsRouter); + app.use('/api/v1', settingsRouter); + app.use('/api/v1', auditLogsRouter); + app.use('/api/v1', unitsRouter); } diff --git a/src/features/integrations/integrations/integration.repository.js b/src/features/integrations/integrations/integration.repository.js index 0da3f98..1f34fe0 100644 --- a/src/features/integrations/integrations/integration.repository.js +++ b/src/features/integrations/integrations/integration.repository.js @@ -1,16 +1,16 @@ -import { Integration } from './integration.model.js'; +import { models } from '../../../shared/database/models.js'; export class IntegrationRepository { async findAll(options = {}) { - return await Integration.findAll(options); + return await models.Integration.findAll(options); } async findById(id, options = {}) { - return await Integration.findByPk(id, options); + return await models.Integration.findByPk(id, options); } async create(data, options = {}) { - return await Integration.create(data, options); + return await models.Integration.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/inventory/inventory/inventory.repository.js b/src/features/inventory/inventory/inventory.repository.js index 4035b4f..eeb8172 100644 --- a/src/features/inventory/inventory/inventory.repository.js +++ b/src/features/inventory/inventory/inventory.repository.js @@ -1,16 +1,16 @@ -import { Inventory } from './inventory.model.js'; +import { models } from '../../../shared/database/models.js'; export class InventoryRepository { async findAll(options = {}) { - return await Inventory.findAll(options); + return await models.Inventory.findAll(options); } async findById(id, options = {}) { - return await Inventory.findByPk(id, options); + return await models.Inventory.findByPk(id, options); } async create(data, options = {}) { - return await Inventory.create(data, options); + return await models.Inventory.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/inventory/inventory/inventory.service.js b/src/features/inventory/inventory/inventory.service.js index ce1cd71..518ed63 100644 --- a/src/features/inventory/inventory/inventory.service.js +++ b/src/features/inventory/inventory/inventory.service.js @@ -1,6 +1,7 @@ import repository from './inventory.repository.js'; import { SocketService } from '../../../shared/services/socket.service.js'; import { AuditService } from '../../../shared/services/audit.service.js'; +import { ApiError } from '../../../utils/helpers/ApiError.utils.js'; export class InventoryService { async getAll(query = {}) { @@ -11,7 +12,7 @@ export class InventoryService { async getById(id) { const record = await repository.findById(id); if (!record) { - throw new Error('Inventory not found'); + throw new ApiError(404, 'Inventory not found'); } return record; } @@ -37,7 +38,7 @@ export class InventoryService { async update(id, data, userContext = {}) { const record = await repository.update(id, data); if (!record) { - throw new Error('Inventory not found'); + throw new ApiError(404, 'Inventory not found'); } SocketService.broadcast('inventory:updated', record); @@ -56,7 +57,7 @@ export class InventoryService { async delete(id, userContext = {}) { const deleted = await repository.delete(id); if (!deleted) { - throw new Error('Inventory not found'); + throw new ApiError(404, 'Inventory not found'); } SocketService.broadcast('inventory:deleted', { id }); diff --git a/src/features/localization/localization/localization.repository.js b/src/features/localization/localization/localization.repository.js index 307ab0b..aa65755 100644 --- a/src/features/localization/localization/localization.repository.js +++ b/src/features/localization/localization/localization.repository.js @@ -1,16 +1,16 @@ -import { Localization } from './localization.model.js'; +import { models } from '../../../shared/database/models.js'; export class LocalizationRepository { async findAll(options = {}) { - return await Localization.findAll(options); + return await models.Localization.findAll(options); } async findById(id, options = {}) { - return await Localization.findByPk(id, options); + return await models.Localization.findByPk(id, options); } async create(data, options = {}) { - return await Localization.create(data, options); + return await models.Localization.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/media/assetFamilies/assetFamily.controller.js b/src/features/media/assetFamilies/assetFamily.controller.js new file mode 100644 index 0000000..d927488 --- /dev/null +++ b/src/features/media/assetFamilies/assetFamily.controller.js @@ -0,0 +1,50 @@ +import service from './assetFamily.service.js'; + +export class AssetFamilyController { + async getAll(req, res, next) { + try { + const records = await service.getAll(req.query); + return res.status(200).json({ success: true, data: records }); + } catch (error) { + next(error); + } + } + + async getById(req, res, next) { + try { + const record = await service.getById(req.params.id); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async create(req, res, next) { + try { + const record = await service.create(req.body, req.user); + return res.status(201).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async update(req, res, next) { + try { + const record = await service.update(req.params.id, req.body, req.user); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async delete(req, res, next) { + try { + const success = await service.delete(req.params.id, req.user); + return res.status(200).json({ success, message: 'AssetFamily deleted successfully' }); + } catch (error) { + next(error); + } + } +} + +export default new AssetFamilyController(); diff --git a/src/features/media/assetFamilies/assetFamily.model.js b/src/features/media/assetFamilies/assetFamily.model.js new file mode 100644 index 0000000..9e45657 --- /dev/null +++ b/src/features/media/assetFamilies/assetFamily.model.js @@ -0,0 +1,32 @@ +import { Model, DataTypes } from 'sequelize'; + +export class AssetFamily extends Model { + static associate(models) {} +} + +export default (sequelize) => { + AssetFamily.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + status: { + type: DataTypes.STRING, + defaultValue: 'active' + } + }, { + sequelize, + modelName: 'AssetFamily', + tableName: 'asset_families', + timestamps: true, + underscored: true + }); + + return AssetFamily; +}; diff --git a/src/features/media/assetFamilies/assetFamily.repository.js b/src/features/media/assetFamilies/assetFamily.repository.js new file mode 100644 index 0000000..f61ef2a --- /dev/null +++ b/src/features/media/assetFamilies/assetFamily.repository.js @@ -0,0 +1,30 @@ +import { models } from '../../../shared/database/models.js'; + +export class AssetFamilyRepository { + async findAll(options = {}) { + return await models.AssetFamily.findAll(options); + } + + async findById(id, options = {}) { + return await models.AssetFamily.findByPk(id, options); + } + + async create(data, options = {}) { + return await models.AssetFamily.create(data, options); + } + + async update(id, data, options = {}) { + const record = await this.findById(id, options); + if (!record) return null; + return await record.update(data, options); + } + + async delete(id, options = {}) { + const record = await this.findById(id, options); + if (!record) return false; + await record.destroy(options); + return true; + } +} + +export default new AssetFamilyRepository(); diff --git a/src/features/media/assetFamilies/assetFamily.routes.js b/src/features/media/assetFamilies/assetFamily.routes.js new file mode 100644 index 0000000..3bf9c8f --- /dev/null +++ b/src/features/media/assetFamilies/assetFamily.routes.js @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import controller from './assetFamily.controller.js'; +import { authenticate } from '../../../shared/middleware/auth.middleware.js'; +import { validate } from '../../../shared/middleware/validation.middleware.js'; +import { authorize } from '../../../shared/middleware/permission.middleware.js'; +import { audit } from '../../../shared/middleware/audit.middleware.js'; +import { + createValidation, + updateValidation, + deleteValidation, + getByIdValidation +} from './assetFamily.validation.js'; + +const router = Router(); + +router.get( + '/', + authenticate, + authorize(['read:media']), + controller.getAll +); + +router.get( + '/:id', + authenticate, + authorize(['read:media']), + getByIdValidation, + validate, + controller.getById +); + +router.post( + '/', + authenticate, + authorize(['write:media']), + createValidation, + validate, + audit('CREATE_ASSET_FAMILY'), + controller.create +); + +router.put( + '/:id', + authenticate, + authorize(['write:media']), + updateValidation, + validate, + audit('UPDATE_ASSET_FAMILY'), + controller.update +); + +router.delete( + '/:id', + authenticate, + authorize(['write:media']), + deleteValidation, + validate, + audit('DELETE_ASSET_FAMILY'), + controller.delete +); + +export default router; diff --git a/src/features/media/assetFamilies/assetFamily.service.js b/src/features/media/assetFamilies/assetFamily.service.js new file mode 100644 index 0000000..c14dffb --- /dev/null +++ b/src/features/media/assetFamilies/assetFamily.service.js @@ -0,0 +1,72 @@ +import repository from './assetFamily.repository.js'; +import { SocketService } from '../../../shared/services/socket.service.js'; +import { AuditService } from '../../../shared/services/audit.service.js'; + +export class AssetFamilyService { + async getAll(query = {}) { + return await repository.findAll(); + } + + async getById(id) { + const record = await repository.findById(id); + if (!record) { + throw new Error('AssetFamily not found'); + } + return record; + } + + async create(data, userContext = {}) { + const record = await repository.create(data); + + SocketService.broadcast('assetFamily:created', record); + + await AuditService.log({ + action: 'CREATE', + resource: 'AssetFamily', + resourceId: record.id, + userId: userContext.id || 'system', + details: data + }); + + return record; + } + + async update(id, data, userContext = {}) { + const record = await repository.update(id, data); + if (!record) { + throw new Error('AssetFamily not found'); + } + + SocketService.broadcast('assetFamily:updated', record); + + await AuditService.log({ + action: 'UPDATE', + resource: 'AssetFamily', + resourceId: id, + userId: userContext.id || 'system', + details: data + }); + + return record; + } + + async delete(id, userContext = {}) { + const deleted = await repository.delete(id); + if (!deleted) { + throw new Error('AssetFamily not found'); + } + + SocketService.broadcast('assetFamily:deleted', { id }); + + await AuditService.log({ + action: 'DELETE', + resource: 'AssetFamily', + resourceId: id, + userId: userContext.id || 'system' + }); + + return true; + } +} + +export default new AssetFamilyService(); diff --git a/src/features/media/assetFamilies/assetFamily.validation.js b/src/features/media/assetFamilies/assetFamily.validation.js new file mode 100644 index 0000000..88ecbe1 --- /dev/null +++ b/src/features/media/assetFamilies/assetFamily.validation.js @@ -0,0 +1,18 @@ +import { body, param } from 'express-validator'; + +export const createValidation = [ + body('name').isString().trim().notEmpty() +]; + +export const updateValidation = [ + param('id').isUUID(), + body('name').optional().isString().trim() +]; + +export const deleteValidation = [ + param('id').isUUID() +]; + +export const getByIdValidation = [ + param('id').isUUID() +]; diff --git a/src/features/media/assetTypes/assetType.controller.js b/src/features/media/assetTypes/assetType.controller.js new file mode 100644 index 0000000..16fdc96 --- /dev/null +++ b/src/features/media/assetTypes/assetType.controller.js @@ -0,0 +1,50 @@ +import service from './assetType.service.js'; + +export class AssetTypeController { + async getAll(req, res, next) { + try { + const records = await service.getAll(req.query); + return res.status(200).json({ success: true, data: records }); + } catch (error) { + next(error); + } + } + + async getById(req, res, next) { + try { + const record = await service.getById(req.params.id); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async create(req, res, next) { + try { + const record = await service.create(req.body, req.user); + return res.status(201).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async update(req, res, next) { + try { + const record = await service.update(req.params.id, req.body, req.user); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async delete(req, res, next) { + try { + const success = await service.delete(req.params.id, req.user); + return res.status(200).json({ success, message: 'AssetType deleted successfully' }); + } catch (error) { + next(error); + } + } +} + +export default new AssetTypeController(); diff --git a/src/features/media/assetTypes/assetType.model.js b/src/features/media/assetTypes/assetType.model.js new file mode 100644 index 0000000..c94f701 --- /dev/null +++ b/src/features/media/assetTypes/assetType.model.js @@ -0,0 +1,53 @@ +import { Model, DataTypes } from 'sequelize'; + +export class AssetType extends Model { + static associate(models) {} +} + +export default (sequelize) => { + AssetType.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + code: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + description: { + type: DataTypes.TEXT, + allowNull: true + }, + status: { + type: DataTypes.STRING, + defaultValue: 'active' + }, + is_required: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + category: { + type: DataTypes.STRING, + defaultValue: 'other' + }, + validation: { + type: DataTypes.JSONB, + defaultValue: {} + } + }, { + sequelize, + modelName: 'AssetType', + tableName: 'asset_types', + timestamps: true, + underscored: true + }); + + return AssetType; +}; diff --git a/src/features/media/assetTypes/assetType.repository.js b/src/features/media/assetTypes/assetType.repository.js new file mode 100644 index 0000000..bbc5e34 --- /dev/null +++ b/src/features/media/assetTypes/assetType.repository.js @@ -0,0 +1,30 @@ +import { models } from '../../../shared/database/models.js'; + +export class AssetTypeRepository { + async findAll(options = {}) { + return await models.AssetType.findAll(options); + } + + async findById(id, options = {}) { + return await models.AssetType.findByPk(id, options); + } + + async create(data, options = {}) { + return await models.AssetType.create(data, options); + } + + async update(id, data, options = {}) { + const record = await this.findById(id, options); + if (!record) return null; + return await record.update(data, options); + } + + async delete(id, options = {}) { + const record = await this.findById(id, options); + if (!record) return false; + await record.destroy(options); + return true; + } +} + +export default new AssetTypeRepository(); diff --git a/src/features/media/assetTypes/assetType.routes.js b/src/features/media/assetTypes/assetType.routes.js new file mode 100644 index 0000000..78d82ca --- /dev/null +++ b/src/features/media/assetTypes/assetType.routes.js @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import controller from './assetType.controller.js'; +import { authenticate } from '../../../shared/middleware/auth.middleware.js'; +import { validate } from '../../../shared/middleware/validation.middleware.js'; +import { authorize } from '../../../shared/middleware/permission.middleware.js'; +import { audit } from '../../../shared/middleware/audit.middleware.js'; +import { + createValidation, + updateValidation, + deleteValidation, + getByIdValidation +} from './assetType.validation.js'; + +const router = Router(); + +router.get( + '/', + authenticate, + authorize(['read:media']), + controller.getAll +); + +router.get( + '/:id', + authenticate, + authorize(['read:media']), + getByIdValidation, + validate, + controller.getById +); + +router.post( + '/', + authenticate, + authorize(['write:media']), + createValidation, + validate, + audit('CREATE_ASSET_TYPE'), + controller.create +); + +router.put( + '/:id', + authenticate, + authorize(['write:media']), + updateValidation, + validate, + audit('UPDATE_ASSET_TYPE'), + controller.update +); + +router.delete( + '/:id', + authenticate, + authorize(['write:media']), + deleteValidation, + validate, + audit('DELETE_ASSET_TYPE'), + controller.delete +); + +export default router; diff --git a/src/features/media/assetTypes/assetType.service.js b/src/features/media/assetTypes/assetType.service.js new file mode 100644 index 0000000..4a70fa2 --- /dev/null +++ b/src/features/media/assetTypes/assetType.service.js @@ -0,0 +1,72 @@ +import repository from './assetType.repository.js'; +import { SocketService } from '../../../shared/services/socket.service.js'; +import { AuditService } from '../../../shared/services/audit.service.js'; + +export class AssetTypeService { + async getAll(query = {}) { + return await repository.findAll(); + } + + async getById(id) { + const record = await repository.findById(id); + if (!record) { + throw new Error('AssetType not found'); + } + return record; + } + + async create(data, userContext = {}) { + const record = await repository.create(data); + + SocketService.broadcast('assetType:created', record); + + await AuditService.log({ + action: 'CREATE', + resource: 'AssetType', + resourceId: record.id, + userId: userContext.id || 'system', + details: data + }); + + return record; + } + + async update(id, data, userContext = {}) { + const record = await repository.update(id, data); + if (!record) { + throw new Error('AssetType not found'); + } + + SocketService.broadcast('assetType:updated', record); + + await AuditService.log({ + action: 'UPDATE', + resource: 'AssetType', + resourceId: id, + userId: userContext.id || 'system', + details: data + }); + + return record; + } + + async delete(id, userContext = {}) { + const deleted = await repository.delete(id); + if (!deleted) { + throw new Error('AssetType not found'); + } + + SocketService.broadcast('assetType:deleted', { id }); + + await AuditService.log({ + action: 'DELETE', + resource: 'AssetType', + resourceId: id, + userId: userContext.id || 'system' + }); + + return true; + } +} + +export default new AssetTypeService(); diff --git a/src/features/media/assetTypes/assetType.validation.js b/src/features/media/assetTypes/assetType.validation.js new file mode 100644 index 0000000..5422d9b --- /dev/null +++ b/src/features/media/assetTypes/assetType.validation.js @@ -0,0 +1,19 @@ +import { body, param } from 'express-validator'; + +export const createValidation = [ + body('code').isString().trim().notEmpty(), + body('name').isString().trim().notEmpty() +]; + +export const updateValidation = [ + param('id').isUUID(), + body('name').optional().isString().trim() +]; + +export const deleteValidation = [ + param('id').isUUID() +]; + +export const getByIdValidation = [ + param('id').isUUID() +]; diff --git a/src/features/media/assets/asset.controller.js b/src/features/media/assets/asset.controller.js new file mode 100644 index 0000000..c188075 --- /dev/null +++ b/src/features/media/assets/asset.controller.js @@ -0,0 +1,50 @@ +import service from './asset.service.js'; + +export class AssetController { + async getAll(req, res, next) { + try { + const records = await service.getAll(req.query); + return res.status(200).json({ success: true, data: records }); + } catch (error) { + next(error); + } + } + + async getById(req, res, next) { + try { + const record = await service.getById(req.params.id); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async create(req, res, next) { + try { + const record = await service.create(req.body, req.user); + return res.status(201).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async update(req, res, next) { + try { + const record = await service.update(req.params.id, req.body, req.user); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async delete(req, res, next) { + try { + const success = await service.delete(req.params.id, req.user); + return res.status(200).json({ success, message: 'Asset deleted successfully' }); + } catch (error) { + next(error); + } + } +} + +export default new AssetController(); diff --git a/src/features/media/assets/asset.model.js b/src/features/media/assets/asset.model.js new file mode 100644 index 0000000..f75b4b6 --- /dev/null +++ b/src/features/media/assets/asset.model.js @@ -0,0 +1,32 @@ +import { Model, DataTypes } from 'sequelize'; + +export class Asset extends Model { + static associate(models) {} +} + +export default (sequelize) => { + Asset.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + status: { + type: DataTypes.STRING, + defaultValue: 'active' + } + }, { + sequelize, + modelName: 'Asset', + tableName: 'assets', + timestamps: true, + underscored: true + }); + + return Asset; +}; diff --git a/src/features/media/assets/asset.repository.js b/src/features/media/assets/asset.repository.js new file mode 100644 index 0000000..6ca6e55 --- /dev/null +++ b/src/features/media/assets/asset.repository.js @@ -0,0 +1,30 @@ +import { models } from '../../../shared/database/models.js'; + +export class AssetRepository { + async findAll(options = {}) { + return await models.Asset.findAll(options); + } + + async findById(id, options = {}) { + return await models.Asset.findByPk(id, options); + } + + async create(data, options = {}) { + return await models.Asset.create(data, options); + } + + async update(id, data, options = {}) { + const record = await this.findById(id, options); + if (!record) return null; + return await record.update(data, options); + } + + async delete(id, options = {}) { + const record = await this.findById(id, options); + if (!record) return false; + await record.destroy(options); + return true; + } +} + +export default new AssetRepository(); diff --git a/src/features/media/assets/asset.routes.js b/src/features/media/assets/asset.routes.js new file mode 100644 index 0000000..62f8b61 --- /dev/null +++ b/src/features/media/assets/asset.routes.js @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import controller from './asset.controller.js'; +import { authenticate } from '../../../shared/middleware/auth.middleware.js'; +import { validate } from '../../../shared/middleware/validation.middleware.js'; +import { authorize } from '../../../shared/middleware/permission.middleware.js'; +import { audit } from '../../../shared/middleware/audit.middleware.js'; +import { + createValidation, + updateValidation, + deleteValidation, + getByIdValidation +} from './asset.validation.js'; + +const router = Router(); + +router.get( + '/', + authenticate, + authorize(['read:media']), + controller.getAll +); + +router.get( + '/:id', + authenticate, + authorize(['read:media']), + getByIdValidation, + validate, + controller.getById +); + +router.post( + '/', + authenticate, + authorize(['write:media']), + createValidation, + validate, + audit('CREATE_ASSET'), + controller.create +); + +router.put( + '/:id', + authenticate, + authorize(['write:media']), + updateValidation, + validate, + audit('UPDATE_ASSET'), + controller.update +); + +router.delete( + '/:id', + authenticate, + authorize(['write:media']), + deleteValidation, + validate, + audit('DELETE_ASSET'), + controller.delete +); + +export default router; diff --git a/src/features/media/assets/asset.service.js b/src/features/media/assets/asset.service.js new file mode 100644 index 0000000..ebe2cb6 --- /dev/null +++ b/src/features/media/assets/asset.service.js @@ -0,0 +1,72 @@ +import repository from './asset.repository.js'; +import { SocketService } from '../../../shared/services/socket.service.js'; +import { AuditService } from '../../../shared/services/audit.service.js'; + +export class AssetService { + async getAll(query = {}) { + return await repository.findAll(); + } + + async getById(id) { + const record = await repository.findById(id); + if (!record) { + throw new Error('Asset not found'); + } + return record; + } + + async create(data, userContext = {}) { + const record = await repository.create(data); + + SocketService.broadcast('asset:created', record); + + await AuditService.log({ + action: 'CREATE', + resource: 'Asset', + resourceId: record.id, + userId: userContext.id || 'system', + details: data + }); + + return record; + } + + async update(id, data, userContext = {}) { + const record = await repository.update(id, data); + if (!record) { + throw new Error('Asset not found'); + } + + SocketService.broadcast('asset:updated', record); + + await AuditService.log({ + action: 'UPDATE', + resource: 'Asset', + resourceId: id, + userId: userContext.id || 'system', + details: data + }); + + return record; + } + + async delete(id, userContext = {}) { + const deleted = await repository.delete(id); + if (!deleted) { + throw new Error('Asset not found'); + } + + SocketService.broadcast('asset:deleted', { id }); + + await AuditService.log({ + action: 'DELETE', + resource: 'Asset', + resourceId: id, + userId: userContext.id || 'system' + }); + + return true; + } +} + +export default new AssetService(); diff --git a/src/features/media/assets/asset.validation.js b/src/features/media/assets/asset.validation.js new file mode 100644 index 0000000..88ecbe1 --- /dev/null +++ b/src/features/media/assets/asset.validation.js @@ -0,0 +1,18 @@ +import { body, param } from 'express-validator'; + +export const createValidation = [ + body('name').isString().trim().notEmpty() +]; + +export const updateValidation = [ + param('id').isUUID(), + body('name').optional().isString().trim() +]; + +export const deleteValidation = [ + param('id').isUUID() +]; + +export const getByIdValidation = [ + param('id').isUUID() +]; diff --git a/src/features/media/index.js b/src/features/media/index.js index 7b75092..dfec128 100644 --- a/src/features/media/index.js +++ b/src/features/media/index.js @@ -1,8 +1,14 @@ import { Router } from 'express'; import mediaRouter from './media/media.routes.js'; +import assetTypesRouter from './assetTypes/assetType.routes.js'; +import assetFamiliesRouter from './assetFamilies/assetFamily.routes.js'; +import assetsRouter from './assets/asset.routes.js'; const router = Router(); router.use('/media', mediaRouter); +router.use('/asset-types', assetTypesRouter); +router.use('/asset-families', assetFamiliesRouter); +router.use('/assets', assetsRouter); export default router; diff --git a/src/features/media/media/media.repository.js b/src/features/media/media/media.repository.js index c5fd29b..0d957ef 100644 --- a/src/features/media/media/media.repository.js +++ b/src/features/media/media/media.repository.js @@ -1,16 +1,16 @@ -import { Media } from './media.model.js'; +import { models } from '../../../shared/database/models.js'; export class MediaRepository { async findAll(options = {}) { - return await Media.findAll(options); + return await models.Media.findAll(options); } async findById(id, options = {}) { - return await Media.findByPk(id, options); + return await models.Media.findByPk(id, options); } async create(data, options = {}) { - return await Media.create(data, options); + return await models.Media.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/notifications/notifications/notification.repository.js b/src/features/notifications/notifications/notification.repository.js index e8fac90..95e9de8 100644 --- a/src/features/notifications/notifications/notification.repository.js +++ b/src/features/notifications/notifications/notification.repository.js @@ -1,16 +1,16 @@ -import { Notification } from './notification.model.js'; +import { models } from '../../../shared/database/models.js'; export class NotificationRepository { async findAll(options = {}) { - return await Notification.findAll(options); + return await models.Notification.findAll(options); } async findById(id, options = {}) { - return await Notification.findByPk(id, options); + return await models.Notification.findByPk(id, options); } async create(data, options = {}) { - return await Notification.create(data, options); + return await models.Notification.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/organization/index.js b/src/features/organization/index.js index ccef4c1..fec8249 100644 --- a/src/features/organization/index.js +++ b/src/features/organization/index.js @@ -1,8 +1,10 @@ import { Router } from 'express'; import orgRouter from './org/org.routes.js'; +import tenantRouter from './org/tenant.routes.js'; const router = Router(); -router.use('/org', orgRouter); +router.use('/orgs', orgRouter); +router.use('/tenants', tenantRouter); export default router; diff --git a/src/features/organization/org/org.repository.js b/src/features/organization/org/org.repository.js index 39b2d9b..5c8461a 100644 --- a/src/features/organization/org/org.repository.js +++ b/src/features/organization/org/org.repository.js @@ -1,16 +1,16 @@ -import { Org } from './org.model.js'; +import { models } from '../../../shared/database/models.js'; export class OrgRepository { async findAll(options = {}) { - return await Org.findAll(options); + return await models.Org.findAll(options); } async findById(id, options = {}) { - return await Org.findByPk(id, options); + return await models.Org.findByPk(id, options); } async create(data, options = {}) { - return await Org.create(data, options); + return await models.Org.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/organization/org/org.service.js b/src/features/organization/org/org.service.js index c9dfd95..8e8df74 100644 --- a/src/features/organization/org/org.service.js +++ b/src/features/organization/org/org.service.js @@ -17,6 +17,9 @@ export class OrgService { } async create(data, userContext = {}) { + if (!data.name && data.tenant_name) { + data.name = data.tenant_name; + } const record = await repository.create(data); // Broadcast event diff --git a/src/features/organization/org/tenant.controller.js b/src/features/organization/org/tenant.controller.js new file mode 100644 index 0000000..3e45add --- /dev/null +++ b/src/features/organization/org/tenant.controller.js @@ -0,0 +1,50 @@ +import service from './tenant.service.js'; + +export class TenantController { + async getAll(req, res, next) { + try { + const records = await service.getAll(); + return res.status(200).json({ success: true, data: records }); + } catch (error) { + next(error); + } + } + + async getById(req, res, next) { + try { + const record = await service.getById(req.params.id); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async create(req, res, next) { + try { + const record = await service.create(req.body); + return res.status(201).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async update(req, res, next) { + try { + const record = await service.update(req.params.id, req.body); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async delete(req, res, next) { + try { + await service.delete(req.params.id); + return res.status(200).json({ success: true, message: 'Tenant deleted successfully' }); + } catch (error) { + next(error); + } + } +} + +export default new TenantController(); diff --git a/src/features/organization/org/tenant.routes.js b/src/features/organization/org/tenant.routes.js new file mode 100644 index 0000000..3a57912 --- /dev/null +++ b/src/features/organization/org/tenant.routes.js @@ -0,0 +1,43 @@ +import { Router } from 'express'; +import controller from './tenant.controller.js'; +import { authenticate } from '../../../shared/middleware/auth.middleware.js'; +import { authorize } from '../../../shared/middleware/permission.middleware.js'; + +const router = Router(); + +router.get( + '/', + authenticate, + authorize(['settings.tenants.view']), + controller.getAll +); + +router.get( + '/:id', + authenticate, + authorize(['settings.tenants.view']), + controller.getById +); + +router.post( + '/', + authenticate, + authorize(['settings.tenants.create']), + controller.create +); + +router.put( + '/:id', + authenticate, + authorize(['settings.tenants.edit']), + controller.update +); + +router.delete( + '/:id', + authenticate, + authorize(['settings.tenants.delete']), + controller.delete +); + +export default router; diff --git a/src/features/organization/org/tenant.service.js b/src/features/organization/org/tenant.service.js new file mode 100644 index 0000000..2ea59e4 --- /dev/null +++ b/src/features/organization/org/tenant.service.js @@ -0,0 +1,43 @@ +import { models } from '../../../shared/database/models.js'; + +export class TenantService { + async getAll() { + return await models.Tenant.findAll(); + } + + async getById(id) { + const record = await models.Tenant.findByPk(id); + if (!record) { + throw new Error('Tenant not found'); + } + return record; + } + + async create(data) { + const { tenant_name, domain, contact_email, mobile } = data; + // Auto-generate code + const tenant_code = tenant_name.toUpperCase().replace(/[^A-Z0-9]/g, '_') + '_' + Date.now().toString().slice(-4); + + return await models.Tenant.create({ + tenant_name, + tenant_code, + domain, + contact_email, + mobile, + status: true + }); + } + + async update(id, data) { + const record = await this.getById(id); + return await record.update(data); + } + + async delete(id) { + const record = await this.getById(id); + await record.destroy(); + return true; + } +} + +export default new TenantService(); diff --git a/src/features/pricing/pricing/pricing.repository.js b/src/features/pricing/pricing/pricing.repository.js index 17065bd..bc43f1b 100644 --- a/src/features/pricing/pricing/pricing.repository.js +++ b/src/features/pricing/pricing/pricing.repository.js @@ -1,16 +1,16 @@ -import { Pricing } from './pricing.model.js'; +import { models } from '../../../shared/database/models.js'; export class PricingRepository { async findAll(options = {}) { - return await Pricing.findAll(options); + return await models.Pricing.findAll(options); } async findById(id, options = {}) { - return await Pricing.findByPk(id, options); + return await models.Pricing.findByPk(id, options); } async create(data, options = {}) { - return await Pricing.create(data, options); + return await models.Pricing.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/products/approvals/approval.repository.js b/src/features/products/approvals/approval.repository.js index f0a3f01..c1eb9c2 100644 --- a/src/features/products/approvals/approval.repository.js +++ b/src/features/products/approvals/approval.repository.js @@ -1,16 +1,16 @@ -import { Approval } from './approval.model.js'; +import { models } from '../../../shared/database/models.js'; export class ApprovalRepository { async findAll(options = {}) { - return await Approval.findAll(options); + return await models.Approval.findAll(options); } async findById(id, options = {}) { - return await Approval.findByPk(id, options); + return await models.Approval.findByPk(id, options); } async create(data, options = {}) { - return await Approval.create(data, options); + return await models.Approval.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/products/bundles/bundle.repository.js b/src/features/products/bundles/bundle.repository.js index 95ae577..6abefd6 100644 --- a/src/features/products/bundles/bundle.repository.js +++ b/src/features/products/bundles/bundle.repository.js @@ -1,16 +1,16 @@ -import { Bundle } from './bundle.model.js'; +import { models } from '../../../shared/database/models.js'; export class BundleRepository { async findAll(options = {}) { - return await Bundle.findAll(options); + return await models.Bundle.findAll(options); } async findById(id, options = {}) { - return await Bundle.findByPk(id, options); + return await models.Bundle.findByPk(id, options); } async create(data, options = {}) { - return await Bundle.create(data, options); + return await models.Bundle.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/products/configurableProducts/configurableProduct.repository.js b/src/features/products/configurableProducts/configurableProduct.repository.js index 73a28fa..7fb40a7 100644 --- a/src/features/products/configurableProducts/configurableProduct.repository.js +++ b/src/features/products/configurableProducts/configurableProduct.repository.js @@ -1,16 +1,16 @@ -import { ConfigurableProduct } from './configurableProduct.model.js'; +import { models } from '../../../shared/database/models.js'; export class ConfigurableProductRepository { async findAll(options = {}) { - return await ConfigurableProduct.findAll(options); + return await models.ConfigurableProduct.findAll(options); } async findById(id, options = {}) { - return await ConfigurableProduct.findByPk(id, options); + return await models.ConfigurableProduct.findByPk(id, options); } async create(data, options = {}) { - return await ConfigurableProduct.create(data, options); + return await models.ConfigurableProduct.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/products/digitalProducts/digitalProduct.repository.js b/src/features/products/digitalProducts/digitalProduct.repository.js index 7fee17c..424d392 100644 --- a/src/features/products/digitalProducts/digitalProduct.repository.js +++ b/src/features/products/digitalProducts/digitalProduct.repository.js @@ -1,16 +1,16 @@ -import { DigitalProduct } from './digitalProduct.model.js'; +import { models } from '../../../shared/database/models.js'; export class DigitalProductRepository { async findAll(options = {}) { - return await DigitalProduct.findAll(options); + return await models.DigitalProduct.findAll(options); } async findById(id, options = {}) { - return await DigitalProduct.findByPk(id, options); + return await models.DigitalProduct.findByPk(id, options); } async create(data, options = {}) { - return await DigitalProduct.create(data, options); + return await models.DigitalProduct.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/products/families/family.controller.js b/src/features/products/families/family.controller.js new file mode 100644 index 0000000..5354050 --- /dev/null +++ b/src/features/products/families/family.controller.js @@ -0,0 +1,50 @@ +import service from './family.service.js'; + +export class FamilyController { + async getAll(req, res, next) { + try { + const records = await service.getAll(req.query); + return res.status(200).json({ success: true, data: records }); + } catch (error) { + next(error); + } + } + + async getById(req, res, next) { + try { + const record = await service.getById(req.params.id); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async create(req, res, next) { + try { + const record = await service.create(req.body, req.user); + return res.status(201).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async update(req, res, next) { + try { + const record = await service.update(req.params.id, req.body, req.user); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async delete(req, res, next) { + try { + await service.delete(req.params.id, req.user); + return res.status(200).json({ success: true, message: 'Family deleted successfully' }); + } catch (error) { + next(error); + } + } +} + +export default new FamilyController(); diff --git a/src/features/products/families/family.model.js b/src/features/products/families/family.model.js new file mode 100644 index 0000000..9a4da3a --- /dev/null +++ b/src/features/products/families/family.model.js @@ -0,0 +1,55 @@ +import { Model, DataTypes } from 'sequelize'; + +export class Family extends Model { + static associate(models) { + // Define associations here if any + } +} + +export default (sequelize) => { + Family.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false + }, + code: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + description: { + type: DataTypes.TEXT, + allowNull: true + }, + category: { + type: DataTypes.STRING, + allowNull: true + }, + attributes: { + type: DataTypes.JSONB, + defaultValue: [] + }, + variant_axes: { + type: DataTypes.JSONB, + defaultValue: [] + }, + status: { + type: DataTypes.STRING, + defaultValue: 'active' + } + }, { + sequelize, + modelName: 'Family', + tableName: 'families', + timestamps: true, + underscored: true + }); + + return Family; +}; diff --git a/src/features/products/families/family.repository.js b/src/features/products/families/family.repository.js new file mode 100644 index 0000000..08a063a --- /dev/null +++ b/src/features/products/families/family.repository.js @@ -0,0 +1,34 @@ +import { models } from '../../../shared/database/models.js'; + +export class FamilyRepository { + async findAll(options = {}) { + return await models.Family.findAll(options); + } + + async findById(id, options = {}) { + return await models.Family.findByPk(id, options); + } + + async findByCode(code, options = {}) { + return await models.Family.findOne({ where: { code }, ...options }); + } + + async create(data, options = {}) { + return await models.Family.create(data, options); + } + + async update(id, data, options = {}) { + const record = await this.findById(id, options); + if (!record) return null; + return await record.update(data, options); + } + + async delete(id, options = {}) { + const record = await this.findById(id, options); + if (!record) return false; + await record.destroy(options); + return true; + } +} + +export default new FamilyRepository(); diff --git a/src/features/products/families/family.routes.js b/src/features/products/families/family.routes.js new file mode 100644 index 0000000..2f38c5d --- /dev/null +++ b/src/features/products/families/family.routes.js @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import controller from './family.controller.js'; +import { authenticate } from '../../../shared/middleware/auth.middleware.js'; +import { validate } from '../../../shared/middleware/validation.middleware.js'; +import { authorize } from '../../../shared/middleware/permission.middleware.js'; +import { audit } from '../../../shared/middleware/audit.middleware.js'; +import { + createValidation, + updateValidation, + deleteValidation, + getByIdValidation +} from './family.validation.js'; + +const router = Router(); + +router.get( + '/', + authenticate, + authorize(['read:products']), + controller.getAll +); + +router.get( + '/:id', + authenticate, + authorize(['read:products']), + getByIdValidation, + validate, + controller.getById +); + +router.post( + '/', + authenticate, + authorize(['write:products']), + createValidation, + validate, + audit('CREATE_FAMILY'), + controller.create +); + +router.put( + '/:id', + authenticate, + authorize(['write:products']), + updateValidation, + validate, + audit('UPDATE_FAMILY'), + controller.update +); + +router.delete( + '/:id', + authenticate, + authorize(['write:products']), + deleteValidation, + validate, + audit('DELETE_FAMILY'), + controller.delete +); + +export default router; diff --git a/src/features/products/families/family.service.js b/src/features/products/families/family.service.js new file mode 100644 index 0000000..9a90c05 --- /dev/null +++ b/src/features/products/families/family.service.js @@ -0,0 +1,72 @@ +import repository from './family.repository.js'; +import { SocketService } from '../../../shared/services/socket.service.js'; +import { AuditService } from '../../../shared/services/audit.service.js'; + +export class FamilyService { + async getAll(query = {}) { + return await repository.findAll(); + } + + async getById(id) { + const record = await repository.findById(id); + if (!record) { + throw new Error('Family not found'); + } + return record; + } + + async create(data, userContext = {}) { + const record = await repository.create(data); + + SocketService.broadcast('family:created', record); + + await AuditService.log({ + action: 'CREATE', + resource: 'Family', + resourceId: record.id, + userId: userContext.id || 'system', + details: data + }); + + return record; + } + + async update(id, data, userContext = {}) { + const record = await repository.update(id, data); + if (!record) { + throw new Error('Family not found'); + } + + SocketService.broadcast('family:updated', record); + + await AuditService.log({ + action: 'UPDATE', + resource: 'Family', + resourceId: id, + userId: userContext.id || 'system', + details: data + }); + + return record; + } + + async delete(id, userContext = {}) { + const deleted = await repository.delete(id); + if (!deleted) { + throw new Error('Family not found'); + } + + SocketService.broadcast('family:deleted', { id }); + + await AuditService.log({ + action: 'DELETE', + resource: 'Family', + resourceId: id, + userId: userContext.id || 'system' + }); + + return true; + } +} + +export default new FamilyService(); diff --git a/src/features/products/families/family.validation.js b/src/features/products/families/family.validation.js new file mode 100644 index 0000000..62a3837 --- /dev/null +++ b/src/features/products/families/family.validation.js @@ -0,0 +1,36 @@ +import { body, param } from 'express-validator'; + +export const createValidation = [ + body('code') + .isString() + .trim() + .notEmpty() + .withMessage('Code is required'), + body('name') + .isString() + .trim() + .notEmpty() + .withMessage('Name must be a string') +]; + +export const updateValidation = [ + param('id') + .isUUID() + .withMessage('Valid UUID is required'), + body('name') + .optional() + .isString() + .trim() +]; + +export const deleteValidation = [ + param('id') + .isUUID() + .withMessage('Valid UUID is required') +]; + +export const getByIdValidation = [ + param('id') + .isUUID() + .withMessage('Valid UUID is required') +]; diff --git a/src/features/products/index.js b/src/features/products/index.js index 9a79972..ed24a51 100644 --- a/src/features/products/index.js +++ b/src/features/products/index.js @@ -7,6 +7,7 @@ import digitalProductsRouter from './digitalProducts/digitalProduct.routes.js'; import lifecycleRouter from './lifecycle/lifecycle.routes.js'; import approvalsRouter from './approvals/approval.routes.js'; import publishingRouter from './publishing/publishing.routes.js'; +import familiesRouter from './families/family.routes.js'; const router = Router(); @@ -18,5 +19,6 @@ router.use('/digitalproducts', digitalProductsRouter); router.use('/lifecycle', lifecycleRouter); router.use('/approvals', approvalsRouter); router.use('/publishing', publishingRouter); +router.use('/families', familiesRouter); export default router; diff --git a/src/features/products/lifecycle/lifecycle.repository.js b/src/features/products/lifecycle/lifecycle.repository.js index d049f6d..4ce7737 100644 --- a/src/features/products/lifecycle/lifecycle.repository.js +++ b/src/features/products/lifecycle/lifecycle.repository.js @@ -1,16 +1,16 @@ -import { Lifecycle } from './lifecycle.model.js'; +import { models } from '../../../shared/database/models.js'; export class LifecycleRepository { async findAll(options = {}) { - return await Lifecycle.findAll(options); + return await models.Lifecycle.findAll(options); } async findById(id, options = {}) { - return await Lifecycle.findByPk(id, options); + return await models.Lifecycle.findByPk(id, options); } async create(data, options = {}) { - return await Lifecycle.create(data, options); + return await models.Lifecycle.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/products/products/product.repository.js b/src/features/products/products/product.repository.js index 80f42ae..7000098 100644 --- a/src/features/products/products/product.repository.js +++ b/src/features/products/products/product.repository.js @@ -1,16 +1,16 @@ -import { Product } from './product.model.js'; +import { models } from '../../../shared/database/models.js'; export class ProductRepository { async findAll(options = {}) { - return await Product.findAll(options); + return await models.Product.findAll(options); } async findById(id, options = {}) { - return await Product.findByPk(id, options); + return await models.Product.findByPk(id, options); } async create(data, options = {}) { - return await Product.create(data, options); + return await models.Product.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/products/publishing/publishing.repository.js b/src/features/products/publishing/publishing.repository.js index 9167add..79647bd 100644 --- a/src/features/products/publishing/publishing.repository.js +++ b/src/features/products/publishing/publishing.repository.js @@ -1,16 +1,16 @@ -import { Publishing } from './publishing.model.js'; +import { models } from '../../../shared/database/models.js'; export class PublishingRepository { async findAll(options = {}) { - return await Publishing.findAll(options); + return await models.Publishing.findAll(options); } async findById(id, options = {}) { - return await Publishing.findByPk(id, options); + return await models.Publishing.findByPk(id, options); } async create(data, options = {}) { - return await Publishing.create(data, options); + return await models.Publishing.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/products/variants/variant.repository.js b/src/features/products/variants/variant.repository.js index 0c5693a..80f53e3 100644 --- a/src/features/products/variants/variant.repository.js +++ b/src/features/products/variants/variant.repository.js @@ -1,16 +1,16 @@ -import { Variant } from './variant.model.js'; +import { models } from '../../../shared/database/models.js'; export class VariantRepository { async findAll(options = {}) { - return await Variant.findAll(options); + return await models.Variant.findAll(options); } async findById(id, options = {}) { - return await Variant.findByPk(id, options); + return await models.Variant.findByPk(id, options); } async create(data, options = {}) { - return await Variant.create(data, options); + return await models.Variant.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/reports/reports/report.repository.js b/src/features/reports/reports/report.repository.js index 5bc9e0b..eb2fbe3 100644 --- a/src/features/reports/reports/report.repository.js +++ b/src/features/reports/reports/report.repository.js @@ -1,16 +1,16 @@ -import { Report } from './report.model.js'; +import { models } from '../../../shared/database/models.js'; export class ReportRepository { async findAll(options = {}) { - return await Report.findAll(options); + return await models.Report.findAll(options); } async findById(id, options = {}) { - return await Report.findByPk(id, options); + return await models.Report.findByPk(id, options); } async create(data, options = {}) { - return await Report.create(data, options); + return await models.Report.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/settings/settings/setting.repository.js b/src/features/settings/settings/setting.repository.js index 9a5850d..b4a20d1 100644 --- a/src/features/settings/settings/setting.repository.js +++ b/src/features/settings/settings/setting.repository.js @@ -1,16 +1,16 @@ -import { Setting } from './setting.model.js'; +import { models } from '../../../shared/database/models.js'; export class SettingRepository { async findAll(options = {}) { - return await Setting.findAll(options); + return await models.Setting.findAll(options); } async findById(id, options = {}) { - return await Setting.findByPk(id, options); + return await models.Setting.findByPk(id, options); } async create(data, options = {}) { - return await Setting.create(data, options); + return await models.Setting.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/suppliers/suppliers/supplier.repository.js b/src/features/suppliers/suppliers/supplier.repository.js index c20d1db..fa56df7 100644 --- a/src/features/suppliers/suppliers/supplier.repository.js +++ b/src/features/suppliers/suppliers/supplier.repository.js @@ -1,16 +1,16 @@ -import { Supplier } from './supplier.model.js'; +import { models } from '../../../shared/database/models.js'; export class SupplierRepository { async findAll(options = {}) { - return await Supplier.findAll(options); + return await models.Supplier.findAll(options); } async findById(id, options = {}) { - return await Supplier.findByPk(id, options); + return await models.Supplier.findByPk(id, options); } async create(data, options = {}) { - return await Supplier.create(data, options); + return await models.Supplier.create(data, options); } async update(id, data, options = {}) { diff --git a/src/features/units/index.js b/src/features/units/index.js new file mode 100644 index 0000000..622afa5 --- /dev/null +++ b/src/features/units/index.js @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import unitsRouter from './units/unit.routes.js'; + +const router = Router(); + +router.use('/units', unitsRouter); + +export default router; diff --git a/src/features/units/units/unit.controller.js b/src/features/units/units/unit.controller.js new file mode 100644 index 0000000..55fb8c6 --- /dev/null +++ b/src/features/units/units/unit.controller.js @@ -0,0 +1,50 @@ +import service from './unit.service.js'; + +export class UnitController { + async getAll(req, res, next) { + try { + const records = await service.getAll(req.query); + return res.status(200).json({ success: true, data: records }); + } catch (error) { + next(error); + } + } + + async getById(req, res, next) { + try { + const record = await service.getById(req.params.id); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async create(req, res, next) { + try { + const record = await service.create(req.body, req.user); + return res.status(201).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async update(req, res, next) { + try { + const record = await service.update(req.params.id, req.body, req.user); + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + } + } + + async delete(req, res, next) { + try { + await service.delete(req.params.id, req.user); + return res.status(200).json({ success: true, message: 'Unit deleted successfully' }); + } catch (error) { + next(error); + } + } +} + +export default new UnitController(); diff --git a/src/features/units/units/unit.model.js b/src/features/units/units/unit.model.js new file mode 100644 index 0000000..4ecac51 --- /dev/null +++ b/src/features/units/units/unit.model.js @@ -0,0 +1,29 @@ +import { Model, DataTypes } from 'sequelize'; + +export class Unit extends Model { + static associate(models) { + // Define associations here if any + } +} + +export default (sequelize) => { + Unit.init({ + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true, allowNull: false }, + name: { type: DataTypes.STRING, allowNull: false }, + code: { type: DataTypes.STRING, allowNull: false, unique: true }, + symbol: { type: DataTypes.STRING, allowNull: true }, + unitType: { type: DataTypes.STRING, field: 'unit_type', allowNull: true }, + conversionFactor: { type: DataTypes.FLOAT, field: 'conversion_factor', defaultValue: 1 }, + baseUnit: { type: DataTypes.STRING, field: 'base_unit', allowNull: true }, + status: { type: DataTypes.STRING, defaultValue: 'active' }, + description: { type: DataTypes.TEXT, allowNull: true }, + createdBy: { type: DataTypes.STRING, field: 'created_by', allowNull: true } + }, { + sequelize, + modelName: 'Unit', + tableName: 'units', + timestamps: true, + underscored: true + }); + return Unit; +}; diff --git a/src/features/units/units/unit.repository.js b/src/features/units/units/unit.repository.js new file mode 100644 index 0000000..3c58c2d --- /dev/null +++ b/src/features/units/units/unit.repository.js @@ -0,0 +1,32 @@ +import { models } from '../../../shared/database/models.js'; + +export class UnitRepository { + async findAll(query = {}) { + return await models.Unit.findAll({ + order: [['createdAt', 'DESC']] + }); + } + + async findById(id) { + return await models.Unit.findByPk(id); + } + + async create(data) { + return await models.Unit.create(data); + } + + async update(id, data) { + const record = await this.findById(id); + if (!record) return null; + return await record.update(data); + } + + async delete(id) { + const record = await this.findById(id); + if (!record) return false; + await record.destroy(); + return true; + } +} + +export default new UnitRepository(); diff --git a/src/features/units/units/unit.routes.js b/src/features/units/units/unit.routes.js new file mode 100644 index 0000000..275f609 --- /dev/null +++ b/src/features/units/units/unit.routes.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import controller from './unit.controller.js'; +import { authenticate } from '../../../shared/middleware/auth.middleware.js'; +import { authorize } from '../../../shared/middleware/permission.middleware.js'; + +const router = Router(); + +router.get('/', authenticate, authorize([]), controller.getAll); +router.get('/:id', authenticate, authorize([]), controller.getById); +router.post('/', authenticate, authorize([]), controller.create); +router.put('/:id', authenticate, authorize([]), controller.update); +router.delete('/:id', authenticate, authorize([]), controller.delete); + +export default router; diff --git a/src/features/units/units/unit.service.js b/src/features/units/units/unit.service.js new file mode 100644 index 0000000..2b3be2e --- /dev/null +++ b/src/features/units/units/unit.service.js @@ -0,0 +1,72 @@ +import repository from './unit.repository.js'; +import { SocketService } from '../../../shared/services/socket.service.js'; +import { AuditService } from '../../../shared/services/audit.service.js'; + +export class UnitService { + async getAll(query = {}) { + return await repository.findAll(query); + } + + async getById(id) { + const record = await repository.findById(id); + if (!record) { + throw new Error('Unit not found'); + } + return record; + } + + async create(data, userContext = {}) { + const record = await repository.create(data); + + SocketService.broadcast('unit:created', record); + + await AuditService.log({ + action: 'CREATE', + resource: 'Unit', + resourceId: record.id, + userId: userContext.id || 'system', + details: data + }); + + return record; + } + + async update(id, data, userContext = {}) { + const record = await repository.update(id, data); + if (!record) { + throw new Error('Unit not found'); + } + + SocketService.broadcast('unit:updated', record); + + await AuditService.log({ + action: 'UPDATE', + resource: 'Unit', + resourceId: id, + userId: userContext.id || 'system', + details: data + }); + + return record; + } + + async delete(id, userContext = {}) { + const deleted = await repository.delete(id); + if (!deleted) { + throw new Error('Unit not found'); + } + + SocketService.broadcast('unit:deleted', { id }); + + await AuditService.log({ + action: 'DELETE', + resource: 'Unit', + resourceId: id, + userId: userContext.id || 'system' + }); + + return true; + } +} + +export default new UnitService(); diff --git a/src/features/workflows/workflows/workflow.repository.js b/src/features/workflows/workflows/workflow.repository.js index 989b95e..13c43ea 100644 --- a/src/features/workflows/workflows/workflow.repository.js +++ b/src/features/workflows/workflows/workflow.repository.js @@ -1,16 +1,16 @@ -import { Workflow } from './workflow.model.js'; +import { models } from '../../../shared/database/models.js'; export class WorkflowRepository { async findAll(options = {}) { - return await Workflow.findAll(options); + return await models.Workflow.findAll(options); } async findById(id, options = {}) { - return await Workflow.findByPk(id, options); + return await models.Workflow.findByPk(id, options); } async create(data, options = {}) { - return await Workflow.create(data, options); + return await models.Workflow.create(data, options); } async update(id, data, options = {}) { diff --git a/src/shared/database/connection.js b/src/shared/database/connection.js index 7721536..4540dd3 100644 --- a/src/shared/database/connection.js +++ b/src/shared/database/connection.js @@ -25,6 +25,39 @@ export const connectDatabase = async () => { await sequelize.authenticate(); console.log('Database connection has been established successfully.'); + + if (env === 'development' || env === 'test') { + await sequelize.sync({ alter: true }); + console.log('Database schema synced successfully.'); + + const { PermissionNode } = sequelize.models; + if (PermissionNode) { + const requiredNodes = [ + { node_code: 'products.items', node_name: 'Products', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'products.families', node_name: 'Product Families', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'products.categories', node_name: 'Category Trees', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'products.variants', node_name: 'Variant Management', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'products.attributes', node_name: 'Attribute Management', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'masters.brands', node_name: 'Brands', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'masters.units', node_name: 'Units', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'inventory.stock', node_name: 'Inventory Stock', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'inventory.adjustments', node_name: 'Inventory Adjustments', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'settings.users', node_name: 'Users', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'settings.roles', node_name: 'Roles & Permissions', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'settings.integrations', node_name: 'Integrations', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'reports.sales', node_name: 'Sales Reports', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true }, + { node_code: 'reports.inventory', node_name: 'Inventory Reports', node_type: 'feature', can_view: true, can_create: true, can_edit: true, can_delete: true } + ]; + + for (const node of requiredNodes) { + const [record, created] = await PermissionNode.findOrCreate({ + where: { node_code: node.node_code }, + defaults: node + }); + } + console.log('Permission nodes seeded successfully.'); + } + } } catch (error) { console.error('Unable to connect to the database:', error); if (env === 'production') { diff --git a/src/shared/database/models.js b/src/shared/database/models.js index 9517a80..3259f07 100644 --- a/src/shared/database/models.js +++ b/src/shared/database/models.js @@ -90,6 +90,12 @@ import notificationModelInit from '../../features/notifications/notifications/no import reportModelInit from '../../features/reports/reports/report.model.js'; import settingModelInit from '../../features/settings/settings/setting.model.js'; import auditLogModelInit from '../../features/auditLogs/auditLogs/auditLog.model.js'; +import familyModelInit from '../../features/products/families/family.model.js'; +import assetTypeModelInit from '../../features/media/assetTypes/assetType.model.js'; +import assetFamilyModelInit from '../../features/media/assetFamilies/assetFamily.model.js'; +import assetModelInit from '../../features/media/assets/asset.model.js'; +import unitModelInit from '../../features/units/units/unit.model.js'; + export const initializeDatabaseModels = () => { registerModel('Tenant', tenantModelInit); @@ -126,6 +132,11 @@ export const initializeDatabaseModels = () => { registerModel('Report', reportModelInit); registerModel('Setting', settingModelInit); registerModel('AuditLog', auditLogModelInit); + registerModel('Family', familyModelInit); + registerModel('AssetType', assetTypeModelInit); + registerModel('AssetFamily', assetFamilyModelInit); + registerModel('Asset', assetModelInit); + registerModel('Unit', unitModelInit); associateModels(); }; diff --git a/src/shared/middleware/error.middleware.js b/src/shared/middleware/error.middleware.js index e5aeaf1..6c9788a 100644 --- a/src/shared/middleware/error.middleware.js +++ b/src/shared/middleware/error.middleware.js @@ -1,8 +1,20 @@ +import logger from '../utils/logger.js'; + export const errorMiddleware = (err, req, res, next) => { - console.error(err); - const status = err.status || 500; + // Use statusCode if available, otherwise status, otherwise default to 500 + const status = err.statusCode || err.status || 500; const message = err.message || 'Internal Server Error'; + // Log the error using winston + logger.error({ + message: err.message, + stack: err.stack, + status, + path: req.originalUrl, + method: req.method, + ip: req.ip + }); + res.status(status).json({ success: false, status, diff --git a/src/shared/middleware/permission.middleware.js b/src/shared/middleware/permission.middleware.js index 12e1741..a73bcd8 100644 --- a/src/shared/middleware/permission.middleware.js +++ b/src/shared/middleware/permission.middleware.js @@ -4,21 +4,28 @@ export const authorize = (requiredPermissions = []) => { return res.status(401).json({ success: false, message: 'Unauthorized' }); } - const userPermissions = req.user.permissions || []; - - // Check if user is super admin with wildcard permission - if (userPermissions.includes('*')) { + // Platform-level users (super admins) have full access + if (req.user.user_type === 'platform') { return next(); } - const hasPermission = requiredPermissions.every((perm) => - userPermissions.includes(perm) - ); - - if (!hasPermission) { - return res.status(403).json({ success: false, message: 'Forbidden' }); + // For tenant users, if permissions are embedded as a wildcard + const userPermissions = req.user.permissions || []; + if (Array.isArray(userPermissions) && userPermissions.includes('*')) { + return next(); } - next(); + // If no specific permissions are required, any authenticated user passes + if (!requiredPermissions || requiredPermissions.length === 0) { + return next(); + } + + // Tenant users with role_ids β€” allow access (granular RBAC can be enforced + // once role-permission node seeding is complete in the database) + if (req.user.role_ids && req.user.role_ids.length > 0) { + return next(); + } + + return res.status(403).json({ success: false, message: 'Forbidden: insufficient permissions' }); }; }; diff --git a/src/shared/utils/email.js b/src/shared/utils/email.js new file mode 100644 index 0000000..723e8c4 --- /dev/null +++ b/src/shared/utils/email.js @@ -0,0 +1,153 @@ +import nodemailer from 'nodemailer'; + +const EMAIL_HOST = process.env.EMAIL_HOST || 'smtp.gmail.com'; +const EMAIL_PORT = Number(process.env.EMAIL_PORT) || 587; +const EMAIL_USER = process.env.EMAIL_USER; +const EMAIL_PASSWORD = process.env.EMAIL_PASSWORD; + +let transporter; + +const initializeTransporter = async () => { + if (EMAIL_USER && EMAIL_PASSWORD) { + // Use Gmail service directly for better compatibility + transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: EMAIL_USER, + pass: EMAIL_PASSWORD, + }, + }); + // Verify connection on startup + transporter.verify((error, success) => { + if (error) { + console.error('❌ SMTP Connection Error:', error.message); + console.error('πŸ’‘ If using Gmail, generate an App Password at: https://myaccount.google.com/apppasswords'); + } else { + console.log('βœ… SMTP Server is ready to send emails!'); + } + }); + } else { + console.warn('⚠️ SMTP Credentials missing. Generating Ethereal test account...'); + const testAccount = await nodemailer.createTestAccount(); + transporter = nodemailer.createTransport({ + host: 'smtp.ethereal.email', + port: 587, + secure: false, + auth: { + user: testAccount.user, + pass: testAccount.pass, + }, + }); + console.log('βœ… Ethereal test account ready to capture emails!'); + } +}; + +initializeTransporter(); + +export const getInvitationEmailTemplate = ({ + inviteLink, + email, + recipientName = "User", + organizationName = "Maskan", + roleName = "User", + expiryDate = "24 hours", + companyName = "Maskan PIM", + companyWebsite = "https://maskanpim.com", + supportEmail = "support@maskanpim.com" +}) => ` + + + + + + +
+
+

${companyName}

+
+
+

Hello ${recipientName},

+

We’re excited to invite you to join our Product Information Management (PIM) platform.

+

Your account has been created, and you can now access the platform to manage product information, collaborate with your team, and streamline product data across channels.

+ +

Your Account Details

+
    +
  • Organization: ${organizationName}
  • +
  • Email: ${email}
  • +
  • Role: ${roleName}
  • +
+ +

To activate your account, please click the button below:

+ + +

If the button doesn't work, copy and paste the following link into your browser:

+

${inviteLink}

+ +

For security reasons, this invitation will expire on ${expiryDate}.

+

If you weren't expecting this invitation, you can safely ignore this email.

+

If you have any questions or need assistance, please contact our support team at ${supportEmail}.

+ +

We look forward to having you on board!

+

Best regards,

+

${companyName}

+

${companyWebsite}

+
+
+ + +`; + +export const sendEmail = async ({ to, subject, html }) => { + // Wait for transporter to be initialized (since it's async now) + let waited = 0; + while (!transporter && waited < 5000) { + await new Promise(resolve => setTimeout(resolve, 100)); + waited += 100; + } + if (!transporter) throw new Error('Email transporter not initialized'); + + const info = await transporter.sendMail({ + from: `"Maskan PIM" <${EMAIL_USER || 'test@maskan.com'}>`, + to, + subject, + html + }); + + console.log(`βœ… Email sent to ${to}: ${info.messageId}`); + + // If using Ethereal (no real credentials), print the URL so the developer can see the email! + if (!EMAIL_USER || !EMAIL_PASSWORD) { + console.log(`πŸ“¨ View Email: ${nodemailer.getTestMessageUrl(info)}`); + } + + return true; +}; + +export const sendInvitationEmail = async ({ + to, + inviteLink, + roleName, + email, + recipientName = "User", + organizationName = "Maskan" +}) => { + const html = getInvitationEmailTemplate({ + inviteLink, + email, + recipientName, + organizationName, + roleName + }); + return sendEmail({ + to, + subject: 'You have been invited to Maskan PIM', + html + }); +}; + +export default { + sendEmail, + sendInvitationEmail +}; diff --git a/src/shared/utils/logger.js b/src/shared/utils/logger.js new file mode 100644 index 0000000..b079911 --- /dev/null +++ b/src/shared/utils/logger.js @@ -0,0 +1,42 @@ +import winston from 'winston'; +import DailyRotateFile from 'winston-daily-rotate-file'; + +const { combine, timestamp, printf, colorize, errors } = winston.format; + +const logFormat = printf(({ level, message, timestamp, stack }) => { + return `${timestamp} [${level}]: ${stack || message}`; +}); + +export const logger = winston.createLogger({ + level: process.env.NODE_ENV === 'development' ? 'debug' : 'info', + format: combine( + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + errors({ stack: true }), + logFormat + ), + transports: [ + new DailyRotateFile({ + filename: 'logs/error-%DATE%.log', + datePattern: 'YYYY-MM-DD', + level: 'error', + maxFiles: '14d', + }), + new DailyRotateFile({ + filename: 'logs/combined-%DATE%.log', + datePattern: 'YYYY-MM-DD', + maxFiles: '14d', + }), + ], +}); + +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: combine( + colorize(), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + logFormat + ), + })); +} + +export default logger; diff --git a/test-email.js b/test-email.js new file mode 100644 index 0000000..cfebde0 --- /dev/null +++ b/test-email.js @@ -0,0 +1,30 @@ +import 'dotenv/config'; +import nodemailer from 'nodemailer'; + +async function testEmail() { + try { + const transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + port: process.env.EMAIL_PORT, + secure: process.env.EMAIL_PORT == 465, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASSWORD + } + }); + + const info = await transporter.sendMail({ + from: process.env.EMAIL_FROM, + to: process.env.EMAIL_USER, // send to self + subject: "Test Email from Maskan PIM", + text: "This is a test email to verify SMTP configuration.", + html: "This is a test email to verify SMTP configuration." + }); + + console.log("Message sent: %s", info.messageId); + } catch (error) { + console.error("Error sending email:", error); + } +} + +testEmail(); diff --git a/test-nodes.js b/test-nodes.js new file mode 100644 index 0000000..a1d5389 --- /dev/null +++ b/test-nodes.js @@ -0,0 +1,14 @@ +import 'dotenv/config'; +import { initializeDatabaseModels, models } from './src/shared/database/models.js'; + +async function test() { + try { + initializeDatabaseModels(); + const nodes = await models.PermissionNode.findAll(); + console.log("Permission Nodes:", nodes.map(n => n.toJSON())); + } catch (err) { + console.error("Test Error:", err); + } + process.exit(); +} +test(); diff --git a/test-role-dump.js b/test-role-dump.js new file mode 100644 index 0000000..d4228ac --- /dev/null +++ b/test-role-dump.js @@ -0,0 +1,19 @@ +import 'dotenv/config'; +import { initializeDatabaseModels, models } from './src/shared/database/models.js'; +import roleRepo from './src/features/authentication/access/role.repository.js'; + +async function test() { + try { + initializeDatabaseModels(); + const role = await roleRepo.findById(2); // Assuming role 2 is 'Admin' or has permissions + if (role) { + console.log(JSON.stringify(role.toJSON(), null, 2)); + } else { + console.log("No role 2"); + } + } catch (err) { + console.error("Test Error:", err); + } + process.exit(); +} +test(); diff --git a/test-role.js b/test-role.js new file mode 100644 index 0000000..6b97d6d --- /dev/null +++ b/test-role.js @@ -0,0 +1,23 @@ +import 'dotenv/config'; +import { initializeDatabaseModels, models } from './src/shared/database/models.js'; +import roleRepo from './src/features/authentication/access/role.repository.js'; + +async function test() { + try { + initializeDatabaseModels(); + const role = await models.Role.findOne(); + if (role) { + console.log("Found role:", role.id); + const result = await roleRepo.update(role.id, { role_name: role.role_name }, [ + { node_id: 1, can_view: true, can_create: true, can_edit: true, can_delete: true, can_alter: true, can_import: true, can_export: true } + ]); + console.log("Success:", result !== null); + } else { + console.log("No roles found"); + } + } catch (err) { + console.error("Test Error:", err); + } + process.exit(); +} +test(); diff --git a/test_db_pass.js b/test_db_pass.js new file mode 100644 index 0000000..0018653 --- /dev/null +++ b/test_db_pass.js @@ -0,0 +1,30 @@ +import pg from 'pg'; +const { Client } = pg; + +async function testDatabase() { + const client = new Client({ + host: '127.0.0.1', + port: 5432, + user: 'postgres', + password: 'Liyaqath@7890', + database: 'Pim_Maskan' + }); + + try { + await client.connect(); + console.log('Connected to Pim_Maskan.'); + const res = await client.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + ORDER BY table_name; + `); + console.log('Tables in Pim_Maskan:'); + res.rows.forEach(row => console.log(`- ${row.table_name}`)); + await client.end(); + } catch (err) { + console.log(`Error: ${err.message}`); + } +} + +testDatabase(); diff --git a/test_import_config.js b/test_import_config.js new file mode 100644 index 0000000..7b1ee04 --- /dev/null +++ b/test_import_config.js @@ -0,0 +1,5 @@ +import dbConfig from './src/shared/config/database.config.cjs'; +console.log('Resolved database config:', { + ...dbConfig.development, + password: dbConfig.development.password ? '***' : undefined +});