feat: Migrate database from MongoDB to PostgreSQL, updating all services and introducing a new schema.

This commit is contained in:
2026-03-19 21:49:42 +00:00
parent 778d6d18ee
commit 0b81094050
21 changed files with 1757 additions and 1452 deletions

295
package-lock.json generated
View File

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

View File

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

View File

@@ -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) {
bucket = new GridFSBucket(mongoose.connection.db, { bucketName: 'pdfs' });
console.log('✅ GridFS Bucket re-initialized');
}
return;
}
console.log('Connecting to MongoDB...'); pool = new Pool({
if (!uri) console.error('MONGODB_URI is undefined!'); connectionString,
ssl: connectionString.includes('localhost') ? false : { rejectUnauthorized: false },
await mongoose.connect(uri, { max: 20,
maxPoolSize: 10, idleTimeoutMillis: 30000,
serverSelectionTimeoutMS: 5000, connectionTimeoutMillis: 2000,
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);

View File

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

View File

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

View File

@@ -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())
if (user) { ON CONFLICT (logto_id)
user.email = email; DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name, updated_at = NOW()
user.name = name; RETURNING id, logto_id, email, name, role, is_banned`,
await user.save(); [logtoId, email, name, 'guest', false]
} else {
user = await User.create({
externalId,
email,
name,
role: 'guest', // Default global role
isBanned: false
});
}
if (organizationId) {
// Map Auth role to our app role
let appRole: OrgRole = 'guest';
if (incomingRole === 'org:admin') {
appRole = 'admin';
} else if (incomingRole === 'org:member') {
appRole = 'user';
}
// 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 user = userResult?.rows[0];
return res.json({
...user.toObject(), if (organizationId) {
organizationRole: member.role, let appRole = 'guest';
organizationBanned: member.isBanned if (incomingRole === 'org:admin') appRole = 'admin';
}); else if (incomingRole === 'org:member') appRole = 'user';
const memberResult = await query(
`INSERT INTO user_organizations (user_id, organization_id, role, is_banned, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (user_id, organization_id)
DO UPDATE SET updated_at = NOW()
RETURNING role, is_banned`,
[user.id, organizationId, appRole, false]
);
const member = memberResult?.rows[0];
return res.json(snakeToCamel({
...user,
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.' });
} }
}; };

View File

@@ -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(); notificationService.checkStockExpirations();
}, 24 * 60 * 60 * 1000); }, 24 * 60 * 60 * 1000);
// Executar uma vez no início para garantir (opcional, bom para dev) // Execute once at start
notificationService.checkStockExpirations(); notificationService.checkStockExpirations();
} else {
console.warn('⚠️ MongoDB is not connected. Skipping migrations.');
}
}); });
} catch (error) { } catch (error) {
console.error('Failed to start server:', error); console.error('Failed to start server:', error);

View File

@@ -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;
// Injeta o externalId no header para que o extractUser (roleMiddleware) // Verify token with Logto JWKS
// continue seu trabalho de carregar o usuário do banco instanciado e popular req.appUser // Note: For production, you should also verify 'audience' if configured
req.headers['x-auth-user-id'] = decoded.externalId; const { payload } = await jwtVerify(token, JWKS, {
issuer: `${LOGTO_ENDPOINT}/oidc`,
});
// The 'sub' claim in Logto is the user's unique ID
req.headers['x-auth-user-id'] = payload.sub;
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' });
} }
}; };

View File

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

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

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

View File

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

View File

@@ -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}%`];
: {};
const filter = { if (organizationId) {
...orgFilter, whereClause += ' AND (organization_id = $2 OR organization_id IS NULL)';
$or: [ params.push(organizationId);
{ 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 effectively hides it or acts as fail
}
// If new file is uploaded (path exists locally)
if (updates.fileUrl && updates.fileUrl !== oldSheet.fileUrl && fs.existsSync(updates.fileUrl)) {
// Upload new file
const newFileId = await saveFileToGridFS(updates.fileUrl, (updates.name || oldSheet.name) + '.pdf');
// Delete old file from GridFS
if (oldSheet.fileUrl && !oldSheet.fileUrl.startsWith('http')) {
await deleteFileFromGridFS(oldSheet.fileUrl);
}
updates.fileUrl = newFileId;
}
if (organizationId && !oldSheet.organizationId) {
updates.organizationId = organizationId;
}
const updated = await TechnicalDataSheet.findOneAndUpdate({ _id: id }, updates, { new: true }).lean();
if (updated) {
return { ...updated, id: updated._id.toString() };
}
return null; return null;
}; }
export const migrateFilesToGridFS = async () => { if (updates.fileUrl && fs.existsSync(updates.fileUrl)) {
try { const newFileId = await saveFileToDB(updates.fileUrl, (updates.name || oldSheet.name) + '.pdf');
const sheets = await TechnicalDataSheet.find({ fileUrl: { $regex: /^uploads\// } }); if (oldSheet.file_id) {
console.log(`[MIGRATION] Found ${sheets.length} sheets to migrate to GridFS`); await deleteFileFromDB(oldSheet.file_id);
}
updates.file_id = newFileId;
delete updates.fileUrl;
}
for (const sheet of sheets) { const fields: string[] = [];
const localPath = path.join(process.cwd(), sheet.fileUrl); const params: any[] = [];
if (fs.existsSync(localPath)) { let i = 1;
try {
const gridFsId = await saveFileToGridFS(localPath, sheet.name + '.pdf'); Object.entries(updates).forEach(([key, value]) => {
sheet.fileUrl = gridFsId; const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
await sheet.save(); fields.push(`${sqlKey} = $${i++}`);
console.log(`[MIGRATION] Successfully migrated: ${sheet.name}`); params.push(value);
} catch (err) { });
console.error(`[MIGRATION] Error migrating ${sheet.name}:`, err);
} params.push(id);
} else { const result = await query(
console.warn(`[MIGRATION] File not found for ${sheet.name}: ${localPath}`); `UPDATE technical_data_sheets SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`,
} params
} );
} catch (error) { return result?.rows[0];
console.error('[MIGRATION] Migration failed:', error);
}
}; };

View File

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

View File

@@ -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 return true;
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();
} 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} vence 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
if (isBefore(expirationDate, now)) {
title = 'Calibração Vencida';
message = `O instrumento ${instrument.name} (${instrument.serialNumber}) está com a calibração vencida desde ${expirationDate.toLocaleDateString()}.`;
type = 'error';
triggerType = 'calibration_expired';
// Atualizar status para expired se não estiver
if (instrument.status !== 'expired') {
instrument.status = 'expired';
await instrument.save();
} }
await query('UPDATE instruments SET status = $1 WHERE id = $2', ['expired', instId]);
} else if (isBefore(expDate, oneMonthFromNow)) {
const metadata = { instrumentId: instId, triggerType: 'calibration_1_month' };
if (!(await this.isAlreadyNotified(orgId, metadata))) {
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 });
} }
// 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 = {
} }
} }
}; };

View File

@@ -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) {
// 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.
// Ensure we "adopt" the record if it didn't have an orgId
if (organizationId && !data.organizationId) {
data.organizationId = organizationId;
}
const updated = await PaintingScheme.findOneAndUpdate({ _id: id }, data, { new: true }).lean();
if (updated) {
return { ...updated, id: updated._id.toString() };
}
return null; 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 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);
}; };

View File

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

View File

@@ -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([
{ $match: matchQuery },
{ $sort: { name: 1 } },
{
$lookup: {
from: 'paintingschemes',
localField: '_id',
foreignField: 'projectId',
as: 'paintingSchemes'
} }
},
{ if (!isGlobalAdmin) {
$addFields: { if (organizationId) {
id: { $toString: "$_id" } filters.push('(organization_id = $' + (params.length + 1) + ' OR organization_id IS NULL)');
params.push(organizationId);
} else {
filters.push('organization_id IS NULL');
} }
} }
]);
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 })
]);
}; };

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

View File

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

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