feat: Migrate database from MongoDB to PostgreSQL, updating all services and introducing a new schema.
This commit is contained in:
295
package-lock.json
generated
295
package-lock.json
generated
@@ -10,8 +10,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@logto/node": "^3.1.9",
|
"@logto/node": "^3.1.9",
|
||||||
"@logto/react": "^4.0.13",
|
"@logto/react": "^4.0.13",
|
||||||
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/mongoose": "^5.11.96",
|
"@types/mongoose": "^5.11.96",
|
||||||
|
"@types/pg": "^8.18.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -22,10 +24,12 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"enhanced-resolve": "^5.18.4",
|
"enhanced-resolve": "^5.18.4",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"jose": "^6.2.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -2362,6 +2366,15 @@
|
|||||||
"jose": "^5.2.2"
|
"jose": "^5.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@logto/client/node_modules/jose": {
|
||||||
|
"version": "5.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||||
|
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@logto/js": {
|
"node_modules/@logto/js": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@logto/js/-/js-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@logto/js/-/js-6.1.1.tgz",
|
||||||
@@ -2875,6 +2888,86 @@
|
|||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-vMEVLA1kGGYd/kdsJSwtjiFUZM1nGfrz2DWmgMBZtocV48qL+L2+4QpIkueXyBEumMQZFEyhz57i/5zGHjvdBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-6tk2zrcBkzKaaBXPOG5nshn30uJNFGOH9LxOnE8i850eQmsX+jVm7vql9kTPyvUzEHwU4zdjSOkXS9M+9ukMVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-8HxEf+zNycj7Z8+ONhhlu+7J7Ha+L6weyCtdEeK2mN5OWJbh6n4LPU4iuJ5UlCvvNnbSXMoutY7piITEEAgl2g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-c1azgZ2nZPczbY5k5u5iFrk1InpxN81IvNE+UBAkjrBz3yc5ALLJNkeTQwbJZT4PZBuYXEzqYGLMuh9fdTtTMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-lOfIm4hInNcd8x0i1LWphnLKxec42wwbjs+vhaVAvR801Vda0UAMbTooUY6gfqgQb8v29GofqKuQMMTAsl6w/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-GuPbzoEaI51AkLw9VGhLNvnzw4PHbS3p8j2/JlvLeZNQMKwZw4aEYQIDBRtFwL5Nv7/275n9m4DHtakY8nCvgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.99.3",
|
||||||
|
"@supabase/functions-js": "2.99.3",
|
||||||
|
"@supabase/postgrest-js": "2.99.3",
|
||||||
|
"@supabase/realtime-js": "2.99.3",
|
||||||
|
"@supabase/storage-js": "2.99.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
||||||
@@ -3353,12 +3446,28 @@
|
|||||||
"version": "24.10.9",
|
"version": "24.10.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
|
||||||
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/phoenix": {
|
||||||
|
"version": "1.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||||
|
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -3440,6 +3549,15 @@
|
|||||||
"@types/webidl-conversions": "*"
|
"@types/webidl-conversions": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.53.1",
|
"version": "8.53.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5927,6 +6045,15 @@
|
|||||||
"node": ">=16.17.0"
|
"node": ">=16.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -6573,9 +6700,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "5.10.0",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||||
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
@@ -7816,6 +7943,95 @@
|
|||||||
"ms": "^2.1.1"
|
"ms": "^2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.12.0",
|
||||||
|
"pg-pool": "^3.13.0",
|
||||||
|
"pg-protocol": "^1.13.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||||
|
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
@@ -7874,6 +8090,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8874,6 +9129,15 @@
|
|||||||
"memory-pager": "^1.0.2"
|
"memory-pager": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -9325,7 +9589,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"devOptional": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
@@ -9523,7 +9786,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
@@ -10395,6 +10657,27 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wsl-utils": {
|
"node_modules/wsl-utils": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -15,8 +15,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@logto/node": "^3.1.9",
|
"@logto/node": "^3.1.9",
|
||||||
"@logto/react": "^4.0.13",
|
"@logto/react": "^4.0.13",
|
||||||
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/mongoose": "^5.11.96",
|
"@types/mongoose": "^5.11.96",
|
||||||
|
"@types/pg": "^8.18.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -27,10 +29,12 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"enhanced-resolve": "^5.18.4",
|
"enhanced-resolve": "^5.18.4",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"jose": "^6.2.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
@@ -1,46 +1,55 @@
|
|||||||
import mongoose from 'mongoose';
|
import pg from 'pg';
|
||||||
import { GridFSBucket } from 'mongodb';
|
const { Pool } = pg;
|
||||||
|
|
||||||
export let bucket: GridFSBucket;
|
// Define a placeholder for bucket since GridFS is MongoDB-only
|
||||||
|
// We'll have to migrate PDF storage to Supabase Storage later or local FS.
|
||||||
|
export let bucket: any = null;
|
||||||
|
|
||||||
|
let pool: pg.Pool | null = null;
|
||||||
|
|
||||||
export const connectDB = async () => {
|
export const connectDB = async () => {
|
||||||
|
if (pool) return pool;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uri = process.env.MONGODB_URI;
|
const connectionString = process.env.POSTGRES_URL || process.env.DATABASE_URL;
|
||||||
if (!uri) {
|
|
||||||
throw new Error('MONGODB_URI is not defined in environment variables');
|
if (!connectionString) {
|
||||||
|
throw new Error('DATABASE_URL or POSTGRES_URL is not defined in environment variables');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mongoose.connection.readyState >= 1) {
|
console.log('Connecting to Postgres (Supabase)...');
|
||||||
console.log('Using existing MongoDB connection');
|
|
||||||
if (!bucket && mongoose.connection.db) {
|
pool = new Pool({
|
||||||
bucket = new GridFSBucket(mongoose.connection.db, { bucketName: 'pdfs' });
|
connectionString,
|
||||||
console.log('✅ GridFS Bucket re-initialized');
|
ssl: connectionString.includes('localhost') ? false : { rejectUnauthorized: false },
|
||||||
}
|
max: 20,
|
||||||
return;
|
idleTimeoutMillis: 30000,
|
||||||
}
|
connectionTimeoutMillis: 2000,
|
||||||
|
|
||||||
console.log('Connecting to MongoDB...');
|
|
||||||
if (!uri) console.error('MONGODB_URI is undefined!');
|
|
||||||
|
|
||||||
await mongoose.connect(uri, {
|
|
||||||
maxPoolSize: 10,
|
|
||||||
serverSelectionTimeoutMS: 5000,
|
|
||||||
socketTimeoutMS: 45000,
|
|
||||||
});
|
});
|
||||||
console.log('✅ MongoDB connected successfully');
|
|
||||||
|
|
||||||
const db = mongoose.connection.db;
|
// Test connection and set schema
|
||||||
if (!db) {
|
const client = await pool.connect();
|
||||||
throw new Error('Database connection not established');
|
try {
|
||||||
|
// Ensure we are in the 'gpi' schema as requested
|
||||||
|
await client.query('SET search_path TO gpi, public;');
|
||||||
|
console.log('✅ Postgres connected successfully (Schema: gpi)');
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
}
|
}
|
||||||
bucket = new GridFSBucket(db, {
|
|
||||||
bucketName: 'pdfs'
|
|
||||||
});
|
|
||||||
console.log('✅ GridFS Bucket initialized');
|
|
||||||
|
|
||||||
|
return pool;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ MongoDB connection error:', error);
|
console.error('❌ Postgres connection error:', error);
|
||||||
console.warn('⚠️ Server will continue running for debugging, but database features will be unavailable.');
|
console.warn('⚠️ Server will continue running for debugging, but database features will be unavailable.');
|
||||||
// process.exit(1);
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPool = () => {
|
||||||
|
if (!pool) throw new Error('Database not connected. Call connectDB first.');
|
||||||
|
return pool;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function for shorthand queries
|
||||||
|
export const query = (text: string, params?: any[]) => pool?.query(text, params);
|
||||||
|
|
||||||
|
|||||||
@@ -1,106 +1,121 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import Instrument from '../models/Instrument.js';
|
import { query } from '../config/database.js';
|
||||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
|
||||||
|
|
||||||
interface AuthRequest extends Request {
|
interface AuthRequest extends Request {
|
||||||
appUser?: IAppUser;
|
appUser?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mapInstrument = (inst: any) => {
|
||||||
|
if (!inst) return null;
|
||||||
|
return {
|
||||||
|
...inst,
|
||||||
|
serialNumber: inst.serial_number,
|
||||||
|
modelName: inst.model_name,
|
||||||
|
calibrationDate: inst.calibration_date,
|
||||||
|
calibrationExpirationDate: inst.calibration_expiration_date,
|
||||||
|
certificateUrl: inst.certificate_url,
|
||||||
|
organizationId: inst.organization_id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const createInstrument = async (req: AuthRequest, res: Response) => {
|
export const createInstrument = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
const { name, type, manufacturer, modelName, serialNumber, calibrationDate, calibrationExpirationDate, certificateUrl, notes } = req.body;
|
const { name, type, manufacturer, modelName, serialNumber, calibrationDate, calibrationExpirationDate, certificateUrl, notes } = req.body;
|
||||||
|
|
||||||
const existing = await Instrument.findOne({ organizationId, serialNumber });
|
const existing = await query('SELECT id FROM instruments WHERE organization_id = $1 AND serial_number = $2', [organizationId, serialNumber]);
|
||||||
if (existing) {
|
if (existing?.rowCount && existing.rowCount > 0) {
|
||||||
return res.status(400).json({ error: 'Já existe um instrumento com este número de série.' });
|
return res.status(400).json({ error: 'Já existe um instrumento com este número de série.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determinar status inicial baseado na validade
|
|
||||||
let status = 'active';
|
let status = 'active';
|
||||||
if (calibrationExpirationDate && new Date(calibrationExpirationDate) < new Date()) {
|
if (calibrationExpirationDate && new Date(calibrationExpirationDate) < new Date()) {
|
||||||
status = 'expired';
|
status = 'expired';
|
||||||
}
|
}
|
||||||
|
|
||||||
const instrument = await Instrument.create({
|
const result = await query(
|
||||||
organizationId,
|
`INSERT INTO instruments (organization_id, name, type, manufacturer, model_name, serial_number, calibration_date, calibration_expiration_date, certificate_url, status, notes)
|
||||||
name,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||||
type,
|
[organizationId, name, type, manufacturer, modelName, serialNumber, calibrationDate, calibrationExpirationDate, certificateUrl, status, notes]
|
||||||
manufacturer,
|
);
|
||||||
modelName,
|
|
||||||
serialNumber,
|
|
||||||
calibrationDate,
|
|
||||||
calibrationExpirationDate,
|
|
||||||
certificateUrl,
|
|
||||||
status,
|
|
||||||
notes
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(instrument);
|
res.status(201).json(mapInstrument(result?.rows[0]));
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
res.status(500).json({ error: error.message });
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInstruments = async (req: AuthRequest, res: Response) => {
|
export const getInstruments = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
const { status } = req.query;
|
const { status } = req.query;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
let sql = 'SELECT * FROM instruments WHERE organization_id = $1';
|
||||||
const query: any = { organizationId };
|
const params: any[] = [organizationId];
|
||||||
if (status) query.status = status;
|
|
||||||
|
|
||||||
const instruments = await Instrument.find(query).sort({ name: 1 });
|
if (status) {
|
||||||
res.json(instruments);
|
sql += ' AND status = $2';
|
||||||
} catch (error: unknown) {
|
params.push(status);
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
}
|
||||||
res.status(500).json({ error: message });
|
sql += ' ORDER BY name ASC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
res.json((result?.rows || []).map(mapInstrument));
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateInstrument = async (req: AuthRequest, res: Response) => {
|
export const updateInstrument = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const id = req.params.id as string;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
|
|
||||||
// Recalcular status se data de validade mudar
|
|
||||||
const updates = { ...req.body };
|
const updates = { ...req.body };
|
||||||
|
|
||||||
if (updates.calibrationExpirationDate) {
|
if (updates.calibrationExpirationDate) {
|
||||||
if (new Date(updates.calibrationExpirationDate) < new Date()) {
|
if (new Date(updates.calibrationExpirationDate) < new Date()) {
|
||||||
updates.status = 'expired';
|
updates.status = 'expired';
|
||||||
} else if (updates.status === 'expired') {
|
} else if (updates.status === 'expired') {
|
||||||
// Se estava expirado e a data é futura, reativar (se o usuário não setou outro status)
|
|
||||||
updates.status = 'active';
|
updates.status = 'active';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const instrument = await Instrument.findOneAndUpdate(
|
const fields: string[] = [];
|
||||||
{ _id: id, organizationId },
|
const params: any[] = [];
|
||||||
updates,
|
let i = 1;
|
||||||
{ new: true }
|
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${sqlKey} = $${i++}`);
|
||||||
|
params.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
params.push(organizationId);
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE instruments SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} AND organization_id = $${i+1} RETURNING *`,
|
||||||
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!instrument) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
if (!result?.rows[0]) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
||||||
res.json(instrument);
|
res.json(mapInstrument(result.rows[0]));
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
res.status(500).json({ error: error.message });
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteInstrument = async (req: AuthRequest, res: Response) => {
|
export const deleteInstrument = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const id = req.params.id as string;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
|
|
||||||
const deleted = await Instrument.findOneAndDelete({ _id: id, organizationId });
|
const result = await query('DELETE FROM instruments WHERE id = $1 AND organization_id = $2 RETURNING id', [id, organizationId]);
|
||||||
if (!deleted) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
if (!result?.rows[0]) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
res.status(500).json({ error: error.message });
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import StockItem from '../models/StockItem.js';
|
import { query } from '../config/database.js';
|
||||||
import StockMovement from '../models/StockMovement.js';
|
import * as stockService from '../services/stockService.js';
|
||||||
|
|
||||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
|
||||||
import { notificationService } from '../services/notificationService.js';
|
import { notificationService } from '../services/notificationService.js';
|
||||||
|
|
||||||
interface AuthRequest extends Request {
|
interface AuthRequest extends Request {
|
||||||
appUser?: IAppUser;
|
appUser?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createStockItem = async (req: AuthRequest, res: Response) => {
|
export const createStockItem = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const {
|
const {
|
||||||
dataSheetId,
|
dataSheetId,
|
||||||
@@ -27,47 +25,35 @@ export const createStockItem = async (req: AuthRequest, res: Response) => {
|
|||||||
minStock
|
minStock
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) {
|
if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) {
|
||||||
return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' });
|
return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate RR within Org
|
const existingResult = await query('SELECT * FROM stock_items WHERE organization_id = $1 AND rr_number = $2', [organizationId, rrNumber]);
|
||||||
const existing = await StockItem.findOne({ organizationId, rrNumber });
|
if (existingResult?.rowCount && existingResult.rowCount > 0) {
|
||||||
if (existing) {
|
|
||||||
return res.status(400).json({ error: `Já existe um item com o RR ${rrNumber}.` });
|
return res.status(400).json({ error: `Já existe um item com o RR ${rrNumber}.` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Min Stock Inheritance Logic ---
|
|
||||||
let finalMinStock = Number(minStock) || 0;
|
let finalMinStock = Number(minStock) || 0;
|
||||||
|
|
||||||
// If user didn't provide a specific minStock (or provided 0), try to inherit from existing group
|
|
||||||
if (finalMinStock === 0) {
|
if (finalMinStock === 0) {
|
||||||
const existingGroupItem = await StockItem.findOne({
|
const existingGroupResult = await query(
|
||||||
organizationId,
|
`SELECT min_stock FROM stock_items
|
||||||
dataSheetId,
|
WHERE organization_id = $1 AND data_sheet_id = $2 AND color = $3
|
||||||
color
|
ORDER BY updated_at DESC LIMIT 1`,
|
||||||
}).sort({ updatedAt: -1 }); // Get latest active config
|
[organizationId, dataSheetId, color]
|
||||||
|
);
|
||||||
if (existingGroupItem && existingGroupItem.minStock > 0) {
|
if (existingGroupResult && existingGroupResult.rows[0]?.min_stock > 0) {
|
||||||
finalMinStock = existingGroupItem.minStock;
|
finalMinStock = existingGroupResult.rows[0].min_stock;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If user DID provide a minStock, update all existing items in that group to match?
|
await query(
|
||||||
// User requested: "a regra de estoque minimo definido no cadastro precisa estar clonado para novos cadastros"
|
'UPDATE stock_items SET min_stock = $1 WHERE organization_id = $2 AND data_sheet_id = $3 AND color = $4',
|
||||||
// And "soma dessas 'mesmas' tintas sejam comparadas com o estoque minimo cadastrado a elas"
|
[finalMinStock, organizationId, dataSheetId, color]
|
||||||
// This implies the rule is a Property of the Group. So create/update should enforce consistency.
|
);
|
||||||
if (finalMinStock > 0) {
|
|
||||||
await StockItem.updateMany(
|
|
||||||
{ organizationId, dataSheetId, color },
|
|
||||||
{ $set: { minStock: finalMinStock } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItem = new StockItem({
|
const savedItem = await stockService.createStockItem({
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?.externalId,
|
|
||||||
dataSheetId,
|
dataSheetId,
|
||||||
rrNumber,
|
rrNumber,
|
||||||
batchNumber,
|
batchNumber,
|
||||||
@@ -78,425 +64,196 @@ export const createStockItem = async (req: AuthRequest, res: Response) => {
|
|||||||
notes,
|
notes,
|
||||||
color,
|
color,
|
||||||
invoiceNumber,
|
invoiceNumber,
|
||||||
receivedBy
|
receivedBy,
|
||||||
|
createdBy: req.appUser?.id
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedItem = await newItem.save();
|
await stockService.createStockMovement({
|
||||||
|
|
||||||
// Create Initial Movement (ENTRY)
|
|
||||||
await StockMovement.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?.externalId,
|
stockItemId: savedItem.id,
|
||||||
stockItemId: savedItem._id,
|
userId: req.appUser?.id,
|
||||||
movementNumber: 1,
|
type: 'entry',
|
||||||
type: 'ENTRY',
|
|
||||||
quantity: Number(quantity),
|
quantity: Number(quantity),
|
||||||
responsible: userName,
|
reason: 'Abertura de Lote / Entrada Inicial'
|
||||||
notes: 'Abertura de Lote / Entrada Inicial'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notificação de Recebimento
|
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
await notificationService.create({
|
await notificationService.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
title: 'Recebimento de Material',
|
title: 'Recebimento de Material',
|
||||||
message: `Recebido: ${quantity}${unit} de ${savedItem.rrNumber} (Lote: ${batchNumber}).`,
|
message: `Recebido: ${quantity}${unit} de ${savedItem.rr_number} (Lote: ${batchNumber}).`,
|
||||||
type: 'info',
|
type: 'info'
|
||||||
metadata: { stockItemId: savedItem._id, triggerType: 'stock_received' }
|
|
||||||
});
|
});
|
||||||
|
await notificationService.checkLowStock(savedItem.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Low Stock immediately
|
|
||||||
await notificationService.checkLowStock(savedItem._id.toString());
|
|
||||||
|
|
||||||
res.status(201).json(savedItem);
|
res.status(201).json(savedItem);
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
console.error('Error creating stock item:', error);
|
console.error('Error creating stock item:', error);
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateStockItem = async (req: AuthRequest, res: Response) => {
|
export const updateStockItem = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
// Only allow updating metadata, NOT quantity directly (quantity must be via adjustments)
|
const { quantity, ...otherData } = req.body;
|
||||||
// Adjusting logic: Admin might need to fix typo in quantity without movement record?
|
|
||||||
// Better enforcing movements. If quantity changes, user should use "Adjustment".
|
|
||||||
// Here we create a general update for details like Notes, Dates, etc.
|
|
||||||
|
|
||||||
const { quantity, ...otherData } = req.body; // Separate quantity
|
|
||||||
|
|
||||||
if (quantity !== undefined) {
|
if (quantity !== undefined) {
|
||||||
return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' });
|
return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if Min Stock is being updated
|
|
||||||
if (otherData.minStock !== undefined) {
|
if (otherData.minStock !== undefined) {
|
||||||
const item = await StockItem.findOne({ _id: id, organizationId });
|
const itemResult = await query('SELECT data_sheet_id, color FROM stock_items WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
const item = itemResult?.rows[0];
|
||||||
if (item) {
|
if (item) {
|
||||||
// Propagate to all siblings (same Product + Color)
|
await query(
|
||||||
await StockItem.updateMany(
|
'UPDATE stock_items SET min_stock = $1 WHERE organization_id = $2 AND data_sheet_id = $3 AND color = $4',
|
||||||
{
|
[otherData.minStock, organizationId, item.data_sheet_id, item.color]
|
||||||
organizationId,
|
|
||||||
dataSheetId: item.dataSheetId,
|
|
||||||
color: item.color
|
|
||||||
},
|
|
||||||
{ $set: { minStock: otherData.minStock } }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await StockItem.findOneAndUpdate(
|
const updated = await stockService.updateStockItem(id as string, otherData, organizationId);
|
||||||
{ _id: id, organizationId },
|
|
||||||
otherData,
|
|
||||||
{ new: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!updated) return res.status(404).json({ error: 'Item não encontrado.' });
|
if (!updated) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
|
|
||||||
// Check Low Stock (in case minStock changed)
|
await notificationService.checkLowStock(id);
|
||||||
await notificationService.checkLowStock(updated._id.toString());
|
|
||||||
|
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
|
} catch (error: any) {
|
||||||
} catch (error: unknown) {
|
res.status(500).json({ error: error.message });
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adjustStock = async (req: AuthRequest, res: Response) => {
|
export const adjustStock = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const { quantityDelta, reason } = req.body;
|
||||||
const { quantityDelta, reason } = req.body; // quantityDelta: +10 or -5
|
|
||||||
|
|
||||||
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
|
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
|
||||||
if (!quantityDelta || isNaN(quantityDelta)) return res.status(400).json({ error: 'Quantidade inválida.' });
|
if (!quantityDelta || isNaN(quantityDelta)) return res.status(400).json({ error: 'Quantidade inválida.' });
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: id, organizationId });
|
const adjustment = await stockService.createStockMovement({
|
||||||
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
|
||||||
|
|
||||||
// Calculate new quantity
|
|
||||||
const newQuantity = Number(item.quantity) + Number(quantityDelta);
|
|
||||||
if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' });
|
|
||||||
|
|
||||||
item.quantity = newQuantity;
|
|
||||||
await item.save();
|
|
||||||
|
|
||||||
// Calculate next movement number
|
|
||||||
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
|
|
||||||
const count = await StockMovement.countDocuments({ stockItemId: item._id });
|
|
||||||
const movementNumber = (lastMov?.movementNumber || count) + 1;
|
|
||||||
|
|
||||||
// Register Movement
|
|
||||||
await StockMovement.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?.externalId,
|
stockItemId: id as string,
|
||||||
stockItemId: item._id,
|
userId: req.appUser?.id,
|
||||||
movementNumber,
|
type: 'adjustment',
|
||||||
type: 'ADJUSTMENT',
|
|
||||||
quantity: Number(quantityDelta),
|
quantity: Number(quantityDelta),
|
||||||
responsible: userName,
|
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check Low Stock
|
await notificationService.checkLowStock(id);
|
||||||
await notificationService.checkLowStock(item._id.toString());
|
res.json(adjustment);
|
||||||
|
} catch (error: any) {
|
||||||
res.json(item);
|
res.status(500).json({ error: error.message });
|
||||||
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const consumeStock = async (req: AuthRequest, res: Response) => {
|
export const consumeStock = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
|
||||||
const { quantityConsumed, requester, date } = req.body;
|
const { quantityConsumed, requester, date } = req.body;
|
||||||
|
|
||||||
if (!requester) return res.status(400).json({ error: 'Solicitante é obrigatório.' });
|
if (!requester) return res.status(400).json({ error: 'Solicitante é obrigatório.' });
|
||||||
if (!quantityConsumed || Number(quantityConsumed) <= 0) return res.status(400).json({ error: 'Quantidade deve ser maior que zero.' });
|
if (!quantityConsumed || Number(quantityConsumed) <= 0) return res.status(400).json({ error: 'Quantidade deve ser maior que zero.' });
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: id, organizationId });
|
await stockService.createStockMovement({
|
||||||
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
|
||||||
|
|
||||||
if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' });
|
|
||||||
|
|
||||||
item.quantity -= Number(quantityConsumed);
|
|
||||||
await item.save();
|
|
||||||
|
|
||||||
// Calculate next movement number
|
|
||||||
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
|
|
||||||
const count = await StockMovement.countDocuments({ stockItemId: item._id });
|
|
||||||
const movementNumber = (lastMov?.movementNumber || count) + 1;
|
|
||||||
|
|
||||||
// Register Movement (Negative quantity for consumption)
|
|
||||||
await StockMovement.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?.externalId,
|
stockItemId: id as string,
|
||||||
stockItemId: item._id,
|
userId: req.appUser?.id,
|
||||||
movementNumber,
|
type: 'exit', // Using exit for consumption
|
||||||
type: 'CONSUMPTION',
|
quantity: Number(quantityConsumed),
|
||||||
quantity: -Number(quantityConsumed), // Negative
|
reason: `Consumo por ${requester}`
|
||||||
responsible: userName,
|
|
||||||
requester,
|
|
||||||
date: date || new Date()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check Low Stock
|
await notificationService.checkLowStock(id);
|
||||||
await notificationService.checkLowStock(item._id.toString());
|
res.json({ message: 'Consumo registrado com sucesso.' });
|
||||||
|
} catch (error: any) {
|
||||||
res.json(item);
|
res.status(500).json({ error: error.message });
|
||||||
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteStockItem = async (req: AuthRequest, res: Response) => {
|
export const deleteStockItem = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
|
await stockService.deleteStockItem(id as string, organizationId);
|
||||||
// Optional: Block delete if there are movements other than ENTRY?
|
|
||||||
// For simplicity allow Admin to nuke it.
|
|
||||||
|
|
||||||
const deleted = await StockItem.findOneAndDelete({ _id: id, organizationId });
|
|
||||||
if (!deleted) return res.status(404).json({ error: 'Item não encontrado.' });
|
|
||||||
|
|
||||||
// Cleanup movements & logs
|
|
||||||
await StockMovement.deleteMany({ stockItemId: id });
|
|
||||||
await StockAuditLog.deleteMany({ stockItemId: id });
|
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
res.status(500).json({ error: error.message });
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStockItems = async (req: AuthRequest, res: Response) => {
|
export const getStockItems = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
const { dataSheetId } = req.query;
|
const items = await stockService.getStockItems(organizationId);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const query: any = { organizationId };
|
|
||||||
if (dataSheetId) query.dataSheetId = dataSheetId;
|
|
||||||
|
|
||||||
// Sort by Expiration Date ASC (First to expire first)
|
|
||||||
const items = await StockItem.find(query)
|
|
||||||
.populate('dataSheetId', 'name manufacturer type')
|
|
||||||
.sort({ expirationDate: 1 });
|
|
||||||
|
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
res.status(500).json({ error: error.message });
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStockItemById = async (req: AuthRequest, res: Response) => {
|
export const getStockItemById = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
|
const result = await query(
|
||||||
const item = await StockItem.findOne({ _id: id, organizationId })
|
`SELECT si.*, tds.name as data_sheet_name
|
||||||
.populate('dataSheetId', 'name manufacturer type');
|
FROM stock_items si
|
||||||
|
LEFT JOIN technical_data_sheets tds ON si.data_sheet_id = tds.id
|
||||||
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
WHERE si.id = $1 AND si.organization_id = $2`,
|
||||||
|
[id, organizationId]
|
||||||
res.json(item);
|
);
|
||||||
} catch (error: unknown) {
|
if (!result?.rows[0]) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
res.json(result.rows[0]);
|
||||||
res.status(500).json({ error: message });
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStockMovements = async (req: AuthRequest, res: Response) => {
|
export const getStockMovements = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params; // StockItem ID
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organization_id;
|
||||||
|
const movements = await stockService.getMovementsByItem(id, organizationId);
|
||||||
const movements = await StockMovement.find({ stockItemId: id, organizationId })
|
|
||||||
.sort({ date: -1 });
|
|
||||||
|
|
||||||
res.json(movements);
|
res.json(movements);
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
res.status(500).json({ error: error.message });
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// CRUD & Auditing for Movements
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
import StockAuditLog from '../models/StockAuditLog.js';
|
|
||||||
|
|
||||||
export const updateStockMovement = async (req: AuthRequest, res: Response) => {
|
export const updateStockMovement = async (req: AuthRequest, res: Response) => {
|
||||||
|
// Basic implementation for Postgres
|
||||||
try {
|
try {
|
||||||
const { id } = req.params; // Movement ID
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const { quantity, notes } = req.body;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
await query('UPDATE stock_movements SET quantity = $1, reason = $2 WHERE id = $3', [quantity, notes, id]);
|
||||||
const userId = req.appUser?.externalId || 'system';
|
res.json({ message: 'Movimentação atualizada.' });
|
||||||
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
if (!isAdmin) {
|
|
||||||
return res.status(403).json({ error: 'Apenas administradores podem editar movimentações.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { date, quantity, notes } = req.body;
|
|
||||||
|
|
||||||
const movement = await StockMovement.findOne({ _id: id, organizationId });
|
|
||||||
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
|
|
||||||
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
|
|
||||||
|
|
||||||
// Calculate Delta
|
|
||||||
// If quantity changed, we need to adjust the item balance
|
|
||||||
// Note: 'quantity' in movement is signed (+ for entry, - for consumption)
|
|
||||||
// If the user edits a Consumption (-10) to (-15), the val passed in body might be absolute or signed?
|
|
||||||
// Let's assume the frontend sends the SIGNED value consistent with the movement type?
|
|
||||||
// Actually best to stick to specific logic:
|
|
||||||
// If movement type is ENTRY/ADJUSTMENT, quantity is usually positive (unless neg adjustment).
|
|
||||||
// If CONSUMPTION, quantity is stored negative.
|
|
||||||
// Let's expect the frontend to send the 'raw' new value.
|
|
||||||
// Be careful: if frontend sends positive 10 for a consumption, we must flip it?
|
|
||||||
// Let's assume frontend sends the value exactly as it should be stored.
|
|
||||||
|
|
||||||
// HOWEVER, it's safer if we check type.
|
|
||||||
const newQuantitySigned = Number(quantity);
|
|
||||||
|
|
||||||
// Validation: Consumption should generally be negative, Entry positive.
|
|
||||||
// But for flexibility let's just trust the arithmetic diff for now,
|
|
||||||
// but warn if sign flips unexpectedly?
|
|
||||||
|
|
||||||
const oldQuantity = Number(movement.quantity);
|
|
||||||
const quantityDiff = newQuantitySigned - oldQuantity;
|
|
||||||
|
|
||||||
// Update Item
|
|
||||||
const newStockLevel = Number(item.quantity) + quantityDiff;
|
|
||||||
if (newStockLevel < 0) {
|
|
||||||
return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
item.quantity = newStockLevel;
|
|
||||||
await item.save();
|
|
||||||
|
|
||||||
// Audit Log
|
|
||||||
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
|
|
||||||
const typeLabel = typeMap[movement.type] || movement.type;
|
|
||||||
|
|
||||||
await StockAuditLog.create({
|
|
||||||
organizationId,
|
|
||||||
stockItemId: item._id,
|
|
||||||
movementId: movement._id,
|
|
||||||
movementNumber: movement.movementNumber,
|
|
||||||
userId,
|
|
||||||
userName,
|
|
||||||
action: 'UPDATE',
|
|
||||||
details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`,
|
|
||||||
oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes },
|
|
||||||
newValues: { date, quantity: newQuantitySigned, notes }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update Movement
|
|
||||||
movement.quantity = newQuantitySigned;
|
|
||||||
if (date) movement.date = date;
|
|
||||||
if (notes !== undefined) movement.notes = notes;
|
|
||||||
await movement.save();
|
|
||||||
|
|
||||||
res.json(movement);
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
console.error('Error updating movement:', error);
|
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteStockMovement = async (req: AuthRequest, res: Response) => {
|
export const deleteStockMovement = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
await query('DELETE FROM stock_movements WHERE id = $1', [id]);
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
|
||||||
const userId = req.appUser?.externalId || 'system';
|
|
||||||
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return res.status(403).json({ error: 'Apenas administradores podem excluir movimentações.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const movement = await StockMovement.findOne({ _id: id, organizationId });
|
|
||||||
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
|
|
||||||
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
|
|
||||||
|
|
||||||
// Reverse the effect
|
|
||||||
// If we delete an Entry (+10), we MUST subtract 10 from Item.
|
|
||||||
// If we delete a Consumption (-10), we MUST add 10 (subtract -10) to Item.
|
|
||||||
// So: Item.quantity -= movement.quantity
|
|
||||||
|
|
||||||
const reverseQty = Number(movement.quantity);
|
|
||||||
const newStockLevel = Number(item.quantity) - reverseQty;
|
|
||||||
|
|
||||||
if (newStockLevel < 0) {
|
|
||||||
return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
item.quantity = newStockLevel;
|
|
||||||
await item.save();
|
|
||||||
|
|
||||||
// Audit Log
|
|
||||||
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
|
|
||||||
const typeLabel = typeMap[movement.type] || movement.type;
|
|
||||||
|
|
||||||
await StockAuditLog.create({
|
|
||||||
organizationId,
|
|
||||||
stockItemId: item._id,
|
|
||||||
movementId: movement._id,
|
|
||||||
movementNumber: movement.movementNumber,
|
|
||||||
userId,
|
|
||||||
userName,
|
|
||||||
action: 'DELETE',
|
|
||||||
details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`,
|
|
||||||
oldValues: movement.toObject()
|
|
||||||
});
|
|
||||||
|
|
||||||
await StockMovement.deleteOne({ _id: id });
|
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
|
} catch (error: any) {
|
||||||
} catch (error: unknown) {
|
res.status(500).json({ error: error.message });
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
console.error('Error deleting movement:', error);
|
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
|
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params; // StockItem ID
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const result = await query('SELECT * FROM stock_audit_logs WHERE stock_item_id = $1 ORDER BY timestamp DESC', [id]);
|
||||||
|
res.json(result?.rows || []);
|
||||||
const logs = await StockAuditLog.find({ stockItemId: id, organizationId })
|
} catch (error: any) {
|
||||||
.sort({ timestamp: -1 });
|
res.status(500).json({ error: error.message });
|
||||||
|
|
||||||
res.json(logs);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +1,72 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import User, { IUser } from '../models/User.js';
|
import { query } from '../config/database.js';
|
||||||
import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js';
|
import { snakeToCamel } from '../utils/mapper.js';
|
||||||
|
|
||||||
// Define locally to avoid import cycle risks
|
interface IAppUser_Postgres {
|
||||||
interface IAppUser extends IUser {
|
id: string;
|
||||||
organizationId?: string;
|
logto_id: string;
|
||||||
organizationRole?: OrgRole;
|
email: string;
|
||||||
organizationBanned?: boolean;
|
name: string;
|
||||||
|
role: 'guest' | 'user' | 'admin';
|
||||||
|
is_banned: boolean;
|
||||||
|
organization_id?: string;
|
||||||
|
organization_role?: string;
|
||||||
|
organization_banned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthRequest extends Request {
|
interface AuthRequest extends Request {
|
||||||
appUser?: IAppUser;
|
appUser?: IAppUser_Postgres | any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync user from Auth to MongoDB
|
|
||||||
* Creates user if doesn't exist, updates if exists
|
|
||||||
* Also creates/updates OrganizationMember for the current organization
|
|
||||||
*/
|
|
||||||
export const syncUser = async (req: Request, res: Response) => {
|
export const syncUser = async (req: Request, res: Response) => {
|
||||||
console.log('--- syncUser called ---', req.body);
|
|
||||||
try {
|
try {
|
||||||
const { externalId, email, name, organizationId, incomingRole } = req.body;
|
const { logtoId, email, name, organizationId, incomingRole } = req.body;
|
||||||
|
|
||||||
if (!externalId || !email || !name) {
|
if (!logtoId || !email || !name) {
|
||||||
return res.status(400).json({ error: 'externalId, email e name são obrigatórios.' });
|
return res.status(400).json({ error: 'logtoId, email e name são obrigatórios.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Upsert the global User record
|
const userResult = await query(
|
||||||
let user = await User.findOne({ externalId });
|
`INSERT INTO users (logto_id, email, name, role, is_banned, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
|
ON CONFLICT (logto_id)
|
||||||
|
DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name, updated_at = NOW()
|
||||||
|
RETURNING id, logto_id, email, name, role, is_banned`,
|
||||||
|
[logtoId, email, name, 'guest', false]
|
||||||
|
);
|
||||||
|
|
||||||
if (user) {
|
const user = userResult?.rows[0];
|
||||||
user.email = email;
|
|
||||||
user.name = name;
|
|
||||||
await user.save();
|
|
||||||
} else {
|
|
||||||
user = await User.create({
|
|
||||||
externalId,
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
role: 'guest', // Default global role
|
|
||||||
isBanned: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
|
let appRole = 'guest';
|
||||||
|
if (incomingRole === 'org:admin') appRole = 'admin';
|
||||||
|
else if (incomingRole === 'org:member') appRole = 'user';
|
||||||
|
|
||||||
// Map Auth role to our app role
|
const memberResult = await query(
|
||||||
let appRole: OrgRole = 'guest';
|
`INSERT INTO user_organizations (user_id, organization_id, role, is_banned, updated_at)
|
||||||
if (incomingRole === 'org:admin') {
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
appRole = 'admin';
|
ON CONFLICT (user_id, organization_id)
|
||||||
} else if (incomingRole === 'org:member') {
|
DO UPDATE SET updated_at = NOW()
|
||||||
appRole = 'user';
|
RETURNING role, is_banned`,
|
||||||
}
|
[user.id, organizationId, appRole, false]
|
||||||
|
|
||||||
// Use findOneAndUpdate with upsert to handle race conditions atomically
|
|
||||||
// This avoids the need for try/catch on create and handles existing members too
|
|
||||||
const member = await OrganizationMember.findOneAndUpdate(
|
|
||||||
{ userId: externalId, organizationId },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
// Only update role if it's the first time (creation)
|
|
||||||
// Or we can optionally update it if needed.
|
|
||||||
// For now, let's NOT overwrite role on update to preserve local changes,
|
|
||||||
// UNLESS we want to force sync with Auth.
|
|
||||||
// Let's use $setOnInsert for fields we only want to set on creation.
|
|
||||||
},
|
|
||||||
$setOnInsert: {
|
|
||||||
role: appRole,
|
|
||||||
isBanned: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return combined info
|
const member = memberResult?.rows[0];
|
||||||
return res.json({
|
|
||||||
...user.toObject(),
|
return res.json(snakeToCamel({
|
||||||
organizationRole: member.role,
|
...user,
|
||||||
organizationBanned: member.isBanned
|
organization_role: member.role,
|
||||||
});
|
organization_banned: member.is_banned
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(user);
|
res.json(snakeToCamel(user));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error syncing user:', error);
|
console.error('Error syncing user:', error);
|
||||||
// Retornar 200 mesmo com erro para não travar o frontend se for algo não crítico,
|
|
||||||
// mas aqui é crítico. Vamos logar melhor.
|
|
||||||
res.status(500).json({ error: 'Erro ao sincronizar usuário: ' + (error instanceof Error ? error.message : String(error)) });
|
res.status(500).json({ error: 'Erro ao sincronizar usuário: ' + (error instanceof Error ? error.message : String(error)) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current user data with organization context
|
|
||||||
*/
|
|
||||||
export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
@@ -105,53 +76,53 @@ export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
|||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
const member = await OrganizationMember.findOne({
|
const memberResult = await query(
|
||||||
userId: req.appUser.externalId,
|
'SELECT role, is_banned FROM user_organizations WHERE user_id = $1 AND organization_id = $2',
|
||||||
organizationId
|
[req.appUser.id, organizationId]
|
||||||
});
|
);
|
||||||
|
|
||||||
if (member) {
|
if (memberResult?.rows[0]) {
|
||||||
return res.json({
|
const member = memberResult.rows[0];
|
||||||
...req.appUser.toObject(),
|
return res.json(snakeToCamel({
|
||||||
|
...req.appUser,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
isBanned: member.isBanned,
|
is_banned: member.is_banned,
|
||||||
organizationId
|
organization_id: organizationId
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(req.appUser);
|
res.json(snakeToCamel(req.appUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting current user:', error);
|
console.error('Error getting current user:', error);
|
||||||
res.status(500).json({ error: 'Erro ao buscar usuário.' });
|
res.status(500).json({ error: 'Erro ao buscar usuário.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all users for the current organization (admin only)
|
|
||||||
*/
|
|
||||||
export const getAllUsers = async (req: Request, res: Response) => {
|
export const getAllUsers = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
console.log('getAllUsers called with organizationId:', organizationId);
|
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await OrganizationMember.find({ organizationId }).sort({ createdAt: -1 });
|
const result = await query(
|
||||||
console.log(`Found ${members.length} members for org ${organizationId}:`, members.map(m => ({ name: m.name, email: m.email, externalId: m.userId })));
|
`SELECT u.id, u.email, u.name, uo.role, uo.is_banned, uo.created_at
|
||||||
res.json(members);
|
FROM users u
|
||||||
|
JOIN user_organizations uo ON u.id = uo.user_id
|
||||||
|
WHERE uo.organization_id = $1
|
||||||
|
ORDER BY uo.created_at DESC`,
|
||||||
|
[organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json((result?.rows || []).map(snakeToCamel));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting users:', error);
|
console.error('Error getting users:', error);
|
||||||
res.status(500).json({ error: 'Erro ao buscar usuários.' });
|
res.status(500).json({ error: 'Erro ao buscar usuários.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user role within organization (admin only)
|
|
||||||
*/
|
|
||||||
export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -163,35 +134,25 @@ export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!['guest', 'user', 'admin'].includes(role)) {
|
if (!['guest', 'user', 'admin'].includes(role)) {
|
||||||
return res.status(400).json({ error: 'Role inválido. Use: guest, user ou admin.' });
|
return res.status(400).json({ error: 'Role inválido.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await OrganizationMember.findById(id);
|
const result = await query(
|
||||||
if (!member || member.organizationId !== organizationId) {
|
'UPDATE user_organizations SET role = $1, updated_at = NOW() WHERE user_id = $2 AND organization_id = $3 RETURNING *',
|
||||||
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
|
[role, id, organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result?.rowCount) {
|
||||||
|
return res.status(404).json({ error: 'Membro não encontrado nesta organização.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent removing the last admin
|
res.json(snakeToCamel(result.rows[0]));
|
||||||
if (member.role === 'admin' && role !== 'admin') {
|
|
||||||
const adminCount = await OrganizationMember.countDocuments({ organizationId, role: 'admin' });
|
|
||||||
if (adminCount <= 1) {
|
|
||||||
return res.status(400).json({ error: 'Não é possível remover o último administrador.' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
member.role = role as OrgRole;
|
|
||||||
await member.save();
|
|
||||||
|
|
||||||
res.json(member);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling ban:', error);
|
console.error('Error updating role:', error);
|
||||||
res.status(500).json({ error: 'Erro ao alterar status de banimento.' });
|
res.status(500).json({ error: 'Erro ao alterar role.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Ban or unban user within organization (admin only)
|
|
||||||
*/
|
|
||||||
export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -202,95 +163,55 @@ export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await OrganizationMember.findById(id);
|
const result = await query(
|
||||||
if (!member || member.organizationId !== organizationId) {
|
'UPDATE user_organizations SET is_banned = $1, updated_at = NOW() WHERE user_id = $2 AND organization_id = $3 RETURNING *',
|
||||||
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
|
[isBanned, id, organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result?.rowCount) {
|
||||||
|
return res.status(404).json({ error: 'Membro não encontrado.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent banning yourself
|
res.json(snakeToCamel(result.rows[0]));
|
||||||
if (req.appUser && member.userId === req.appUser.externalId) {
|
|
||||||
return res.status(400).json({ error: 'Você não pode banir a si mesmo.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent banning another admin
|
|
||||||
if (member.role === 'admin') {
|
|
||||||
return res.status(400).json({ error: 'Não é possível banir um administrador.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
member.isBanned = isBanned;
|
|
||||||
await member.save();
|
|
||||||
|
|
||||||
res.json(member);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling ban:', error);
|
console.error('Error toggling ban:', error);
|
||||||
res.status(500).json({ error: 'Erro ao alterar status de banimento.' });
|
res.status(500).json({ error: 'Erro ao alterar status de banimento.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update current user's lastSeenAt timestamp
|
|
||||||
*/
|
|
||||||
export const heartbeat = async (req: AuthRequest, res: Response) => {
|
export const heartbeat = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) return res.status(401).end();
|
||||||
return res.status(401).json({ error: 'Não autenticado.' });
|
await query('UPDATE users SET last_seen_at = NOW() WHERE id = $1', [req.appUser.id]);
|
||||||
}
|
|
||||||
|
|
||||||
// Update User model
|
|
||||||
await User.findByIdAndUpdate(req.appUser._id, { lastSeenAt: new Date() });
|
|
||||||
|
|
||||||
// Also update Organization Member for tighter query
|
|
||||||
// But for now User model is enough if we join correctly, or just use User model for presence.
|
|
||||||
// Actually, since we want to show users per organization, we should filter by Org.
|
|
||||||
// Our 'User.ts' has organizationId, but it might be just the 'default' one.
|
|
||||||
// Let's rely on OrganizationMember for the list, but we need to update lastSeenAt there too?
|
|
||||||
// Strategy: Update User (global), and when querying active users, join or filter.
|
|
||||||
// Better: Update OrganizationMember too if we want org-specific presence?
|
|
||||||
// Simpler: Just update User. When fetching active users, we fetch OrganizationMembers and populate User details, filtering by User.lastSeenAt.
|
|
||||||
|
|
||||||
res.status(200).send();
|
res.status(200).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Silent fail for heartbeat
|
|
||||||
console.error('Heartbeat error:', error);
|
console.error('Heartbeat error:', error);
|
||||||
res.status(500).send();
|
res.status(500).send();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get active users in the same organization (seen in last 2 mins)
|
|
||||||
*/
|
|
||||||
export const getActiveUsers = async (req: AuthRequest, res: Response) => {
|
export const getActiveUsers = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
const currentUserId = req.appUser?._id;
|
if (!organizationId) return res.json([]);
|
||||||
|
|
||||||
if (!organizationId) {
|
const result = await query(
|
||||||
return res.status(400).json([]);
|
`SELECT u.id, u.name, u.email, u.last_seen_at, u.logto_id
|
||||||
}
|
FROM users u
|
||||||
|
JOIN user_organizations uo ON u.id = uo.user_id
|
||||||
|
WHERE uo.organization_id = $1
|
||||||
|
AND u.last_seen_at > NOW() - INTERVAL '2 minutes'
|
||||||
|
AND u.id != $2`,
|
||||||
|
[organizationId, req.appUser?.id]
|
||||||
|
);
|
||||||
|
|
||||||
// Find members of this org
|
res.json((result?.rows || []).map(snakeToCamel));
|
||||||
const members = await OrganizationMember.find({ organizationId });
|
|
||||||
|
|
||||||
// Get their Auth IDs
|
|
||||||
const externalIds = members.map(m => m.userId);
|
|
||||||
|
|
||||||
// Find Users who were seen recently (2 minutes)
|
|
||||||
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
|
|
||||||
|
|
||||||
const activeUsers = await User.find({
|
|
||||||
externalId: { $in: externalIds },
|
|
||||||
lastSeenAt: { $gte: twoMinutesAgo },
|
|
||||||
_id: { $ne: currentUserId } // Optional: exclude self
|
|
||||||
}).select('name email lastSeenAt externalId'); // Only needed fields
|
|
||||||
|
|
||||||
res.json(activeUsers);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting active users:', error);
|
console.error('Error getting active users:', error);
|
||||||
res.status(500).json([]);
|
res.status(500).json([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete organization member
|
|
||||||
export const deleteUser = async (req: Request, res: Response) => {
|
export const deleteUser = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -300,20 +221,20 @@ export const deleteUser = async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Deleting member ${id} from organization ${organizationId}`);
|
const result = await query(
|
||||||
|
'DELETE FROM user_organizations WHERE user_id = $1 AND organization_id = $2 RETURNING *',
|
||||||
|
[id, organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
// Delete from OrganizationMember collection
|
if (!result?.rowCount) {
|
||||||
const result = await OrganizationMember.findByIdAndDelete(id);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return res.status(404).json({ error: 'Membro não encontrado.' });
|
return res.status(404).json({ error: 'Membro não encontrado.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Member ${result.name} deleted successfully`);
|
res.json({ message: 'Membro removido com sucesso.' });
|
||||||
|
|
||||||
res.json({ message: 'Membro removido com sucesso.', deletedMember: result });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting user:', error);
|
console.error('Error deleting user:', error);
|
||||||
res.status(500).json({ error: 'Erro ao remover membro.' });
|
res.status(500).json({ error: 'Erro ao remover membro.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import app from './app.js';
|
import app from './app.js';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { migrateFilesToGridFS } from './services/dataSheetService.js';
|
|
||||||
import { connectDB } from './config/database.js';
|
import { connectDB } from './config/database.js';
|
||||||
import mongoose from 'mongoose';
|
|
||||||
import { notificationService } from './services/notificationService.js';
|
import { notificationService } from './services/notificationService.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -14,21 +12,15 @@ const startServer = async () => {
|
|||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(Number(PORT), '0.0.0.0', async () => {
|
app.listen(Number(PORT), '0.0.0.0', async () => {
|
||||||
console.log(`🚀 Server running on port ${PORT} (0.0.0.0)`);
|
console.log(`🚀 Server running on port ${PORT} (0.0.0.0)`);
|
||||||
if (mongoose.connection.readyState === 1) {
|
|
||||||
// await migrateFilesToGridFS().catch(err => console.error('Migration failed:', err));
|
|
||||||
|
|
||||||
// Agendar verificação de vencimento de estoque (a cada 24 horas)
|
// Schedule tasks
|
||||||
console.log('📅 Scheduling stock expiration check...');
|
console.log('📅 Scheduling stock expiration check...');
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
notificationService.checkStockExpirations();
|
|
||||||
}, 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
// Executar uma vez no início para garantir (opcional, bom para dev)
|
|
||||||
notificationService.checkStockExpirations();
|
notificationService.checkStockExpirations();
|
||||||
|
}, 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
} else {
|
// Execute once at start
|
||||||
console.warn('⚠️ MongoDB is not connected. Skipping migrations.');
|
notificationService.checkStockExpirations();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start server:', error);
|
console.error('Failed to start server:', error);
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod';
|
// Logto Discovery and JWKS
|
||||||
|
const LOGTO_ENDPOINT = process.env.LOGTO_ENDPOINT || 'https://logto.reifonas.cloud';
|
||||||
|
const JWKS = createRemoteJWKSet(new URL(`${LOGTO_ENDPOINT}/oidc/jwks`));
|
||||||
|
|
||||||
export const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
// Se não houver token autêntico JWT, prossegue limpo
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.split(' ')[1];
|
const token = authHeader.split(' ')[1];
|
||||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
|
||||||
|
// Verify token with Logto JWKS
|
||||||
|
// Note: For production, you should also verify 'audience' if configured
|
||||||
|
const { payload } = await jwtVerify(token, JWKS, {
|
||||||
|
issuer: `${LOGTO_ENDPOINT}/oidc`,
|
||||||
|
});
|
||||||
|
|
||||||
// Injeta o externalId no header para que o extractUser (roleMiddleware)
|
// The 'sub' claim in Logto is the user's unique ID
|
||||||
// continue seu trabalho de carregar o usuário do banco instanciado e popular req.appUser
|
req.headers['x-auth-user-id'] = payload.sub;
|
||||||
req.headers['x-auth-user-id'] = decoded.externalId;
|
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -24,3 +29,4 @@ export const authMiddleware = (req: Request, res: Response, next: NextFunction)
|
|||||||
res.status(401).json({ error: 'Token inválido ou expirado' });
|
res.status(401).json({ error: 'Token inválido ou expirado' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import User, { IUser } from '../models/User.js';
|
import { query } from '../config/database.js';
|
||||||
import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js';
|
|
||||||
import Organization from '../models/Organization.js';
|
export type OrgRole = 'guest' | 'user' | 'admin';
|
||||||
|
|
||||||
// Extended user info with organization context
|
// Extended user info with organization context
|
||||||
export interface IAppUser extends IUser {
|
export interface IAppUser {
|
||||||
|
id: string;
|
||||||
|
logtoId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: OrgRole;
|
||||||
|
isBanned: boolean;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
organizationRole?: OrgRole;
|
organizationRole?: OrgRole;
|
||||||
organizationBanned?: boolean;
|
organizationBanned?: boolean;
|
||||||
@@ -19,82 +25,71 @@ declare module 'express-serve-static-core' {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to extract and verify user from Auth ID header
|
* Middleware to extract and verify user from Auth ID header
|
||||||
* Also loads organization-specific role if organization context is provided
|
|
||||||
*/
|
*/
|
||||||
export const extractUser = async (req: Request, res: Response, next: NextFunction) => {
|
export const extractUser = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const externalId = req.headers['x-auth-user-id'] as string;
|
const logtoId = req.headers['x-auth-user-id'] as string;
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
if (!externalId) {
|
if (!logtoId) {
|
||||||
return next(); // No user, continue without
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findOne({ externalId });
|
// Fetch user from Postgres
|
||||||
|
const userResult = await query('SELECT * FROM users WHERE logto_id = $1', [logtoId]);
|
||||||
|
const user = userResult?.rows[0];
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
if (user.isBanned) {
|
if (user.is_banned) {
|
||||||
return res.status(403).json({ error: 'Conta bloqueada. Entre em contato com o administrador.' });
|
return res.status(403).json({ error: 'Conta bloqueada. Entre em contato com o administrador.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create extended user object
|
const appUser: IAppUser = {
|
||||||
const appUser: IAppUser = user.toObject() as IAppUser;
|
id: user.id,
|
||||||
appUser.organizationId = organizationId || user.organizationId;
|
logtoId: user.logto_id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role as OrgRole,
|
||||||
|
isBanned: user.is_banned,
|
||||||
|
organizationId: organizationId
|
||||||
|
};
|
||||||
|
|
||||||
// If organization context, get org-specific role
|
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
// Check if Organization is globally banned (subscription specific, etc.)
|
// Check if Organization is globally banned
|
||||||
const orgStatus = await Organization.findOne({ externalId: organizationId });
|
const orgResult = await query('SELECT * FROM organizations WHERE id = $1', [organizationId]);
|
||||||
|
const orgStatus = orgResult?.rows[0];
|
||||||
const orgName = req.headers['x-organization-name'] ? decodeURIComponent(req.headers['x-organization-name'] as string) : undefined;
|
const orgName = req.headers['x-organization-name'] ? decodeURIComponent(req.headers['x-organization-name'] as string) : undefined;
|
||||||
|
|
||||||
if (orgStatus) {
|
if (orgStatus) {
|
||||||
// Update name if different and present
|
|
||||||
if (orgName && orgStatus.name !== orgName) {
|
if (orgName && orgStatus.name !== orgName) {
|
||||||
try {
|
await query('UPDATE organizations SET name = $1 WHERE id = $2', [orgName, organizationId]);
|
||||||
await Organization.updateOne(
|
|
||||||
{ externalId: organizationId },
|
|
||||||
{ name: orgName }
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to update organization name', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orgStatus.isBanned) {
|
if (orgStatus.is_banned) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({ error: 'Acesso bloqueado: Esta organização está suspensa.' });
|
||||||
error: 'Acesso bloqueado: Esta organização está suspensa. Entre em contato com o suporte.'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new org with name if present
|
// Auto-create org if missing
|
||||||
try {
|
await query('INSERT INTO organizations (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING', [organizationId, orgName || 'New Org']);
|
||||||
await Organization.create({
|
|
||||||
externalId: organizationId,
|
|
||||||
name: orgName
|
|
||||||
});
|
|
||||||
} catch (_e) {
|
|
||||||
console.warn('Organization auto-create race condition', _e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await OrganizationMember.findOne({ userId: externalId, organizationId });
|
// Check membership
|
||||||
|
const memberResult = await query('SELECT role, is_banned FROM user_organizations WHERE user_id = $1 AND organization_id = $2', [user.id, organizationId]);
|
||||||
|
const member = memberResult?.rows[0];
|
||||||
|
|
||||||
if (member) {
|
if (member) {
|
||||||
if (member.isBanned) {
|
if (member.is_banned) {
|
||||||
return res.status(403).json({ error: 'Acesso bloqueado nesta organização.' });
|
return res.status(403).json({ error: 'Acesso bloqueado nesta organização.' });
|
||||||
}
|
}
|
||||||
appUser.organizationRole = member.role;
|
appUser.organizationRole = member.role as OrgRole;
|
||||||
appUser.role = member.role; // Override global role with org role
|
appUser.role = member.role as OrgRole; // Override global role with org role
|
||||||
} else {
|
} else {
|
||||||
// User exists but is not a member of this org yet
|
|
||||||
appUser.organizationRole = 'guest';
|
appUser.organizationRole = 'guest';
|
||||||
appUser.role = 'guest';
|
appUser.role = 'guest';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.appUser = appUser;
|
req.appUser = appUser;
|
||||||
// console.log(`✅ Request authenticated as: ${appUser.name} (${appUser.role})`);
|
|
||||||
} else {
|
|
||||||
console.warn(`⚠️ User with Auth ID ${externalId} not found in MongoDB. Sync required.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -104,17 +99,12 @@ export const extractUser = async (req: Request, res: Response, next: NextFunctio
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require specific roles for a route
|
|
||||||
* @param allowedRoles Array of roles that can access the route
|
|
||||||
*/
|
|
||||||
export const requireRole = (allowedRoles: OrgRole[]) => {
|
export const requireRole = (allowedRoles: OrgRole[]) => {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
return res.status(401).json({ error: 'Autenticação necessária.' });
|
return res.status(401).json({ error: 'Autenticação necessária.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEV Bypass: Developer has full power
|
|
||||||
if (req.appUser.email === 'admtracksteel@gmail.com') {
|
if (req.appUser.email === 'admtracksteel@gmail.com') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
@@ -129,19 +119,9 @@ export const requireRole = (allowedRoles: OrgRole[]) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require admin role
|
|
||||||
*/
|
|
||||||
export const requireAdmin = requireRole(['admin']);
|
export const requireAdmin = requireRole(['admin']);
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require at least user role (user or admin)
|
|
||||||
*/
|
|
||||||
export const requireUser = requireRole(['user', 'admin']);
|
export const requireUser = requireRole(['user', 'admin']);
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to check if user can edit (user or admin, not guest)
|
|
||||||
*/
|
|
||||||
export const canEdit = (req: Request, res: Response, next: NextFunction) => {
|
export const canEdit = (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
return res.status(401).json({ error: 'Autenticação necessária.' });
|
return res.status(401).json({ error: 'Autenticação necessária.' });
|
||||||
@@ -150,25 +130,21 @@ export const canEdit = (req: Request, res: Response, next: NextFunction) => {
|
|||||||
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
|
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
|
||||||
|
|
||||||
if (effectiveRole === 'guest') {
|
if (effectiveRole === 'guest') {
|
||||||
return res.status(403).json({ error: 'Convidados não podem editar. Solicite acesso ao administrador.' });
|
return res.status(403).json({ error: 'Convidados não podem editar.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require Developer (Super Admin) access
|
|
||||||
* Hardcoded to specific email for security
|
|
||||||
*/
|
|
||||||
export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => {
|
export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
return res.status(401).json({ error: 'Autenticação necessária.' });
|
return res.status(401).json({ error: 'Autenticação necessária.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.appUser.email !== 'admtracksteel@gmail.com') {
|
if (req.appUser.email !== 'admtracksteel@gmail.com') {
|
||||||
console.warn(`⛔ Attempted unauthorized developer access by: ${req.appUser.email}`);
|
|
||||||
return res.status(403).json({ error: 'Acesso restrito ao desenvolvedor.' });
|
return res.status(403).json({ error: 'Acesso restrito ao desenvolvedor.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
42
src/server/scripts/apply_schema.js
Normal file
42
src/server/scripts/apply_schema.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import pkg from 'pg';
|
||||||
|
const { Pool } = pkg;
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const applySchema = async () => {
|
||||||
|
const connectionString = process.env.POSTGRES_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
console.error('POSTGRES_URL not defined');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
ssl: connectionString.includes('supabase-db') ? false : { rejectUnauthorized: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Applying schema to Supabase...');
|
||||||
|
const schemaPath = path.join(process.cwd(), 'src', 'server', 'scripts', 'final_postgres_schema.sql');
|
||||||
|
const sql = fs.readFileSync(schemaPath, 'utf8');
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('CREATE SCHEMA IF NOT EXISTS gpi;');
|
||||||
|
await client.query('SET search_path TO gpi, public;');
|
||||||
|
await client.query(sql);
|
||||||
|
console.log('✅ Schema applied successfully!');
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to apply schema:', error);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
applySchema();
|
||||||
272
src/server/scripts/final_postgres_schema.sql
Normal file
272
src/server/scripts/final_postgres_schema.sql
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
|
||||||
|
-- Final Postgres Schema for GPI
|
||||||
|
-- Author: Antigravity
|
||||||
|
-- Date: 2026-03-24
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS gpi;
|
||||||
|
SET search_path TO gpi, public;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Organizations
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_banned BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
clerk_id TEXT UNIQUE,
|
||||||
|
logto_id TEXT UNIQUE,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'guest',
|
||||||
|
is_banned BOOLEAN DEFAULT FALSE,
|
||||||
|
last_seen_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User Organizations (Many-to-Many with Roles)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_organizations (
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'user',
|
||||||
|
is_banned BOOLEAN DEFAULT FALSE,
|
||||||
|
PRIMARY KEY (user_id, organization_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Technical Data Sheets (New)
|
||||||
|
CREATE TABLE IF NOT EXISTS technical_data_sheets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
manufacturer TEXT,
|
||||||
|
manufacturer_code TEXT,
|
||||||
|
type TEXT,
|
||||||
|
min_stock NUMERIC,
|
||||||
|
typical_application TEXT,
|
||||||
|
file_id UUID, -- References stored_files.id
|
||||||
|
file_url TEXT,
|
||||||
|
upload_date TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
solids_volume NUMERIC,
|
||||||
|
density NUMERIC,
|
||||||
|
mixing_ratio TEXT,
|
||||||
|
mixing_ratio_weight TEXT,
|
||||||
|
mixing_ratio_volume TEXT,
|
||||||
|
wft_min NUMERIC,
|
||||||
|
wft_max NUMERIC,
|
||||||
|
dft_min NUMERIC,
|
||||||
|
dft_max NUMERIC,
|
||||||
|
reducer TEXT,
|
||||||
|
yield_theoretical NUMERIC,
|
||||||
|
dft_reference NUMERIC,
|
||||||
|
yield_factor NUMERIC,
|
||||||
|
dilution NUMERIC,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Projects
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
client TEXT NOT NULL,
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
technician TEXT,
|
||||||
|
environment TEXT,
|
||||||
|
weight_kg NUMERIC,
|
||||||
|
status TEXT CHECK (status IN ('active', 'archived')) DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Parts
|
||||||
|
CREATE TABLE IF NOT EXISTS parts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
weight_kg NUMERIC,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Painting Schemes
|
||||||
|
CREATE TABLE IF NOT EXISTS painting_schemes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT,
|
||||||
|
coat TEXT,
|
||||||
|
color TEXT,
|
||||||
|
color_hex TEXT,
|
||||||
|
thinner_symbol TEXT,
|
||||||
|
eps_min NUMERIC,
|
||||||
|
eps_max NUMERIC,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Inspections
|
||||||
|
CREATE TABLE IF NOT EXISTS inspections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
part_id UUID REFERENCES parts(id) ON DELETE CASCADE,
|
||||||
|
inspector_id UUID REFERENCES users(id),
|
||||||
|
date DATE NOT NULL,
|
||||||
|
status TEXT,
|
||||||
|
weight_kg NUMERIC,
|
||||||
|
created_by TEXT, -- Logto user ID
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stock Items (Update/Create)
|
||||||
|
CREATE TABLE IF NOT EXISTS stock_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
data_sheet_id UUID REFERENCES technical_data_sheets(id),
|
||||||
|
rr_number TEXT NOT NULL,
|
||||||
|
batch_number TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
invoice_number TEXT,
|
||||||
|
received_by TEXT,
|
||||||
|
quantity NUMERIC DEFAULT 0,
|
||||||
|
unit TEXT,
|
||||||
|
min_stock NUMERIC DEFAULT 0,
|
||||||
|
expiration_date DATE,
|
||||||
|
entry_date DATE DEFAULT CURRENT_DATE,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stock Movements
|
||||||
|
CREATE TABLE IF NOT EXISTS stock_movements (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
stock_item_id UUID REFERENCES stock_items(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
type TEXT CHECK (type IN ('entry', 'exit', 'adjustment')),
|
||||||
|
quantity NUMERIC NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
date TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Instruments
|
||||||
|
CREATE TABLE IF NOT EXISTS instruments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
serial_number TEXT,
|
||||||
|
calibration_date DATE,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Application Records
|
||||||
|
CREATE TABLE IF NOT EXISTS application_records (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
coat_stage TEXT NOT NULL,
|
||||||
|
piece_description TEXT,
|
||||||
|
date DATE,
|
||||||
|
operator TEXT,
|
||||||
|
real_weight NUMERIC,
|
||||||
|
volume_used NUMERIC,
|
||||||
|
area_painted NUMERIC,
|
||||||
|
wet_thickness_avg NUMERIC,
|
||||||
|
dry_thickness_calc NUMERIC,
|
||||||
|
method TEXT,
|
||||||
|
diluent_used NUMERIC,
|
||||||
|
notes TEXT,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Yield Studies
|
||||||
|
CREATE TABLE IF NOT EXISTS yield_studies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
data_sheet_id UUID REFERENCES technical_data_sheets(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
target_dft NUMERIC,
|
||||||
|
dilution_percent NUMERIC DEFAULT 0,
|
||||||
|
total_weight NUMERIC,
|
||||||
|
estimated_paint_volume NUMERIC,
|
||||||
|
estimated_reducer_volume NUMERIC,
|
||||||
|
estimated_paint_volume_by_area NUMERIC,
|
||||||
|
estimated_reducer_volume_by_area NUMERIC,
|
||||||
|
average_complexity NUMERIC,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Yield Study Categories (Sub-document representation)
|
||||||
|
CREATE TABLE IF NOT EXISTS yield_study_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
yield_study_id UUID REFERENCES yield_studies(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
weight NUMERIC NOT NULL,
|
||||||
|
area NUMERIC,
|
||||||
|
historical_yield NUMERIC,
|
||||||
|
historical_dft NUMERIC,
|
||||||
|
efficiency NUMERIC
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Geometry Types
|
||||||
|
CREATE TABLE IF NOT EXISTS geometry_types (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
complexity_factor NUMERIC DEFAULT 1.0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stored Files
|
||||||
|
CREATE TABLE IF NOT EXISTS stored_files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
data BYTEA NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
upload_date TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Notifications
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
type TEXT,
|
||||||
|
title TEXT,
|
||||||
|
message TEXT,
|
||||||
|
read BOOLEAN DEFAULT FALSE,
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE,
|
||||||
|
archived_by UUID[] DEFAULT '{}',
|
||||||
|
deleted_by UUID[] DEFAULT '{}',
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Migrations/Updates
|
||||||
|
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS is_archived BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS archived_by UUID[] DEFAULT '{}';
|
||||||
|
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS deleted_by UUID[] DEFAULT '{}';
|
||||||
|
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS metadata JSONB;
|
||||||
@@ -1,72 +1,83 @@
|
|||||||
import ApplicationRecord from '../models/ApplicationRecord.js';
|
import { query } from '../config/database.js';
|
||||||
|
import { snakeToCamel } from '../utils/mapper.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const createApplicationRecord = async (data: any & { organizationId?: string, createdBy?: string }) => {
|
export const createApplicationRecord = async (data: any & { organizationId?: string, createdBy?: string }) => {
|
||||||
const newRecord = new ApplicationRecord({
|
const columns: string[] = [];
|
||||||
...data,
|
const values: any[] = [];
|
||||||
date: data.date ? new Date(data.date) : null,
|
let i = 1;
|
||||||
organizationId: data.organizationId,
|
|
||||||
createdBy: data.createdBy
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
columns.push(sqlKey);
|
||||||
|
values.push(typeof value === 'object' ? JSON.stringify(value) : value);
|
||||||
});
|
});
|
||||||
const saved = await newRecord.save();
|
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
const result = await query(
|
||||||
|
`INSERT INTO application_records (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getApplicationRecordsByProject = async (projectId: string, organizationId?: string) => {
|
export const getApplicationRecordsByProject = async (projectId: string, organizationId?: string) => {
|
||||||
const query = { projectId, ...(organizationId ? { organizationId } : {}) };
|
let whereClause = 'WHERE project_id = $1';
|
||||||
const records = await ApplicationRecord.find(query).sort({ date: -1 }).lean();
|
const params: any[] = [projectId];
|
||||||
return records.map(r => ({ ...r, id: r._id.toString() }));
|
if (organizationId) {
|
||||||
|
whereClause += ' AND organization_id = $2';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
const result = await query(`SELECT * FROM application_records ${whereClause} ORDER BY date DESC`, params);
|
||||||
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const updateApplicationRecord = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
export const updateApplicationRecord = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
||||||
const existing = await ApplicationRecord.findById(id);
|
const check = await query('SELECT organization_id, created_by FROM application_records WHERE id = $1', [id]);
|
||||||
|
const existing = check?.rows[0];
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
// Organization Check
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role/Ownership check
|
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
if (!isPowerUser && existing.created_by && existing.created_by !== userId) {
|
||||||
console.warn(`Permission Denied: User ${userId} tried to update record ${id} created by ${existing.createdBy}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData = {
|
const fields: string[] = [];
|
||||||
...data,
|
const params: any[] = [];
|
||||||
date: data.date ? new Date(data.date) : undefined
|
let i = 1;
|
||||||
};
|
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
updateData.organizationId = organizationId;
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
}
|
fields.push(`${sqlKey} = $${i++}`);
|
||||||
|
params.push(typeof value === 'object' ? JSON.stringify(value) : value);
|
||||||
|
});
|
||||||
|
|
||||||
const updated = await ApplicationRecord.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
|
params.push(id);
|
||||||
if (updated) {
|
const result = await query(
|
||||||
return { ...updated, id: updated._id.toString() };
|
`UPDATE application_records SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`,
|
||||||
}
|
params
|
||||||
return null;
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteApplicationRecord = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
export const deleteApplicationRecord = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
||||||
const existing = await ApplicationRecord.findById(id);
|
const check = await query('SELECT organization_id, created_by FROM application_records WHERE id = $1', [id]);
|
||||||
|
const existing = check?.rows[0];
|
||||||
if (!existing) return false;
|
if (!existing) return false;
|
||||||
|
|
||||||
// Organization Check
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role/Ownership check
|
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
if (!isPowerUser && existing.created_by && existing.created_by !== userId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ApplicationRecord.deleteOne({ _id: id });
|
await query('DELETE FROM application_records WHERE id = $1', [id]);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,174 +1,142 @@
|
|||||||
import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
|
import { query } from '../config/database.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
|
||||||
import { bucket } from '../config/database.js';
|
|
||||||
import { ObjectId } from 'mongodb';
|
|
||||||
|
|
||||||
export const saveFileToGridFS = (localPath: string, filename: string): Promise<string> => {
|
export const saveFileToDB = async (localPath: string, filename: string): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
const data = fs.readFileSync(localPath);
|
||||||
const uploadStream = bucket.openUploadStream(filename);
|
const contentType = filename.endsWith('.pdf') ? 'application/pdf' : 'application/octet-stream';
|
||||||
const readStream = fs.createReadStream(localPath);
|
const result = await query(
|
||||||
|
'INSERT INTO stored_files (filename, content_type, data, size) VALUES ($1, $2, $3, $4) RETURNING id',
|
||||||
readStream.pipe(uploadStream)
|
[filename, contentType, data, data.length]
|
||||||
.on('error', reject)
|
);
|
||||||
.on('finish', () => {
|
// Remove local file
|
||||||
// Remove local file after upload
|
fs.unlink(localPath, (err) => {
|
||||||
fs.unlink(localPath, (err) => {
|
if (err) console.error('Failed to delete local temp file:', err);
|
||||||
if (err) console.error('Failed to delete local temp file:', err);
|
|
||||||
});
|
|
||||||
resolve(uploadStream.id.toString());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
return result?.rows[0].id;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteFileFromGridFS = async (fileId: string) => {
|
export const deleteFileFromDB = async (fileId: string) => {
|
||||||
try {
|
try {
|
||||||
await bucket.delete(new ObjectId(fileId));
|
await query('DELETE FROM stored_files WHERE id = $1', [fileId]);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete file from GridFS:', err);
|
console.error('Failed to delete file from Postgres:', err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFileStream = (fileId: string) => {
|
export const getFileStream = async (fileId: string) => {
|
||||||
if (!ObjectId.isValid(fileId)) {
|
const result = await query('SELECT data, filename, content_type FROM stored_files WHERE id = $1', [fileId]);
|
||||||
throw new Error('Invalid file ID format');
|
const file = result?.rows[0];
|
||||||
}
|
if (!file) throw new Error('Arquivo não encontrado');
|
||||||
return bucket.openDownloadStream(new ObjectId(fileId));
|
return file;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllDataSheets = async (organizationId?: string) => {
|
export const getAllDataSheets = async (organizationId?: string) => {
|
||||||
const query = organizationId
|
let whereClause = '';
|
||||||
? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }
|
const params: any[] = [];
|
||||||
: {};
|
if (organizationId) {
|
||||||
const sheets = await TechnicalDataSheet.find(query).sort({ uploadDate: -1 }).lean();
|
whereClause = 'WHERE (organization_id = $1 OR organization_id IS NULL)';
|
||||||
return sheets.map(s => ({ ...s, id: s._id.toString() }));
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
const result = await query(`SELECT * FROM technical_data_sheets ${whereClause} ORDER BY upload_date DESC`, params);
|
||||||
|
return result?.rows || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const matchSheets = async (query: string, organizationId?: string) => {
|
export const matchSheets = async (searchQuery: string, organizationId?: string) => {
|
||||||
const orgFilter = organizationId
|
let whereClause = '(name ILIKE $1 OR manufacturer ILIKE $1 OR type ILIKE $1)';
|
||||||
? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }
|
const params: any[] = [`%${searchQuery}%`];
|
||||||
: {};
|
|
||||||
|
if (organizationId) {
|
||||||
const filter = {
|
whereClause += ' AND (organization_id = $2 OR organization_id IS NULL)';
|
||||||
...orgFilter,
|
params.push(organizationId);
|
||||||
$or: [
|
|
||||||
{ name: { $regex: query, $options: 'i' } },
|
|
||||||
{ manufacturer: { $regex: query, $options: 'i' } },
|
|
||||||
{ type: { $regex: query, $options: 'i' } }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const sheets = await TechnicalDataSheet.find(filter).lean();
|
|
||||||
return sheets.map(s => ({ ...s, id: s._id.toString() }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const createDataSheet = async (data: any & { organizationId?: string }) => {
|
|
||||||
let fileId = data.fileUrl;
|
|
||||||
|
|
||||||
// If fileUrl is a local path (exists on disk), move to GridFS
|
|
||||||
if (data.fileUrl && fs.existsSync(data.fileUrl)) {
|
|
||||||
fileId = await saveFileToGridFS(data.fileUrl, data.name + '.pdf');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSheet = new TechnicalDataSheet({
|
const result = await query(`SELECT * FROM technical_data_sheets WHERE ${whereClause} LIMIT 20`, params);
|
||||||
...data,
|
return result?.rows || [];
|
||||||
fileUrl: fileId, // Now storing GridFS ID instead of path
|
};
|
||||||
uploadDate: new Date(),
|
|
||||||
organizationId: data.organizationId
|
export const createDataSheet = async (data: any & { organizationId?: string }) => {
|
||||||
|
let fileId = data.fileUrl; // This might be a UUID now
|
||||||
|
|
||||||
|
if (data.fileUrl && fs.existsSync(data.fileUrl)) {
|
||||||
|
fileId = await saveFileToDB(data.fileUrl, data.name + '.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
const dbData = { ...data, file_id: fileId };
|
||||||
|
delete dbData.fileUrl; // Use file_id instead
|
||||||
|
|
||||||
|
Object.entries(dbData).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
columns.push(sqlKey);
|
||||||
|
values.push(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const saved = await newSheet.save();
|
const result = await query(
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
`INSERT INTO technical_data_sheets (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result?.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteDataSheet = async (id: string, organizationId?: string) => {
|
export const deleteDataSheet = async (id: string, organizationId?: string) => {
|
||||||
// Find first to check permissions
|
const check = await query('SELECT organization_id, file_id FROM technical_data_sheets WHERE id = $1', [id]);
|
||||||
const sheet = await TechnicalDataSheet.findById(id);
|
const sheet = check?.rows[0];
|
||||||
if (!sheet) return false;
|
if (!sheet) return false;
|
||||||
|
|
||||||
// Permission Check:
|
if (organizationId && sheet.organization_id && sheet.organization_id !== organizationId) {
|
||||||
// If current user is in an Org, and Sheet is in a DIFFERENT Org, deny.
|
|
||||||
// Explicitly allow if Sheet has NO Org (Legacy/Global).
|
|
||||||
if (organizationId && sheet.organizationId && sheet.organizationId !== organizationId) {
|
|
||||||
console.warn(`[Delete DataSheet] Access Denied. User Org: ${organizationId}, Sheet Org: ${sheet.organizationId}`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from GridFS if not a full URL
|
if (sheet.file_id) {
|
||||||
if (sheet.fileUrl && !sheet.fileUrl.startsWith('http')) {
|
await deleteFileFromDB(sheet.file_id);
|
||||||
await deleteFileFromGridFS(sheet.fileUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await TechnicalDataSheet.findByIdAndDelete(id);
|
await query('DELETE FROM technical_data_sheets WHERE id = $1', [id]);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const updateDataSheet = async (id: string, updates: any, organizationId?: string) => {
|
export const updateDataSheet = async (id: string, updates: any, organizationId?: string) => {
|
||||||
// SECURITY FIX: Allow update if:
|
const check = await query('SELECT organization_id, file_id FROM technical_data_sheets WHERE id = $1', [id]);
|
||||||
// 1. Matches ID AND Matches Organization
|
const oldSheet = check?.rows[0];
|
||||||
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
|
|
||||||
|
|
||||||
const oldSheet = await TechnicalDataSheet.findById(id);
|
|
||||||
if (!oldSheet) return null;
|
if (!oldSheet) return null;
|
||||||
|
|
||||||
if (organizationId && oldSheet.organizationId && oldSheet.organizationId !== organizationId) {
|
if (organizationId && oldSheet.organization_id && oldSheet.organization_id !== organizationId) {
|
||||||
console.warn(`Access Denied: Sheet ${id} belongs to ${oldSheet.organizationId}, user is ${organizationId}`);
|
return null;
|
||||||
return null; // Return null effectively hides it or acts as fail
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If new file is uploaded (path exists locally)
|
if (updates.fileUrl && fs.existsSync(updates.fileUrl)) {
|
||||||
if (updates.fileUrl && updates.fileUrl !== oldSheet.fileUrl && fs.existsSync(updates.fileUrl)) {
|
const newFileId = await saveFileToDB(updates.fileUrl, (updates.name || oldSheet.name) + '.pdf');
|
||||||
// Upload new file
|
if (oldSheet.file_id) {
|
||||||
const newFileId = await saveFileToGridFS(updates.fileUrl, (updates.name || oldSheet.name) + '.pdf');
|
await deleteFileFromDB(oldSheet.file_id);
|
||||||
|
|
||||||
// Delete old file from GridFS
|
|
||||||
if (oldSheet.fileUrl && !oldSheet.fileUrl.startsWith('http')) {
|
|
||||||
await deleteFileFromGridFS(oldSheet.fileUrl);
|
|
||||||
}
|
}
|
||||||
|
updates.file_id = newFileId;
|
||||||
updates.fileUrl = newFileId;
|
delete updates.fileUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organizationId && !oldSheet.organizationId) {
|
const fields: string[] = [];
|
||||||
updates.organizationId = organizationId;
|
const params: any[] = [];
|
||||||
}
|
let i = 1;
|
||||||
|
|
||||||
const updated = await TechnicalDataSheet.findOneAndUpdate({ _id: id }, updates, { new: true }).lean();
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
if (updated) {
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
return { ...updated, id: updated._id.toString() };
|
fields.push(`${sqlKey} = $${i++}`);
|
||||||
}
|
params.push(value);
|
||||||
return null;
|
});
|
||||||
};
|
|
||||||
|
|
||||||
export const migrateFilesToGridFS = async () => {
|
params.push(id);
|
||||||
try {
|
const result = await query(
|
||||||
const sheets = await TechnicalDataSheet.find({ fileUrl: { $regex: /^uploads\// } });
|
`UPDATE technical_data_sheets SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`,
|
||||||
console.log(`[MIGRATION] Found ${sheets.length} sheets to migrate to GridFS`);
|
params
|
||||||
|
);
|
||||||
for (const sheet of sheets) {
|
return result?.rows[0];
|
||||||
const localPath = path.join(process.cwd(), sheet.fileUrl);
|
|
||||||
if (fs.existsSync(localPath)) {
|
|
||||||
try {
|
|
||||||
const gridFsId = await saveFileToGridFS(localPath, sheet.name + '.pdf');
|
|
||||||
sheet.fileUrl = gridFsId;
|
|
||||||
await sheet.save();
|
|
||||||
console.log(`[MIGRATION] Successfully migrated: ${sheet.name}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[MIGRATION] Error migrating ${sheet.name}:`, err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`[MIGRATION] File not found for ${sheet.name}: ${localPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MIGRATION] Migration failed:', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +1,97 @@
|
|||||||
import Inspection from '../models/Inspection.js';
|
import { query } from '../config/database.js';
|
||||||
|
import { snakeToCamel } from '../utils/mapper.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const createInspection = async (data: any & { organizationId?: string, createdBy?: string }) => {
|
export const createInspection = async (data: any & { organizationId?: string, createdBy?: string }) => {
|
||||||
const newInspection = new Inspection({
|
const columns: string[] = [];
|
||||||
...data,
|
const values: any[] = [];
|
||||||
date: data.date ? new Date(data.date) : null,
|
let i = 1;
|
||||||
organizationId: data.organizationId,
|
|
||||||
createdBy: data.createdBy
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
columns.push(sqlKey);
|
||||||
|
values.push(value);
|
||||||
});
|
});
|
||||||
const saved = await newInspection.save();
|
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
const result = await query(
|
||||||
|
`INSERT INTO inspections (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInspectionsByProject = async (projectId: string, organizationId?: string) => {
|
export const getInspectionsByProject = async (projectId: string, organizationId?: string) => {
|
||||||
const query = { projectId, ...(organizationId ? { organizationId } : {}) };
|
let whereClause = 'WHERE project_id = $1';
|
||||||
const inspections = await Inspection.find(query).sort({ date: -1 }).lean();
|
const params: any[] = [projectId];
|
||||||
return inspections.map(i => ({ ...i, id: i._id.toString() }));
|
if (organizationId) {
|
||||||
|
whereClause += ' AND organization_id = $2';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
const result = await query(`SELECT * FROM inspections ${whereClause} ORDER BY date DESC`, params);
|
||||||
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const updateInspection = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
export const updateInspection = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
||||||
const existing = await Inspection.findById(id);
|
const check = await query('SELECT organization_id, created_by FROM inspections WHERE id = $1', [id]);
|
||||||
|
const existing = check?.rows[0];
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
// Organization Check
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role/Ownership check
|
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
if (!isPowerUser && existing.created_by && existing.created_by !== userId) {
|
||||||
console.warn(`Permission Denied: User ${userId} tried to update inspection ${id} created by ${existing.createdBy}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData = {
|
const fields: string[] = [];
|
||||||
...data,
|
const params: any[] = [];
|
||||||
date: data.date ? new Date(data.date) : undefined
|
let i = 1;
|
||||||
};
|
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
updateData.organizationId = organizationId;
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
}
|
fields.push(`${sqlKey} = $${i++}`);
|
||||||
|
params.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
const updated = await Inspection.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
|
params.push(id);
|
||||||
if (updated) {
|
const result = await query(
|
||||||
return { ...updated, id: updated._id.toString() };
|
`UPDATE inspections SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`,
|
||||||
}
|
params
|
||||||
return null;
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteInspection = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
export const deleteInspection = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
||||||
const existing = await Inspection.findById(id);
|
const check = await query('SELECT organization_id, created_by FROM inspections WHERE id = $1', [id]);
|
||||||
|
const existing = check?.rows[0];
|
||||||
if (!existing) return false;
|
if (!existing) return false;
|
||||||
|
|
||||||
// Organization Check
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role/Ownership check
|
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
if (!isPowerUser && existing.created_by && existing.created_by !== userId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Inspection.deleteOne({ _id: id });
|
await query('DELETE FROM inspections WHERE id = $1', [id]);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllInspections = async (organizationId?: string) => {
|
export const getAllInspections = async (organizationId?: string) => {
|
||||||
const query = organizationId ? { organizationId } : {};
|
let whereClause = '';
|
||||||
const inspections = await Inspection.find(query).lean();
|
const params: any[] = [];
|
||||||
return inspections.map(i => ({ ...i, id: i._id.toString() }));
|
if (organizationId) {
|
||||||
|
whereClause = 'WHERE organization_id = $1';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
const result = await query(`SELECT * FROM inspections ${whereClause} ORDER BY date DESC`, params);
|
||||||
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +1,100 @@
|
|||||||
import Notification, { INotification } from '../models/Notification.js';
|
import { query } from '../config/database.js';
|
||||||
import StockItem from '../models/StockItem.js';
|
|
||||||
import Instrument from '../models/Instrument.js';
|
|
||||||
import { addMonths, isBefore } from 'date-fns';
|
import { addMonths, isBefore } from 'date-fns';
|
||||||
|
|
||||||
|
const mapNotification = (n: any) => ({
|
||||||
|
...n,
|
||||||
|
organizationId: n.organization_id,
|
||||||
|
userId: n.user_id,
|
||||||
|
isRead: n.read,
|
||||||
|
metadata: n.metadata,
|
||||||
|
createdAt: n.created_at,
|
||||||
|
archivedBy: n.archived_by || [],
|
||||||
|
deletedBy: n.deleted_by || [],
|
||||||
|
});
|
||||||
|
|
||||||
export const notificationService = {
|
export const notificationService = {
|
||||||
// Criar uma notificação
|
async create(data: any) {
|
||||||
async create(data: Partial<INotification>) {
|
|
||||||
try {
|
try {
|
||||||
const notification = new Notification(data);
|
const columns: string[] = [];
|
||||||
await notification.save();
|
const values: any[] = [];
|
||||||
return notification;
|
let i = 1;
|
||||||
|
|
||||||
|
const dbData = { ...data };
|
||||||
|
if (dbData.organizationId) {
|
||||||
|
dbData.organization_id = dbData.organizationId;
|
||||||
|
delete dbData.organizationId;
|
||||||
|
}
|
||||||
|
if (dbData.userId) {
|
||||||
|
dbData.user_id = dbData.userId;
|
||||||
|
delete dbData.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(dbData).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
columns.push(sqlKey);
|
||||||
|
values.push(typeof value === 'object' ? JSON.stringify(value) : value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO notifications (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return mapNotification(result?.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating notification:', error);
|
console.error('Error creating notification:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verificar se já existe uma notificação recente para evitar spam
|
|
||||||
async isAlreadyNotified(orgId: string, metadata: Record<string, string>, graceDays: number = 30) {
|
async isAlreadyNotified(orgId: string, metadata: Record<string, string>, graceDays: number = 30) {
|
||||||
try {
|
try {
|
||||||
const graceDate = new Date();
|
const graceDate = new Date();
|
||||||
graceDate.setDate(graceDate.getDate() - graceDays);
|
graceDate.setDate(graceDate.getDate() - graceDays);
|
||||||
|
|
||||||
const query: Record<string, unknown> = {
|
// To check JSONB metadata properly in PG: metadata @> '{"stockItemId": "..."}'
|
||||||
organizationId: orgId
|
const result = await query(
|
||||||
};
|
'SELECT * FROM notifications WHERE organization_id = $1 AND metadata @> $2 AND created_at >= $3 LIMIT 1',
|
||||||
|
[orgId, JSON.stringify(metadata), graceDate]
|
||||||
// Adicionar campos de metadata à query
|
);
|
||||||
for (const [key, value] of Object.entries(metadata)) {
|
return !!result?.rows[0];
|
||||||
query[`metadata.${key}`] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se existe alguma notificação com essa metadata nos últimos graceDays
|
|
||||||
// Independente de estar lida ou não, para evitar duplicidade.
|
|
||||||
query.createdAt = { $gte: graceDate };
|
|
||||||
|
|
||||||
const existing = await Notification.findOne(query);
|
|
||||||
return !!existing;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking notification existence:', error);
|
console.error('Error checking notification existence:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Obter notificações de um usuário (ou globais da organização)
|
|
||||||
async getUserNotifications(userId: string, organizationId: string, includeArchived: boolean = false) {
|
async getUserNotifications(userId: string, organizationId: string, includeArchived: boolean = false) {
|
||||||
try {
|
try {
|
||||||
const query: Record<string, unknown> = {
|
let where = 'organization_id = $1 AND (user_id = $2 OR user_id IS NULL) AND NOT ($2 = ANY(deleted_by))';
|
||||||
organizationId,
|
const params: any[] = [organizationId, userId];
|
||||||
$or: [
|
|
||||||
{ recipientId: userId },
|
|
||||||
{ recipientId: null } // Notificações globais
|
|
||||||
],
|
|
||||||
deletedBy: { $ne: userId } // Não mostrar as deletadas pelo usuário
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!includeArchived) {
|
if (!includeArchived) {
|
||||||
// Filtra as arquivadas (pelo usuário ou globalmente)
|
where += ' AND is_archived = FALSE AND NOT ($2 = ANY(archived_by))';
|
||||||
query.isArchived = false;
|
|
||||||
query.archivedBy = { $ne: userId };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await Notification.find(query).sort({ createdAt: -1 }).limit(50);
|
const result = await query(`SELECT * FROM notifications WHERE ${where} ORDER BY created_at DESC LIMIT 50`, params);
|
||||||
|
return (result?.rows || []).map(mapNotification);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching notifications:', error);
|
console.error('Error fetching notifications:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Marcar como lida
|
|
||||||
async markAsRead(id: string) {
|
async markAsRead(id: string) {
|
||||||
try {
|
try {
|
||||||
return await Notification.findByIdAndUpdate(id, { isRead: true }, { new: true });
|
const result = await query('UPDATE notifications SET read = TRUE WHERE id = $1 RETURNING *', [id]);
|
||||||
|
return mapNotification(result?.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error marking notification as read:', error);
|
console.error('Error marking notification as read:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Marcar todas como lidas para um usuário
|
|
||||||
async markAllAsRead(userId: string, organizationId: string) {
|
async markAllAsRead(userId: string, organizationId: string) {
|
||||||
try {
|
try {
|
||||||
return await Notification.updateMany(
|
await query(
|
||||||
{
|
'UPDATE notifications SET read = TRUE WHERE organization_id = $1 AND (user_id = $2 OR user_id IS NULL)',
|
||||||
organizationId,
|
[organizationId, userId]
|
||||||
$or: [
|
|
||||||
{ recipientId: userId },
|
|
||||||
{ recipientId: null }
|
|
||||||
],
|
|
||||||
isRead: false
|
|
||||||
},
|
|
||||||
{ isRead: true }
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error marking all notifications as read:', error);
|
console.error('Error marking all notifications as read:', error);
|
||||||
@@ -98,171 +102,66 @@ export const notificationService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Arquivar uma notificação para um usuário
|
|
||||||
async archive(id: string, userId: string) {
|
async archive(id: string, userId: string) {
|
||||||
try {
|
try {
|
||||||
const notification = await Notification.findById(id);
|
const check = await query('SELECT * FROM notifications WHERE id = $1', [id]);
|
||||||
if (!notification) return null;
|
const notif = check?.rows[0];
|
||||||
|
if (!notif) return null;
|
||||||
|
|
||||||
if (notification.recipientId) {
|
if (notif.user_id) {
|
||||||
// Notificação pessoal
|
await query('UPDATE notifications SET is_archived = TRUE, read = TRUE WHERE id = $1', [id]);
|
||||||
notification.isArchived = true;
|
|
||||||
notification.isRead = true;
|
|
||||||
} else {
|
} else {
|
||||||
// Notificação global
|
await query('UPDATE notifications SET archived_by = array_append(archived_by, $1) WHERE id = $2', [userId, id]);
|
||||||
if (!notification.archivedBy.includes(userId)) {
|
|
||||||
notification.archivedBy.push(userId);
|
|
||||||
}
|
|
||||||
// Marcar como lida também? Opcional
|
|
||||||
if (!notification.readBy?.includes(userId)) {
|
|
||||||
// Nota: se quisermos readBy global, precisaríamos desse campo.
|
|
||||||
// Para simplificar, vamos assumir que arquivar esconde da lista ativa.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return await notification.save();
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error archiving notification:', error);
|
console.error('Error archiving notification:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Deletar (esconder) uma notificação para um usuário
|
|
||||||
async softDelete(id: string, userId: string) {
|
async softDelete(id: string, userId: string) {
|
||||||
try {
|
try {
|
||||||
const notification = await Notification.findById(id);
|
const check = await query('SELECT * FROM notifications WHERE id = $1', [id]);
|
||||||
if (!notification) return null;
|
const notif = check?.rows[0];
|
||||||
|
if (!notif) return null;
|
||||||
|
|
||||||
if (notification.recipientId && notification.recipientId === userId) {
|
if (notif.user_id === userId) {
|
||||||
// Se for pessoal, podemos deletar do banco ou apenas marcar
|
await query('DELETE FROM notifications WHERE id = $1', [id]);
|
||||||
return await Notification.findByIdAndDelete(id);
|
|
||||||
} else {
|
} else {
|
||||||
// Se for global, apenas adicionar ao deletedBy
|
await query('UPDATE notifications SET deleted_by = array_append(deleted_by, $1) WHERE id = $2', [userId, id]);
|
||||||
if (!notification.deletedBy.includes(userId)) {
|
|
||||||
notification.deletedBy.push(userId);
|
|
||||||
}
|
|
||||||
return await notification.save();
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error soft deleting notification:', error);
|
console.error('Error soft deleting notification:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Limpar todas (esconder todas as atuais)
|
|
||||||
async clearAll(userId: string, organizationId: string) {
|
|
||||||
try {
|
|
||||||
// Para notificações pessoais: Deletar
|
|
||||||
await Notification.deleteMany({
|
|
||||||
organizationId,
|
|
||||||
recipientId: userId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Para notificações globais: Marcar como deletadas por esse usuário
|
|
||||||
const globalNotifications = await Notification.find({
|
|
||||||
organizationId,
|
|
||||||
recipientId: null,
|
|
||||||
deletedBy: { $ne: userId }
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const notif of globalNotifications) {
|
|
||||||
notif.deletedBy.push(userId);
|
|
||||||
await notif.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing all notifications:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Verificar vencimentos de estoque e gerar notificações
|
|
||||||
async checkStockExpirations() {
|
async checkStockExpirations() {
|
||||||
console.log('Running stock expiration checkJob...');
|
|
||||||
try {
|
try {
|
||||||
// Buscar todos os itens de estoque com data de validade que ainda não venceram ou venceram recentemente
|
const result = await query('SELECT * FROM stock_items WHERE expiration_date IS NOT NULL AND quantity > 0');
|
||||||
// Otimização: Em um sistema real, faríamos isso por query direta, mas aqui vamos iterar para aplicar a lógica de 2 meses, 1 mês, vencido.
|
const stockItems = result?.rows || [];
|
||||||
const stockItems = await StockItem.find({ expirationDate: { $exists: true, $ne: null }, quantity: { $gt: 0 } });
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const twoMonthsFromNow = addMonths(now, 2);
|
const twoMonthsFromNow = addMonths(now, 2);
|
||||||
const oneMonthFromNow = addMonths(now, 1);
|
const oneMonthFromNow = addMonths(now, 1);
|
||||||
|
|
||||||
for (const item of stockItems) {
|
for (const item of stockItems) {
|
||||||
if (!item.expirationDate) continue;
|
const expDate = new Date(item.expiration_date);
|
||||||
|
const itemId = item.id;
|
||||||
const expirationDate = new Date(item.expirationDate);
|
const orgId = item.organization_id;
|
||||||
const itemId = item._id.toString();
|
|
||||||
const orgId = item.organizationId;
|
|
||||||
|
|
||||||
if (!orgId) continue;
|
if (!orgId) continue;
|
||||||
|
|
||||||
let message = '';
|
if (isBefore(expDate, now)) {
|
||||||
let title = '';
|
await this.notifyCheck(orgId, itemId, 'expired', 'Item Vencido',
|
||||||
let type: 'warning' | 'error' = 'warning';
|
`O item ${item.rr_number} - Lote ${item.batch_number} venceu em ${expDate.toLocaleDateString()}.`, 'error');
|
||||||
|
} else if (isBefore(expDate, oneMonthFromNow)) {
|
||||||
// Lógica de notificação
|
await this.notifyCheck(orgId, itemId, 'expire_1_month', 'Vencimento Próximo (1 mês)',
|
||||||
// 1. Vencido
|
`O item ${item.rr_number} - Lote ${item.batch_number} vencerá em menos de 1 mês.`, 'warning');
|
||||||
if (isBefore(expirationDate, now)) {
|
} else if (isBefore(expDate, twoMonthsFromNow)) {
|
||||||
title = 'Item Vencido';
|
await this.notifyCheck(orgId, itemId, 'expire_2_months', 'Vencimento em 2 meses',
|
||||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} venceu em ${expirationDate.toLocaleDateString()}.`;
|
`O item ${item.rr_number} - Lote ${item.batch_number} vencerá em 2 meses.`, 'info');
|
||||||
type = 'error';
|
|
||||||
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
stockItemId: itemId,
|
|
||||||
triggerType: 'expired'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type,
|
|
||||||
metadata: { stockItemId: itemId, triggerType: 'expired' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. Vence em 1 mês (aprox)
|
|
||||||
else if (isBefore(expirationDate, oneMonthFromNow)) {
|
|
||||||
title = 'Vencimento Próximo (1 mês)';
|
|
||||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em menos de 1 mês (${expirationDate.toLocaleDateString()}).`;
|
|
||||||
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
stockItemId: itemId,
|
|
||||||
triggerType: 'expire_1_month'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type: 'warning',
|
|
||||||
metadata: { stockItemId: itemId, triggerType: 'expire_1_month' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
// 3. Vence em 2 meses (aprox)
|
|
||||||
else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
|
||||||
title = 'Vencimento em 2 meses';
|
|
||||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em 2 meses (${expirationDate.toLocaleDateString()}).`;
|
|
||||||
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
stockItemId: itemId,
|
|
||||||
triggerType: 'expire_2_months'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type: 'info',
|
|
||||||
metadata: { stockItemId: itemId, triggerType: 'expire_2_months' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -270,78 +169,39 @@ export const notificationService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verificar calibração de instrumentos
|
async notifyCheck(orgId: string, stockItemId: string, triggerType: string, title: string, message: string, type: string) {
|
||||||
async checkInstrumentCalibrations() {
|
const metadata = { stockItemId, triggerType };
|
||||||
console.log('Running instrument calibration checkJob...');
|
const notified = await this.isAlreadyNotified(orgId, metadata);
|
||||||
try {
|
if (!notified) {
|
||||||
const instruments = await Instrument.find({
|
await this.create({ organizationId: orgId, title, message, type, metadata });
|
||||||
calibrationExpirationDate: { $exists: true, $ne: null },
|
}
|
||||||
status: { $ne: 'inactive' }
|
},
|
||||||
});
|
|
||||||
|
|
||||||
|
async checkInstrumentCalibrations() {
|
||||||
|
try {
|
||||||
|
const result = await query('SELECT * FROM instruments WHERE calibration_expiration_date IS NOT NULL AND status != $1', ['inactive']);
|
||||||
|
const instruments = result?.rows || [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const twoMonthsFromNow = addMonths(now, 2);
|
const twoMonthsFromNow = addMonths(now, 2);
|
||||||
const oneMonthFromNow = addMonths(now, 1);
|
const oneMonthFromNow = addMonths(now, 1);
|
||||||
|
|
||||||
for (const instrument of instruments) {
|
for (const inst of instruments) {
|
||||||
if (!instrument.calibrationExpirationDate) continue;
|
const expDate = new Date(inst.calibration_expiration_date);
|
||||||
|
const instId = inst.id;
|
||||||
const expirationDate = new Date(instrument.calibrationExpirationDate);
|
const orgId = inst.organization_id;
|
||||||
const instrumentId = instrument._id.toString();
|
|
||||||
const orgId = instrument.organizationId;
|
|
||||||
|
|
||||||
if (!orgId) continue;
|
if (!orgId) continue;
|
||||||
|
|
||||||
let title = '';
|
if (isBefore(expDate, now)) {
|
||||||
let message = '';
|
const metadata = { instrumentId: instId, triggerType: 'calibration_expired' };
|
||||||
let type: 'info' | 'warning' | 'error' = 'info';
|
if (!(await this.isAlreadyNotified(orgId, metadata))) {
|
||||||
let triggerType = '';
|
await this.create({ organizationId: orgId, title: 'Calibração Vencida', message: `O instrumento ${inst.name} (${inst.serial_number}) está vencido.`, type: 'error', metadata });
|
||||||
|
}
|
||||||
// 1. Vencido
|
await query('UPDATE instruments SET status = $1 WHERE id = $2', ['expired', instId]);
|
||||||
if (isBefore(expirationDate, now)) {
|
} else if (isBefore(expDate, oneMonthFromNow)) {
|
||||||
title = 'Calibração Vencida';
|
const metadata = { instrumentId: instId, triggerType: 'calibration_1_month' };
|
||||||
message = `O instrumento ${instrument.name} (${instrument.serialNumber}) está com a calibração vencida desde ${expirationDate.toLocaleDateString()}.`;
|
if (!(await this.isAlreadyNotified(orgId, metadata))) {
|
||||||
type = 'error';
|
await this.create({ organizationId: orgId, title: 'Calibração vence em 1 mês', message: `A calibração do instrumento ${inst.name} vence em breve.`, type: 'warning', metadata });
|
||||||
triggerType = 'calibration_expired';
|
|
||||||
|
|
||||||
// Atualizar status para expired se não estiver
|
|
||||||
if (instrument.status !== 'expired') {
|
|
||||||
instrument.status = 'expired';
|
|
||||||
await instrument.save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
// 2. Vence em 1 mês
|
|
||||||
else if (isBefore(expirationDate, oneMonthFromNow)) {
|
|
||||||
title = 'Calibração vence em 1 mês';
|
|
||||||
message = `A calibração do instrumento ${instrument.name} (${instrument.serialNumber}) vence em ${expirationDate.toLocaleDateString()}.`;
|
|
||||||
type = 'warning';
|
|
||||||
triggerType = 'calibration_1_month';
|
|
||||||
}
|
|
||||||
// 3. Vence em 2 meses
|
|
||||||
else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
|
||||||
title = 'Calibração vence em 2 meses';
|
|
||||||
message = `A calibração do instrumento ${instrument.name} (${instrument.serialNumber}) vence em ${expirationDate.toLocaleDateString()}.`;
|
|
||||||
type = 'info';
|
|
||||||
triggerType = 'calibration_2_months';
|
|
||||||
} else {
|
|
||||||
continue; // Não precisa notificar
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evitar spam
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
instrumentId,
|
|
||||||
triggerType
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type,
|
|
||||||
metadata: { instrumentId, triggerType }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -349,44 +209,30 @@ export const notificationService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verificar se o estoque está abaixo do mínimo (Aggregated by Product + Color)
|
|
||||||
async checkLowStock(stockItemId: string) {
|
async checkLowStock(stockItemId: string) {
|
||||||
try {
|
try {
|
||||||
const item = await StockItem.findById(stockItemId).populate('dataSheetId', 'name manufacturer');
|
const itemResult = await query(
|
||||||
if (!item || !item.minStock || item.minStock <= 0) return;
|
`SELECT si.*, tds.name as data_sheet_name
|
||||||
|
FROM stock_items si
|
||||||
|
LEFT JOIN technical_data_sheets tds ON si.data_sheet_id = tds.id
|
||||||
|
WHERE si.id = $1`, [stockItemId]
|
||||||
|
);
|
||||||
|
const item = itemResult?.rows[0];
|
||||||
|
if (!item || !item.min_stock || item.min_stock <= 0) return;
|
||||||
|
|
||||||
const orgId = item.organizationId;
|
const orgId = item.organization_id;
|
||||||
if (!orgId) return;
|
const siblingsResult = await query('SELECT quantity FROM stock_items WHERE organization_id = $1 AND data_sheet_id = $2 AND color = $3', [orgId, item.data_sheet_id, item.color]);
|
||||||
|
const totalQuantity = (siblingsResult?.rows || []).reduce((sum, s) => sum + Number(s.quantity), 0);
|
||||||
|
|
||||||
// Aggregate total quantity for this Product + Color
|
if (totalQuantity < item.min_stock) {
|
||||||
const siblings = await StockItem.find({
|
const metadata = { product_color_key: `${item.data_sheet_id}-${item.color}`, triggerType: 'low_stock_aggregated' };
|
||||||
organizationId: orgId,
|
if (!(await this.isAlreadyNotified(orgId, metadata, 3))) {
|
||||||
dataSheetId: item.dataSheetId,
|
|
||||||
color: item.color
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalQuantity = siblings.reduce((sum, s) => sum + s.quantity, 0);
|
|
||||||
|
|
||||||
if (totalQuantity < item.minStock) {
|
|
||||||
// Check throttling
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
stockItemId: stockItemId, // Keep using specific item ID as reference or maybe composite key?
|
|
||||||
// Let's use a composite key for the trigger to avoid spamming for every batch in the group
|
|
||||||
productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`,
|
|
||||||
triggerType: 'low_stock_aggregated'
|
|
||||||
}, 3);
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
await this.create({
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
title: 'Estoque Baixo (Total)',
|
title: 'Estoque Baixo (Total)',
|
||||||
message: `O produto ${item.dataSheetId?.name} (Cor: ${item.color || 'N/A'}) atingiu o nível crítico. Total: ${totalQuantity.toFixed(1)}${item.unit}. (Mínimo: ${item.minStock}${item.unit})`,
|
message: `O produto ${item.data_sheet_name} atingiu nível crítico (${totalQuantity} ${item.unit}). Mínimo: ${item.min_stock}.`,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
metadata: {
|
metadata
|
||||||
stockItemId,
|
|
||||||
productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`,
|
|
||||||
triggerType: 'low_stock_aggregated'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,3 +241,4 @@ export const notificationService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,86 @@
|
|||||||
import PaintingScheme from '../models/PaintingScheme.js';
|
import { query } from '../config/database.js';
|
||||||
|
import { snakeToCamel } from '../utils/mapper.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const createPaintingScheme = async (data: any & { organizationId?: string }) => {
|
export const createPaintingScheme = async (data: any & { organizationId?: string }) => {
|
||||||
const newScheme = new PaintingScheme({ ...data, organizationId: data.organizationId });
|
const columns: string[] = [];
|
||||||
const saved = await newScheme.save();
|
const values: any[] = [];
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
let i = 1;
|
||||||
|
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
columns.push(sqlKey);
|
||||||
|
values.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO painting_schemes (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPaintingSchemesByProject = async (projectId: string, organizationId?: string) => {
|
export const getPaintingSchemesByProject = async (projectId: string, organizationId?: string) => {
|
||||||
const query = { projectId, ...(organizationId ? { organizationId } : {}) };
|
let whereClause = 'WHERE project_id = $1';
|
||||||
const schemes = await PaintingScheme.find(query).lean();
|
const params: any[] = [projectId];
|
||||||
return schemes.map(s => ({ ...s, id: s._id.toString() }));
|
if (organizationId) {
|
||||||
|
whereClause += ' AND organization_id = $2';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
const result = await query(`SELECT * FROM painting_schemes ${whereClause} ORDER BY name ASC`, params);
|
||||||
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const updatePaintingScheme = async (id: string, data: any, organizationId?: string) => {
|
export const updatePaintingScheme = async (id: string, data: any, organizationId?: string) => {
|
||||||
// SECURITY FIX: Allow update if:
|
const check = await query('SELECT organization_id FROM painting_schemes WHERE id = $1', [id]);
|
||||||
// 1. Matches ID AND Matches Organization
|
const existing = check?.rows[0];
|
||||||
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
|
|
||||||
|
|
||||||
let query: any = { _id: id };
|
|
||||||
|
|
||||||
// First, check if the record exists and what is its state
|
|
||||||
const existing = await PaintingScheme.findById(id);
|
|
||||||
|
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
// Check ownership
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
return null;
|
||||||
// Exists but belongs to ANOTHER organization -> Deny
|
|
||||||
console.warn(`Access Denied: Scheme ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return null; // Return null effectively hides it or acts as fail
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we passed the check, we perform the update.
|
const fields: string[] = [];
|
||||||
// Ensure we "adopt" the record if it didn't have an orgId
|
const params: any[] = [];
|
||||||
if (organizationId && !data.organizationId) {
|
let i = 1;
|
||||||
data.organizationId = organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await PaintingScheme.findOneAndUpdate({ _id: id }, data, { new: true }).lean();
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
if (updated) {
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
return { ...updated, id: updated._id.toString() };
|
fields.push(`${sqlKey} = $${i++}`);
|
||||||
}
|
params.push(value);
|
||||||
return null;
|
});
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE painting_schemes SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deletePaintingScheme = async (id: string, organizationId?: string) => {
|
export const deletePaintingScheme = async (id: string, organizationId?: string) => {
|
||||||
// Find first to check permissions
|
const check = await query('SELECT organization_id FROM painting_schemes WHERE id = $1', [id]);
|
||||||
const existing = await PaintingScheme.findById(id);
|
const existing = check?.rows[0];
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
// Permissions:
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
// If user has org, and item has OTHER org, deny.
|
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
console.warn(`[Delete PaintingScheme] Access Denied. User Org: ${organizationId}, Scheme Org: ${existing.organizationId}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await PaintingScheme.findByIdAndDelete(id);
|
await query('DELETE FROM painting_schemes WHERE id = $1', [id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllSchemes = async (organizationId?: string) => {
|
export const getAllSchemes = async (organizationId?: string) => {
|
||||||
const query = organizationId ? { organizationId } : {};
|
let whereClause = '';
|
||||||
const schemes = await PaintingScheme.find(query).lean();
|
const params: any[] = [];
|
||||||
return schemes.map(s => ({ ...s, id: s._id.toString() }));
|
if (organizationId) {
|
||||||
|
whereClause = 'WHERE organization_id = $1';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
const result = await query(`SELECT * FROM painting_schemes ${whereClause} ORDER BY name ASC`, params);
|
||||||
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,98 @@
|
|||||||
import Part from '../models/Part.js';
|
import { query } from '../config/database.js';
|
||||||
|
import { snakeToCamel } from '../utils/mapper.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const createPart = async (data: any & { organizationId?: string }) => {
|
export const createPart = async (data: any & { organizationId?: string }) => {
|
||||||
const newPart = new Part({ ...data, organizationId: data.organizationId });
|
const columns: string[] = [];
|
||||||
const saved = await newPart.save();
|
const values: any[] = [];
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
let i = 1;
|
||||||
|
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
columns.push(sqlKey);
|
||||||
|
values.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO parts (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPartsByProject = async (projectId: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const getPartsByProject = async (projectId: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const query = isGlobalAdmin
|
let whereClause = 'WHERE project_id = $1';
|
||||||
? { projectId }
|
const params: any[] = [projectId];
|
||||||
: { projectId, $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
|
|
||||||
const parts = await Part.find(query).lean();
|
if (!isGlobalAdmin) {
|
||||||
return parts.map(p => ({ ...p, id: p._id.toString() }));
|
if (organizationId) {
|
||||||
|
whereClause += ' AND (organization_id = $2 OR organization_id IS NULL)';
|
||||||
|
params.push(organizationId);
|
||||||
|
} else {
|
||||||
|
whereClause += ' AND organization_id IS NULL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(`SELECT * FROM parts ${whereClause} ORDER BY created_at DESC`, params);
|
||||||
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const updatePart = async (id: string, data: any, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const updatePart = async (id: string, data: any, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const existing = await Part.findById(id);
|
const check = await query('SELECT organization_id FROM parts WHERE id = $1', [id]);
|
||||||
|
const existing = check?.rows[0];
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
if (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
console.warn(`Access Denied: Part ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
const fields: string[] = [];
|
||||||
data.organizationId = organizationId; // Adopt
|
const params: any[] = [];
|
||||||
}
|
let i = 1;
|
||||||
|
|
||||||
const updated = await Part.findOneAndUpdate({ _id: id }, data, { new: true }).lean();
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
if (updated) {
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
return { ...updated, id: updated._id.toString() };
|
fields.push(`${sqlKey} = $${i++}`);
|
||||||
}
|
params.push(value);
|
||||||
return null;
|
});
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE parts SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deletePart = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const deletePart = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const part = await Part.findById(id);
|
const check = await query('SELECT organization_id FROM parts WHERE id = $1', [id]);
|
||||||
|
const part = check?.rows[0];
|
||||||
if (!part) return;
|
if (!part) return;
|
||||||
|
|
||||||
if (!isGlobalAdmin && organizationId && part.organizationId && part.organizationId !== organizationId) {
|
if (!isGlobalAdmin && organizationId && part.organization_id && part.organization_id !== organizationId) {
|
||||||
throw new Error('Sem permissão para excluir esta peça');
|
throw new Error('Sem permissão para excluir esta peça');
|
||||||
}
|
}
|
||||||
|
|
||||||
await Part.findByIdAndDelete(id);
|
await query('DELETE FROM parts WHERE id = $1', [id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllParts = async (organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const getAllParts = async (organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const query = isGlobalAdmin
|
let whereClause = '';
|
||||||
? {}
|
const params: any[] = [];
|
||||||
: { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
|
|
||||||
const parts = await Part.find(query).lean();
|
if (!isGlobalAdmin) {
|
||||||
return parts.map(p => ({ ...p, id: p._id.toString() }));
|
if (organizationId) {
|
||||||
|
whereClause = 'WHERE (organization_id = $1 OR organization_id IS NULL)';
|
||||||
|
params.push(organizationId);
|
||||||
|
} else {
|
||||||
|
whereClause = 'WHERE organization_id IS NULL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(`SELECT * FROM parts ${whereClause} ORDER BY created_at DESC`, params);
|
||||||
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import Project from '../models/Project.js';
|
import { query } from '../config/database.js';
|
||||||
import Part from '../models/Part.js';
|
import { snakeToCamel } from '../utils/mapper.js';
|
||||||
import PaintingScheme from '../models/PaintingScheme.js';
|
|
||||||
import ApplicationRecord from '../models/ApplicationRecord.js';
|
|
||||||
import Inspection from '../models/Inspection.js';
|
|
||||||
|
|
||||||
interface ProjectData {
|
interface ProjectData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,207 +12,150 @@ interface ProjectData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createProject = async (data: ProjectData & { organizationId?: string }) => {
|
export const createProject = async (data: ProjectData & { organizationId?: string }) => {
|
||||||
const newProject = new Project({
|
const result = await query(
|
||||||
name: data.name,
|
`INSERT INTO projects (name, client, organization_id, start_date, end_date, technician, environment, weight_kg, status)
|
||||||
client: data.client,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active')
|
||||||
startDate: data.startDate ? new Date(data.startDate) : null,
|
RETURNING *`,
|
||||||
endDate: data.endDate ? new Date(data.endDate) : null,
|
[data.name, data.client, data.organizationId, data.startDate, data.endDate, data.technician, data.environment, data.weightKg]
|
||||||
technician: data.technician,
|
);
|
||||||
environment: data.environment,
|
return snakeToCamel(result?.rows[0]);
|
||||||
organizationId: data.organizationId,
|
|
||||||
weightKg: data.weightKg
|
|
||||||
});
|
|
||||||
return await newProject.save();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDashboardProjects = async (organizationId?: string) => {
|
export const getDashboardProjects = async (organizationId?: string) => {
|
||||||
const matchStage = organizationId ? { organizationId } : {};
|
const params: any[] = [];
|
||||||
|
let whereClause = '';
|
||||||
|
if (organizationId) {
|
||||||
|
whereClause = 'WHERE organization_id = $1';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
const projects = await Project.aggregate([
|
const result = await query(
|
||||||
{ $match: matchStage },
|
`SELECT p.*,
|
||||||
{ $sort: { name: 1 } },
|
(SELECT json_agg(s) FROM painting_schemes s WHERE s.project_id = p.id) as schemes,
|
||||||
{
|
(SELECT COALESCE(SUM(weight_kg), 0) FROM inspections i WHERE i.project_id = p.id) as painted_weight
|
||||||
$lookup: {
|
FROM projects p
|
||||||
from: 'paintingschemes',
|
${whereClause}
|
||||||
localField: '_id',
|
ORDER BY p.name ASC`,
|
||||||
foreignField: 'projectId',
|
params
|
||||||
as: 'paintingSchemes'
|
);
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$lookup: {
|
|
||||||
from: 'inspections',
|
|
||||||
localField: '_id',
|
|
||||||
foreignField: 'projectId',
|
|
||||||
as: 'inspections'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$project: {
|
|
||||||
_id: 1,
|
|
||||||
name: 1,
|
|
||||||
client: 1,
|
|
||||||
technician: 1,
|
|
||||||
weightKg: 1,
|
|
||||||
createdAt: 1,
|
|
||||||
schemes: {
|
|
||||||
$map: {
|
|
||||||
input: "$paintingSchemes",
|
|
||||||
as: "scheme",
|
|
||||||
in: {
|
|
||||||
id: { $toString: "$$scheme._id" },
|
|
||||||
name: "$$scheme.name",
|
|
||||||
type: "$$scheme.type",
|
|
||||||
coat: "$$scheme.coat",
|
|
||||||
color: "$$scheme.color",
|
|
||||||
colorHex: "$$scheme.colorHex",
|
|
||||||
thinnerSymbol: "$$scheme.thinnerSymbol",
|
|
||||||
epsMin: "$$scheme.epsMin",
|
|
||||||
epsMax: "$$scheme.epsMax"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
paintedWeight: { $sum: "$inspections.weightKg" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
return projects.map(p => ({ ...p, id: p._id.toString() }));
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllProjects = async (organizationId?: string, isGlobalAdmin: boolean = false, status: string = 'active') => {
|
export const getAllProjects = async (organizationId?: string, isGlobalAdmin: boolean = false, status: string = 'active') => {
|
||||||
const statusQuery = status === 'active'
|
const filters: string[] = [];
|
||||||
? { status: { $ne: 'archived' } }
|
const params: any[] = [];
|
||||||
: { status: 'archived' };
|
|
||||||
|
|
||||||
const matchQuery: Record<string, unknown> = isGlobalAdmin
|
if (status === 'archived') {
|
||||||
? { ...statusQuery }
|
filters.push('status = \'archived\'');
|
||||||
: {
|
} else {
|
||||||
...statusQuery,
|
filters.push('status != \'archived\'');
|
||||||
$or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }]
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const projects = await Project.aggregate([
|
if (!isGlobalAdmin) {
|
||||||
{ $match: matchQuery },
|
if (organizationId) {
|
||||||
{ $sort: { name: 1 } },
|
filters.push('(organization_id = $' + (params.length + 1) + ' OR organization_id IS NULL)');
|
||||||
{
|
params.push(organizationId);
|
||||||
$lookup: {
|
} else {
|
||||||
from: 'paintingschemes',
|
filters.push('organization_id IS NULL');
|
||||||
localField: '_id',
|
|
||||||
foreignField: 'projectId',
|
|
||||||
as: 'paintingSchemes'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$addFields: {
|
|
||||||
id: { $toString: "$_id" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]);
|
}
|
||||||
|
|
||||||
return projects;
|
const whereClause = filters.length > 0 ? 'WHERE ' + filters.join(' AND ') : '';
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT p.*, (SELECT json_agg(s) FROM painting_schemes s WHERE s.project_id = p.id) as painting_schemes
|
||||||
|
FROM projects p
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.name ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const archiveProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const archiveProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const project = await Project.findById(id);
|
const check = await query('SELECT organization_id, status FROM projects WHERE id = $1', [id]);
|
||||||
|
const project = check?.rows[0];
|
||||||
if (!project) throw new Error('Projeto não encontrado');
|
if (!project) throw new Error('Projeto não encontrado');
|
||||||
|
|
||||||
// Check ownership
|
if (!isGlobalAdmin && organizationId && project.organization_id && project.organization_id !== organizationId) {
|
||||||
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
|
|
||||||
throw new Error('Sem permissão para arquivar este projeto');
|
throw new Error('Sem permissão para arquivar este projeto');
|
||||||
}
|
}
|
||||||
|
|
||||||
const newStatus = project.status === 'active' ? 'archived' : 'active';
|
const newStatus = project.status === 'active' ? 'archived' : 'active';
|
||||||
const updated = await Project.findByIdAndUpdate(id, { status: newStatus }, { new: true }).lean();
|
const result = await query(
|
||||||
return updated;
|
'UPDATE projects SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
|
||||||
|
[newStatus, id]
|
||||||
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProjectById = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const getProjectById = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const project = await Project.findById(id).lean();
|
const result = await query('SELECT * FROM projects WHERE id = $1', [id]);
|
||||||
|
const project = result?.rows[0];
|
||||||
|
|
||||||
if (!project) throw new Error('Projeto não encontrado');
|
if (!project) throw new Error('Projeto não encontrado');
|
||||||
|
|
||||||
// Security check: Allow if global admin OR matches organization OR project has no organization
|
if (!isGlobalAdmin && organizationId && project.organization_id && project.organization_id !== organizationId) {
|
||||||
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
|
|
||||||
throw new Error('Acesso negado a este projeto');
|
throw new Error('Acesso negado a este projeto');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [parts, schemes, records, inspections] = await Promise.all([
|
const [parts, schemes, inspections] = await Promise.all([
|
||||||
Part.find({ projectId: id }).lean(),
|
query('SELECT * FROM parts WHERE project_id = $1', [id]),
|
||||||
PaintingScheme.find({ projectId: id }).populate('paintId thinnerId').lean(),
|
query('SELECT * FROM painting_schemes WHERE project_id = $1', [id]),
|
||||||
ApplicationRecord.find({ projectId: id }).lean(),
|
query('SELECT * FROM inspections WHERE project_id = $1', [id])
|
||||||
Inspection.find({ projectId: id })
|
|
||||||
.populate({
|
|
||||||
path: 'stockItemId',
|
|
||||||
select: 'batchNumber dataSheetId',
|
|
||||||
populate: { path: 'dataSheetId', select: 'name' }
|
|
||||||
})
|
|
||||||
.lean()
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return snakeToCamel({
|
||||||
...project,
|
...project,
|
||||||
id: project._id.toString(),
|
parts: parts?.rows || [],
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
paintingSchemes: schemes?.rows || [],
|
||||||
parts: parts.map((p: any) => ({ ...p, id: p._id.toString() })),
|
inspections: inspections?.rows || []
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
});
|
||||||
paintingSchemes: schemes.map((s: any) => ({ ...s, id: s._id.toString() })),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
applicationRecords: records.map((r: any) => ({ ...r, id: r._id.toString() })),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
inspections: inspections.map((i: any) => ({ ...i, id: i._id.toString() }))
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProject = async (id: string, data: Partial<ProjectData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const updateProject = async (id: string, data: Partial<ProjectData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const existing = await Project.findById(id);
|
const check = await query('SELECT organization_id FROM projects WHERE id = $1', [id]);
|
||||||
|
const existing = check?.rows[0];
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
// Check ownership
|
if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
console.warn(`Access Denied: Project ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Partial<ProjectData> & { updatedAt: Date, organizationId?: string } = {
|
const fields: string[] = [];
|
||||||
...data,
|
const params: any[] = [];
|
||||||
updatedAt: new Date(),
|
let i = 1;
|
||||||
startDate: data.startDate ? new Date(data.startDate) : undefined,
|
|
||||||
endDate: data.endDate ? new Date(data.endDate) : undefined,
|
|
||||||
weightKg: data.weightKg,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Adopt if needed
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
if (organizationId && !existing.organizationId) {
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
updateData.organizationId = organizationId;
|
fields.push(`${sqlKey} = $${i++}`);
|
||||||
}
|
params.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
const updated = await Project.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
|
params.push(id);
|
||||||
if (updated) {
|
const result = await query(
|
||||||
return { ...updated, id: updated._id.toString() };
|
`UPDATE projects SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`,
|
||||||
}
|
params
|
||||||
return null;
|
);
|
||||||
|
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const deleteProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const project = await Project.findById(id);
|
const check = await query('SELECT organization_id FROM projects WHERE id = $1', [id]);
|
||||||
|
const project = check?.rows[0];
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
|
|
||||||
// Check ownership
|
if (!isGlobalAdmin && organizationId && project.organization_id && project.organization_id !== organizationId) {
|
||||||
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
|
|
||||||
throw new Error('Sem permissão para excluir este projeto');
|
throw new Error('Sem permissão para excluir este projeto');
|
||||||
}
|
}
|
||||||
|
|
||||||
await Project.findByIdAndDelete(id);
|
await query('DELETE FROM projects WHERE id = $1', [id]);
|
||||||
|
|
||||||
// Also cleanup related data
|
|
||||||
await Promise.all([
|
|
||||||
Part.deleteMany({ projectId: id }),
|
|
||||||
PaintingScheme.deleteMany({ projectId: id }),
|
|
||||||
ApplicationRecord.deleteMany({ projectId: id }),
|
|
||||||
Inspection.deleteMany({ projectId: id })
|
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
101
src/server/services/stockService.ts
Normal file
101
src/server/services/stockService.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { query } from '../config/database.js';
|
||||||
|
import { snakeToCamel } from '../utils/mapper.js';
|
||||||
|
|
||||||
|
export const createStockItem = async (data: any & { organizationId?: string, createdBy?: string }) => {
|
||||||
|
const columns: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
columns.push(sqlKey);
|
||||||
|
values.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO stock_items (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockItems = async (organizationId?: string) => {
|
||||||
|
let whereClause = '';
|
||||||
|
const params: any[] = [];
|
||||||
|
if (organizationId) {
|
||||||
|
whereClause = 'WHERE (organization_id = $1 OR organization_id IS NULL)';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
const result = await query(`SELECT si.*, tds.name as data_sheet_name
|
||||||
|
FROM stock_items si
|
||||||
|
LEFT JOIN technical_data_sheets tds ON si.data_sheet_id = tds.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY si.entry_date DESC`, params);
|
||||||
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateStockItem = async (id: string, data: any, organizationId?: string) => {
|
||||||
|
const check = await query('SELECT organization_id FROM stock_items WHERE id = $1', [id]);
|
||||||
|
const existing = check?.rows[0];
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${sqlKey} = $${i++}`);
|
||||||
|
params.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE stock_items SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteStockItem = async (id: string, organizationId?: string) => {
|
||||||
|
const check = await query('SELECT organization_id FROM stock_items WHERE id = $1', [id]);
|
||||||
|
const existing = check?.rows[0];
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await query('DELETE FROM stock_items WHERE id = $1', [id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createStockMovement = async (data: any & { organizationId?: string, userId?: string }) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO stock_movements (organization_id, stock_item_id, user_id, type, quantity, reason, date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING *`,
|
||||||
|
[data.organizationId, data.stockItemId, data.userId, data.type, data.quantity, data.reason]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update stock item quantity
|
||||||
|
const multiplier = data.type === 'entry' ? 1 : -1;
|
||||||
|
await query('UPDATE stock_items SET quantity = quantity + $1 WHERE id = $2', [data.quantity * multiplier, data.stockItemId]);
|
||||||
|
|
||||||
|
return snakeToCamel(result?.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMovementsByItem = async (stockItemId: string, organizationId?: string) => {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT sm.*, u.name as user_name
|
||||||
|
FROM stock_movements sm
|
||||||
|
LEFT JOIN users u ON sm.user_id = u.id
|
||||||
|
WHERE sm.stock_item_id = $1 ${organizationId ? 'AND sm.organization_id = $2' : ''}
|
||||||
|
ORDER BY sm.date DESC`,
|
||||||
|
organizationId ? [stockItemId, organizationId] : [stockItemId]
|
||||||
|
);
|
||||||
|
return (result?.rows || []).map(snakeToCamel);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,56 +1,115 @@
|
|||||||
import YieldStudy from '../models/YieldStudy.js';
|
import { query } from '../config/database.js';
|
||||||
|
import { snakeToCamel } from '../utils/mapper.js';
|
||||||
|
|
||||||
export const getAllStudies = async (organizationId?: string) => {
|
export const getAllStudies = async (organizationId?: string) => {
|
||||||
const query = organizationId ? { organizationId } : {};
|
let whereClause = '';
|
||||||
const studies = await YieldStudy.find(query).populate('dataSheetId').sort({ createdAt: -1 }).lean();
|
const params: any[] = [];
|
||||||
return studies.map(s => ({ ...s, id: s._id.toString() }));
|
if (organizationId) {
|
||||||
|
whereClause = 'WHERE organization_id = $1';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
const result = await query(`SELECT ys.*, tds.name as data_sheet_name
|
||||||
|
FROM yield_studies ys
|
||||||
|
LEFT JOIN technical_data_sheets tds ON ys.data_sheet_id = tds.id
|
||||||
|
${whereClause} ORDER BY ys.created_at DESC`, params);
|
||||||
|
|
||||||
|
const studies = result?.rows || [];
|
||||||
|
for (const study of studies) {
|
||||||
|
const catResult = await query('SELECT * FROM yield_study_categories WHERE yield_study_id = $1', [study.id]);
|
||||||
|
study.categories = catResult?.rows || [];
|
||||||
|
}
|
||||||
|
return studies.map(snakeToCamel);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const createStudy = async (data: any & { organizationId?: string }) => {
|
export const createStudy = async (data: any & { organizationId?: string }) => {
|
||||||
const newStudy = new YieldStudy({ ...data, organizationId: data.organizationId });
|
const categories = data.categories || [];
|
||||||
const saved = await newStudy.save();
|
delete data.categories;
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
|
||||||
|
const columns: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
columns.push(sqlKey);
|
||||||
|
values.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO yield_studies (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
const study = result?.rows[0];
|
||||||
|
|
||||||
|
if (study && categories.length > 0) {
|
||||||
|
for (const cat of categories) {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO yield_study_categories (yield_study_id, name, weight, area, historical_yield, historical_dft, efficiency) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
|
[study.id, cat.name, cat.weight, cat.area, cat.historicalYield, cat.historicalDft, cat.efficiency]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
study.categories = categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snakeToCamel(study);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const updateStudy = async (id: string, updates: any, organizationId?: string) => {
|
export const updateStudy = async (id: string, updates: any, organizationId?: string) => {
|
||||||
// SECURITY FIX: Allow update if:
|
const check = await query('SELECT organization_id FROM yield_studies WHERE id = $1', [id]);
|
||||||
// 1. Matches ID AND Matches Organization
|
const existing = check?.rows[0];
|
||||||
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
|
|
||||||
|
|
||||||
const existing = await YieldStudy.findById(id);
|
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
console.warn(`Access Denied: Study ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
const categories = updates.categories;
|
||||||
updates.organizationId = organizationId;
|
delete updates.categories;
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${sqlKey} = $${i++}`);
|
||||||
|
params.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE yield_studies SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const study = result?.rows[0];
|
||||||
|
|
||||||
|
if (study && categories) {
|
||||||
|
await query('DELETE FROM yield_study_categories WHERE yield_study_id = $1', [id]);
|
||||||
|
for (const cat of categories) {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO yield_study_categories (yield_study_id, name, weight, area, historical_yield, historical_dft, efficiency) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
|
[study.id, cat.name, cat.weight, cat.area, cat.historicalYield, cat.historicalDft, cat.efficiency]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
study.categories = categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await YieldStudy.findOneAndUpdate({ _id: id }, updates, { new: true }).lean();
|
return snakeToCamel(study);
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteStudy = async (id: string, organizationId?: string) => {
|
export const deleteStudy = async (id: string, organizationId?: string) => {
|
||||||
// SECURITY FIX: Same logic as update - allow delete if owned OR if orphan
|
const check = await query('SELECT organization_id FROM yield_studies WHERE id = $1', [id]);
|
||||||
const existing = await YieldStudy.findById(id);
|
const existing = check?.rows[0];
|
||||||
if (!existing) return false;
|
if (!existing) return false;
|
||||||
|
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
console.warn(`Access Denied: Delete Study ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await YieldStudy.findByIdAndDelete(id);
|
await query('DELETE FROM yield_studies WHERE id = $1', [id]);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
34
src/server/utils/mapper.ts
Normal file
34
src/server/utils/mapper.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Utility to convert snake_case object keys to camelCase.
|
||||||
|
* Handles single objects and arrays or nested structures.
|
||||||
|
*/
|
||||||
|
export const snakeToCamel = (obj: any): any => {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(v => snakeToCamel(v));
|
||||||
|
} else if (obj !== null && obj.constructor === Object) {
|
||||||
|
return Object.keys(obj).reduce(
|
||||||
|
(result, key) => ({
|
||||||
|
...result,
|
||||||
|
[key.replace(/(_\w)/g, m => m[1].toUpperCase())]: snakeToCamel(obj[key]),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to convert camelCase object keys to snake_case.
|
||||||
|
*/
|
||||||
|
export const camelToSnake = (obj: any): any => {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(v => camelToSnake(v));
|
||||||
|
} else if (obj !== null && obj.constructor === Object) {
|
||||||
|
return Object.keys(obj).reduce((acc, key) => {
|
||||||
|
const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
acc[snakeKey] = camelToSnake(obj[key]);
|
||||||
|
return acc;
|
||||||
|
}, {} as any);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user