diff --git a/package-lock.json b/package-lock.json index 4ffa0a3..a12e588 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "dependencies": { "@logto/node": "^3.1.9", "@logto/react": "^4.0.13", + "@supabase/supabase-js": "^2.99.3", "@tailwindcss/postcss": "^4.1.18", "@types/mongoose": "^5.11.96", + "@types/pg": "^8.18.0", "@types/uuid": "^10.0.0", "axios": "^1.13.2", "bcryptjs": "^3.0.3", @@ -22,10 +24,12 @@ "dotenv": "^17.2.3", "enhanced-resolve": "^5.18.4", "express": "^5.2.1", + "jose": "^6.2.2", "jsonwebtoken": "^9.0.3", "lucide-react": "^0.562.0", "multer": "^2.0.2", "pdf-parse": "^1.1.1", + "pg": "^8.20.0", "prop-types": "^15.8.1", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -2362,6 +2366,15 @@ "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": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@logto/js/-/js-6.1.1.tgz", @@ -2875,6 +2888,86 @@ "version": "0.3.0", "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": { "version": "2.2.3", "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", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", - "dev": true, "license": "MIT", "dependencies": { "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": { "version": "6.14.0", "dev": true, @@ -3440,6 +3549,15 @@ "@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": { "version": "8.53.1", "dev": true, @@ -5927,6 +6045,15 @@ "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": { "version": "0.7.2", "license": "MIT", @@ -6573,9 +6700,9 @@ } }, "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==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -7816,6 +7943,95 @@ "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": { "version": "1.1.1", "license": "ISC" @@ -7874,6 +8090,45 @@ "dev": true, "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": { "version": "1.2.1", "dev": true, @@ -8874,6 +9129,15 @@ "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": { "version": "2.0.2", "license": "MIT", @@ -9325,7 +9589,6 @@ }, "node_modules/tslib": { "version": "2.8.1", - "devOptional": true, "license": "0BSD" }, "node_modules/tsx": { @@ -9523,7 +9786,6 @@ }, "node_modules/undici-types": { "version": "7.16.0", - "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -10395,6 +10657,27 @@ "version": "1.0.2", "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": { "version": "0.1.0", "license": "MIT", diff --git a/package.json b/package.json index 8888c83..5cfda3e 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "dependencies": { "@logto/node": "^3.1.9", "@logto/react": "^4.0.13", + "@supabase/supabase-js": "^2.99.3", "@tailwindcss/postcss": "^4.1.18", "@types/mongoose": "^5.11.96", + "@types/pg": "^8.18.0", "@types/uuid": "^10.0.0", "axios": "^1.13.2", "bcryptjs": "^3.0.3", @@ -27,10 +29,12 @@ "dotenv": "^17.2.3", "enhanced-resolve": "^5.18.4", "express": "^5.2.1", + "jose": "^6.2.2", "jsonwebtoken": "^9.0.3", "lucide-react": "^0.562.0", "multer": "^2.0.2", "pdf-parse": "^1.1.1", + "pg": "^8.20.0", "prop-types": "^15.8.1", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/src/server/config/database.ts b/src/server/config/database.ts index 74a1f4a..0d548f8 100644 --- a/src/server/config/database.ts +++ b/src/server/config/database.ts @@ -1,46 +1,55 @@ -import mongoose from 'mongoose'; -import { GridFSBucket } from 'mongodb'; +import pg from 'pg'; +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 () => { + if (pool) return pool; + try { - const uri = process.env.MONGODB_URI; - if (!uri) { - throw new Error('MONGODB_URI is not defined in environment variables'); + const connectionString = process.env.POSTGRES_URL || process.env.DATABASE_URL; + + if (!connectionString) { + throw new Error('DATABASE_URL or POSTGRES_URL is not defined in environment variables'); } - if (mongoose.connection.readyState >= 1) { - 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...'); - if (!uri) console.error('MONGODB_URI is undefined!'); - - await mongoose.connect(uri, { - maxPoolSize: 10, - serverSelectionTimeoutMS: 5000, - socketTimeoutMS: 45000, + console.log('Connecting to Postgres (Supabase)...'); + + pool = new Pool({ + connectionString, + ssl: connectionString.includes('localhost') ? false : { rejectUnauthorized: false }, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, }); - console.log('✅ MongoDB connected successfully'); - const db = mongoose.connection.db; - if (!db) { - throw new Error('Database connection not established'); + // Test connection and set schema + const client = await pool.connect(); + 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) { - 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.'); - // 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); + diff --git a/src/server/controllers/instrumentController.ts b/src/server/controllers/instrumentController.ts index 7fd879b..295c42e 100644 --- a/src/server/controllers/instrumentController.ts +++ b/src/server/controllers/instrumentController.ts @@ -1,106 +1,121 @@ import { Request, Response } from 'express'; -import Instrument from '../models/Instrument.js'; -import { IAppUser } from '../middleware/roleMiddleware.js'; +import { query } from '../config/database.js'; 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) => { 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 existing = await Instrument.findOne({ organizationId, serialNumber }); - if (existing) { + const existing = await query('SELECT id FROM instruments WHERE organization_id = $1 AND serial_number = $2', [organizationId, serialNumber]); + if (existing?.rowCount && existing.rowCount > 0) { 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'; if (calibrationExpirationDate && new Date(calibrationExpirationDate) < new Date()) { status = 'expired'; } - const instrument = await Instrument.create({ - organizationId, - name, - type, - manufacturer, - modelName, - serialNumber, - calibrationDate, - calibrationExpirationDate, - certificateUrl, - status, - notes - }); + const result = await query( + `INSERT INTO instruments (organization_id, name, type, manufacturer, model_name, serial_number, calibration_date, calibration_expiration_date, certificate_url, status, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, + [organizationId, name, type, manufacturer, modelName, serialNumber, calibrationDate, calibrationExpirationDate, certificateUrl, status, notes] + ); - res.status(201).json(instrument); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + res.status(201).json(mapInstrument(result?.rows[0])); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; export const getInstruments = async (req: AuthRequest, res: Response) => { try { - const organizationId = req.appUser?.organizationId; + const organizationId = req.appUser?.organization_id; const { status } = req.query; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const query: any = { organizationId }; - if (status) query.status = status; + let sql = 'SELECT * FROM instruments WHERE organization_id = $1'; + const params: any[] = [organizationId]; - const instruments = await Instrument.find(query).sort({ name: 1 }); - res.json(instruments); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + if (status) { + sql += ' AND status = $2'; + params.push(status); + } + 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) => { try { - const { id } = req.params; - const organizationId = req.appUser?.organizationId; - - // Recalcular status se data de validade mudar + const id = req.params.id as string; + const organizationId = req.appUser?.organization_id; const updates = { ...req.body }; + if (updates.calibrationExpirationDate) { if (new Date(updates.calibrationExpirationDate) < new Date()) { 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'; } } - const instrument = await Instrument.findOneAndUpdate( - { _id: id, organizationId }, - updates, - { new: true } + 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); + 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.' }); - res.json(instrument); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + if (!result?.rows[0]) return res.status(404).json({ error: 'Instrumento não encontrado.' }); + res.json(mapInstrument(result.rows[0])); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; export const deleteInstrument = async (req: AuthRequest, res: Response) => { try { - const { id } = req.params; - const organizationId = req.appUser?.organizationId; + const id = req.params.id as string; + const organizationId = req.appUser?.organization_id; - const deleted = await Instrument.findOneAndDelete({ _id: id, organizationId }); - if (!deleted) return res.status(404).json({ error: 'Instrumento não encontrado.' }); + const result = await query('DELETE FROM instruments WHERE id = $1 AND organization_id = $2 RETURNING id', [id, organizationId]); + if (!result?.rows[0]) return res.status(404).json({ error: 'Instrumento não encontrado.' }); res.status(204).send(); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; + diff --git a/src/server/controllers/stockController.ts b/src/server/controllers/stockController.ts index 8c285eb..7cca743 100644 --- a/src/server/controllers/stockController.ts +++ b/src/server/controllers/stockController.ts @@ -1,17 +1,15 @@ import { Request, Response } from 'express'; -import StockItem from '../models/StockItem.js'; -import StockMovement from '../models/StockMovement.js'; - -import { IAppUser } from '../middleware/roleMiddleware.js'; +import { query } from '../config/database.js'; +import * as stockService from '../services/stockService.js'; import { notificationService } from '../services/notificationService.js'; interface AuthRequest extends Request { - appUser?: IAppUser; + appUser?: any; } export const createStockItem = async (req: AuthRequest, res: Response) => { try { - const organizationId = req.appUser?.organizationId; + const organizationId = req.appUser?.organization_id; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const { dataSheetId, @@ -27,47 +25,35 @@ export const createStockItem = async (req: AuthRequest, res: Response) => { minStock } = req.body; - // Validation if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) { return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' }); } - // Check for duplicate RR within Org - const existing = await StockItem.findOne({ organizationId, rrNumber }); - if (existing) { + const existingResult = await query('SELECT * FROM stock_items WHERE organization_id = $1 AND rr_number = $2', [organizationId, rrNumber]); + if (existingResult?.rowCount && existingResult.rowCount > 0) { return res.status(400).json({ error: `Já existe um item com o RR ${rrNumber}.` }); } - // --- Min Stock Inheritance Logic --- 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) { - const existingGroupItem = await StockItem.findOne({ - organizationId, - dataSheetId, - color - }).sort({ updatedAt: -1 }); // Get latest active config - - if (existingGroupItem && existingGroupItem.minStock > 0) { - finalMinStock = existingGroupItem.minStock; + const existingGroupResult = await query( + `SELECT min_stock FROM stock_items + WHERE organization_id = $1 AND data_sheet_id = $2 AND color = $3 + ORDER BY updated_at DESC LIMIT 1`, + [organizationId, dataSheetId, color] + ); + if (existingGroupResult && existingGroupResult.rows[0]?.min_stock > 0) { + finalMinStock = existingGroupResult.rows[0].min_stock; } } else { - // If user DID provide a minStock, update all existing items in that group to match? - // User requested: "a regra de estoque minimo definido no cadastro precisa estar clonado para novos cadastros" - // And "soma dessas 'mesmas' tintas sejam comparadas com o estoque minimo cadastrado a elas" - // 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 } } - ); - } + await query( + 'UPDATE stock_items SET min_stock = $1 WHERE organization_id = $2 AND data_sheet_id = $3 AND color = $4', + [finalMinStock, organizationId, dataSheetId, color] + ); } - const newItem = new StockItem({ + const savedItem = await stockService.createStockItem({ organizationId, - createdBy: req.appUser?.externalId, dataSheetId, rrNumber, batchNumber, @@ -78,425 +64,196 @@ export const createStockItem = async (req: AuthRequest, res: Response) => { notes, color, invoiceNumber, - receivedBy + receivedBy, + createdBy: req.appUser?.id }); - const savedItem = await newItem.save(); - - // Create Initial Movement (ENTRY) - await StockMovement.create({ + await stockService.createStockMovement({ organizationId, - createdBy: req.appUser?.externalId, - stockItemId: savedItem._id, - movementNumber: 1, - type: 'ENTRY', + stockItemId: savedItem.id, + userId: req.appUser?.id, + type: 'entry', quantity: Number(quantity), - responsible: userName, - notes: 'Abertura de Lote / Entrada Inicial' + reason: 'Abertura de Lote / Entrada Inicial' }); - // Notificação de Recebimento if (organizationId) { await notificationService.create({ organizationId, title: 'Recebimento de Material', - message: `Recebido: ${quantity}${unit} de ${savedItem.rrNumber} (Lote: ${batchNumber}).`, - type: 'info', - metadata: { stockItemId: savedItem._id, triggerType: 'stock_received' } + message: `Recebido: ${quantity}${unit} de ${savedItem.rr_number} (Lote: ${batchNumber}).`, + type: 'info' }); + await notificationService.checkLowStock(savedItem.id); } - // Check Low Stock immediately - await notificationService.checkLowStock(savedItem._id.toString()); - res.status(201).json(savedItem); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; + } catch (error: any) { 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) => { try { const { id } = req.params; - const organizationId = req.appUser?.organizationId; - // Only allow updating metadata, NOT quantity directly (quantity must be via adjustments) - // 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 + const organizationId = req.appUser?.organization_id; + const { quantity, ...otherData } = req.body; if (quantity !== undefined) { 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) { - 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) { - // Propagate to all siblings (same Product + Color) - await StockItem.updateMany( - { - organizationId, - dataSheetId: item.dataSheetId, - color: item.color - }, - { $set: { minStock: otherData.minStock } } + await query( + '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] ); } } - const updated = await StockItem.findOneAndUpdate( - { _id: id, organizationId }, - otherData, - { new: true } - ); - + const updated = await stockService.updateStockItem(id as string, otherData, organizationId); if (!updated) return res.status(404).json({ error: 'Item não encontrado.' }); - // Check Low Stock (in case minStock changed) - await notificationService.checkLowStock(updated._id.toString()); - + await notificationService.checkLowStock(id); res.json(updated); - - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; export const adjustStock = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; - const organizationId = req.appUser?.organizationId; - const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; - const { quantityDelta, reason } = req.body; // quantityDelta: +10 or -5 + const organizationId = req.appUser?.organization_id; + const { quantityDelta, reason } = req.body; 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.' }); - const item = await StockItem.findOne({ _id: id, organizationId }); - 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({ + const adjustment = await stockService.createStockMovement({ organizationId, - createdBy: req.appUser?.externalId, - stockItemId: item._id, - movementNumber, - type: 'ADJUSTMENT', + stockItemId: id as string, + userId: req.appUser?.id, + type: 'adjustment', quantity: Number(quantityDelta), - responsible: userName, reason }); - // Check Low Stock - await notificationService.checkLowStock(item._id.toString()); - - res.json(item); - - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + await notificationService.checkLowStock(id); + res.json(adjustment); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; export const consumeStock = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; - const organizationId = req.appUser?.organizationId; - const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; + const organizationId = req.appUser?.organization_id; const { quantityConsumed, requester, date } = req.body; 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.' }); - const item = await StockItem.findOne({ _id: id, organizationId }); - 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({ + await stockService.createStockMovement({ organizationId, - createdBy: req.appUser?.externalId, - stockItemId: item._id, - movementNumber, - type: 'CONSUMPTION', - quantity: -Number(quantityConsumed), // Negative - responsible: userName, - requester, - date: date || new Date() + stockItemId: id as string, + userId: req.appUser?.id, + type: 'exit', // Using exit for consumption + quantity: Number(quantityConsumed), + reason: `Consumo por ${requester}` }); - // Check Low Stock - await notificationService.checkLowStock(item._id.toString()); - - res.json(item); - - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + await notificationService.checkLowStock(id); + res.json({ message: 'Consumo registrado com sucesso.' }); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; export const deleteStockItem = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; - const organizationId = req.appUser?.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 }); - + const organizationId = req.appUser?.organization_id; + await stockService.deleteStockItem(id as string, organizationId); res.status(204).send(); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; export const getStockItems = async (req: AuthRequest, res: Response) => { try { - const organizationId = req.appUser?.organizationId; - const { dataSheetId } = req.query; - - // 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 }); - + const organizationId = req.appUser?.organization_id; + const items = await stockService.getStockItems(organizationId); res.json(items); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; export const getStockItemById = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; - const organizationId = req.appUser?.organizationId; - - const item = await StockItem.findOne({ _id: id, organizationId }) - .populate('dataSheetId', 'name manufacturer type'); - - if (!item) return res.status(404).json({ error: 'Item não encontrado.' }); - - res.json(item); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + const organizationId = req.appUser?.organization_id; + 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 + WHERE si.id = $1 AND si.organization_id = $2`, + [id, organizationId] + ); + if (!result?.rows[0]) return res.status(404).json({ error: 'Item não encontrado.' }); + res.json(result.rows[0]); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; export const getStockMovements = async (req: AuthRequest, res: Response) => { try { - const { id } = req.params; // StockItem ID - const organizationId = req.appUser?.organizationId; - - const movements = await StockMovement.find({ stockItemId: id, organizationId }) - .sort({ date: -1 }); - + const { id } = req.params; + const organizationId = req.appUser?.organization_id; + const movements = await stockService.getMovementsByItem(id, organizationId); res.json(movements); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; -// ------------------------------------------------------------------ -// CRUD & Auditing for Movements -// ------------------------------------------------------------------ - -import StockAuditLog from '../models/StockAuditLog.js'; - export const updateStockMovement = async (req: AuthRequest, res: Response) => { + // Basic implementation for Postgres try { - const { id } = req.params; // Movement ID - const organizationId = req.appUser?.organizationId; - 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 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 = { 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 }); + const { id } = req.params; + const { quantity, notes } = req.body; + await query('UPDATE stock_movements SET quantity = $1, reason = $2 WHERE id = $3', [quantity, notes, id]); + res.json({ message: 'Movimentação atualizada.' }); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; export const deleteStockMovement = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; - const organizationId = req.appUser?.organizationId; - 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 = { 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 }); - + await query('DELETE FROM stock_movements WHERE id = $1', [id]); res.status(204).send(); - - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error('Error deleting movement:', error); - res.status(500).json({ error: message }); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; export const getStockAuditLogs = async (req: AuthRequest, res: Response) => { try { - const { id } = req.params; // StockItem ID - const organizationId = req.appUser?.organizationId; - - const logs = await StockAuditLog.find({ stockItemId: id, organizationId }) - .sort({ timestamp: -1 }); - - res.json(logs); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + const { id } = req.params; + const result = await query('SELECT * FROM stock_audit_logs WHERE stock_item_id = $1 ORDER BY timestamp DESC', [id]); + res.json(result?.rows || []); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; + diff --git a/src/server/controllers/userController.ts b/src/server/controllers/userController.ts index 72d1c26..0c4d9ee 100644 --- a/src/server/controllers/userController.ts +++ b/src/server/controllers/userController.ts @@ -1,101 +1,72 @@ import { Request, Response } from 'express'; -import User, { IUser } from '../models/User.js'; -import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js'; +import { query } from '../config/database.js'; +import { snakeToCamel } from '../utils/mapper.js'; -// Define locally to avoid import cycle risks -interface IAppUser extends IUser { - organizationId?: string; - organizationRole?: OrgRole; - organizationBanned?: boolean; +interface IAppUser_Postgres { + id: string; + logto_id: string; + email: string; + name: string; + role: 'guest' | 'user' | 'admin'; + is_banned: boolean; + organization_id?: string; + organization_role?: string; + organization_banned?: boolean; } 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) => { - console.log('--- syncUser called ---', req.body); try { - const { externalId, email, name, organizationId, incomingRole } = req.body; + const { logtoId, email, name, organizationId, incomingRole } = req.body; - if (!externalId || !email || !name) { - return res.status(400).json({ error: 'externalId, email e name são obrigatórios.' }); + if (!logtoId || !email || !name) { + return res.status(400).json({ error: 'logtoId, email e name são obrigatórios.' }); } - // 1. Upsert the global User record - let user = await User.findOne({ externalId }); + const userResult = await query( + `INSERT INTO users (logto_id, email, name, role, is_banned, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (logto_id) + DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name, updated_at = NOW() + RETURNING id, logto_id, email, name, role, is_banned`, + [logtoId, email, name, 'guest', false] + ); - if (user) { - user.email = email; - user.name = name; - await user.save(); - } else { - user = await User.create({ - externalId, - email, - name, - role: 'guest', // Default global role - isBanned: false - }); - } + const user = userResult?.rows[0]; if (organizationId) { + let appRole = 'guest'; + if (incomingRole === 'org:admin') appRole = 'admin'; + else if (incomingRole === 'org:member') appRole = 'user'; - // Map Auth role to our app role - 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 } + 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] ); - // Return combined info - return res.json({ - ...user.toObject(), - organizationRole: member.role, - organizationBanned: member.isBanned - }); + 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) { 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)) }); } }; -/** - * Get current user data with organization context - */ export const getCurrentUser = async (req: AuthRequest, res: Response) => { try { if (!req.appUser) { @@ -105,53 +76,53 @@ export const getCurrentUser = async (req: AuthRequest, res: Response) => { const organizationId = req.headers['x-organization-id'] as string; if (organizationId) { - const member = await OrganizationMember.findOne({ - userId: req.appUser.externalId, - organizationId - }); + const memberResult = await query( + 'SELECT role, is_banned FROM user_organizations WHERE user_id = $1 AND organization_id = $2', + [req.appUser.id, organizationId] + ); - if (member) { - return res.json({ - ...req.appUser.toObject(), + if (memberResult?.rows[0]) { + const member = memberResult.rows[0]; + return res.json(snakeToCamel({ + ...req.appUser, role: member.role, - isBanned: member.isBanned, - organizationId - }); + is_banned: member.is_banned, + organization_id: organizationId + })); } } - res.json(req.appUser); + res.json(snakeToCamel(req.appUser)); } catch (error) { console.error('Error getting current user:', error); 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) => { try { const organizationId = req.headers['x-organization-id'] as string; - console.log('getAllUsers called with organizationId:', organizationId); - if (!organizationId) { return res.status(400).json({ error: 'Organização não selecionada.' }); } - const members = await OrganizationMember.find({ organizationId }).sort({ createdAt: -1 }); - console.log(`Found ${members.length} members for org ${organizationId}:`, members.map(m => ({ name: m.name, email: m.email, externalId: m.userId }))); - res.json(members); + const result = await query( + `SELECT u.id, u.email, u.name, uo.role, uo.is_banned, uo.created_at + 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) { console.error('Error getting users:', error); 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) => { try { const { id } = req.params; @@ -163,35 +134,25 @@ export const updateUserRole = async (req: AuthRequest, res: Response) => { } 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); - if (!member || member.organizationId !== organizationId) { - return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' }); + const result = await query( + 'UPDATE user_organizations SET role = $1, updated_at = NOW() WHERE user_id = $2 AND organization_id = $3 RETURNING *', + [role, id, organizationId] + ); + + if (!result?.rowCount) { + return res.status(404).json({ error: 'Membro não encontrado nesta organização.' }); } - // Prevent removing the last admin - 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); + res.json(snakeToCamel(result.rows[0])); } catch (error) { - console.error('Error toggling ban:', error); - res.status(500).json({ error: 'Erro ao alterar status de banimento.' }); + console.error('Error updating role:', error); + 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) => { try { 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.' }); } - const member = await OrganizationMember.findById(id); - if (!member || member.organizationId !== organizationId) { - return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' }); + const result = await query( + 'UPDATE user_organizations SET is_banned = $1, updated_at = NOW() WHERE user_id = $2 AND organization_id = $3 RETURNING *', + [isBanned, id, organizationId] + ); + + if (!result?.rowCount) { + return res.status(404).json({ error: 'Membro não encontrado.' }); } - // Prevent banning yourself - 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); + res.json(snakeToCamel(result.rows[0])); } catch (error) { console.error('Error toggling ban:', error); 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) => { try { - if (!req.appUser) { - return res.status(401).json({ error: 'Não autenticado.' }); - } - - // 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. - + if (!req.appUser) return res.status(401).end(); + await query('UPDATE users SET last_seen_at = NOW() WHERE id = $1', [req.appUser.id]); res.status(200).send(); } catch (error) { - // Silent fail for heartbeat console.error('Heartbeat error:', error); res.status(500).send(); } }; -/** - * Get active users in the same organization (seen in last 2 mins) - */ export const getActiveUsers = async (req: AuthRequest, res: Response) => { try { const organizationId = req.headers['x-organization-id'] as string; - const currentUserId = req.appUser?._id; + if (!organizationId) return res.json([]); - if (!organizationId) { - return res.status(400).json([]); - } + const result = await query( + `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 - 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); + res.json((result?.rows || []).map(snakeToCamel)); } catch (error) { console.error('Error getting active users:', error); res.status(500).json([]); } }; -// Delete organization member export const deleteUser = async (req: Request, res: Response) => { try { 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.' }); } - 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 - const result = await OrganizationMember.findByIdAndDelete(id); - - if (!result) { + if (!result?.rowCount) { return res.status(404).json({ error: 'Membro não encontrado.' }); } - console.log(`Member ${result.name} deleted successfully`); - - res.json({ message: 'Membro removido com sucesso.', deletedMember: result }); + res.json({ message: 'Membro removido com sucesso.' }); } catch (error) { console.error('Error deleting user:', error); res.status(500).json({ error: 'Erro ao remover membro.' }); } }; + + diff --git a/src/server/index.ts b/src/server/index.ts index 7ddb2b5..8b4abb1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,8 +1,6 @@ import app from './app.js'; import dotenv from 'dotenv'; -import { migrateFilesToGridFS } from './services/dataSheetService.js'; import { connectDB } from './config/database.js'; -import mongoose from 'mongoose'; import { notificationService } from './services/notificationService.js'; dotenv.config(); @@ -14,21 +12,15 @@ const startServer = async () => { const PORT = process.env.PORT || 3000; app.listen(Number(PORT), '0.0.0.0', async () => { 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) - console.log('📅 Scheduling stock expiration check...'); - setInterval(() => { - notificationService.checkStockExpirations(); - }, 24 * 60 * 60 * 1000); - - // Executar uma vez no início para garantir (opcional, bom para dev) + // Schedule tasks + console.log('📅 Scheduling stock expiration check...'); + setInterval(() => { notificationService.checkStockExpirations(); + }, 24 * 60 * 60 * 1000); - } else { - console.warn('⚠️ MongoDB is not connected. Skipping migrations.'); - } + // Execute once at start + notificationService.checkStockExpirations(); }); } catch (error) { console.error('Failed to start server:', error); diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index b701f9e..bef89ab 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -1,22 +1,27 @@ 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 { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { - // Se não houver token autêntico JWT, prossegue limpo return next(); } const token = authHeader.split(' ')[1]; - const decoded = jwt.verify(token, JWT_SECRET) as any; + + // Verify token with Logto JWKS + // Note: For production, you should also verify 'audience' if configured + const { payload } = await jwtVerify(token, JWKS, { + issuer: `${LOGTO_ENDPOINT}/oidc`, + }); - // Injeta o externalId no header para que o extractUser (roleMiddleware) - // continue seu trabalho de carregar o usuário do banco instanciado e popular req.appUser - req.headers['x-auth-user-id'] = decoded.externalId; + // The 'sub' claim in Logto is the user's unique ID + req.headers['x-auth-user-id'] = payload.sub; next(); } 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' }); } }; + diff --git a/src/server/middleware/roleMiddleware.ts b/src/server/middleware/roleMiddleware.ts index 96e1949..d7726d5 100644 --- a/src/server/middleware/roleMiddleware.ts +++ b/src/server/middleware/roleMiddleware.ts @@ -1,10 +1,16 @@ import { Request, Response, NextFunction } from 'express'; -import User, { IUser } from '../models/User.js'; -import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js'; -import Organization from '../models/Organization.js'; +import { query } from '../config/database.js'; + +export type OrgRole = 'guest' | 'user' | 'admin'; // 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; organizationRole?: OrgRole; organizationBanned?: boolean; @@ -19,82 +25,71 @@ declare module 'express-serve-static-core' { /** * 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) => { 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; - if (!externalId) { - return next(); // No user, continue without + if (!logtoId) { + 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.isBanned) { + if (user.is_banned) { return res.status(403).json({ error: 'Conta bloqueada. Entre em contato com o administrador.' }); } - // Create extended user object - const appUser: IAppUser = user.toObject() as IAppUser; - appUser.organizationId = organizationId || user.organizationId; + const appUser: IAppUser = { + id: user.id, + 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) { - // Check if Organization is globally banned (subscription specific, etc.) - const orgStatus = await Organization.findOne({ externalId: organizationId }); + // Check if Organization is globally banned + 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; if (orgStatus) { - // Update name if different and present if (orgName && orgStatus.name !== orgName) { - try { - await Organization.updateOne( - { externalId: organizationId }, - { name: orgName } - ); - } catch (err) { - console.warn('Failed to update organization name', err); - } + await query('UPDATE organizations SET name = $1 WHERE id = $2', [orgName, organizationId]); } - if (orgStatus.isBanned) { - return res.status(403).json({ - error: 'Acesso bloqueado: Esta organização está suspensa. Entre em contato com o suporte.' - }); + if (orgStatus.is_banned) { + return res.status(403).json({ error: 'Acesso bloqueado: Esta organização está suspensa.' }); } } else { - // Create new org with name if present - try { - await Organization.create({ - externalId: organizationId, - name: orgName - }); - } catch (_e) { - console.warn('Organization auto-create race condition', _e); - } + // Auto-create org if missing + await query('INSERT INTO organizations (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING', [organizationId, orgName || 'New Org']); } - 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.isBanned) { + if (member.is_banned) { return res.status(403).json({ error: 'Acesso bloqueado nesta organização.' }); } - appUser.organizationRole = member.role; - appUser.role = member.role; // Override global role with org role + appUser.organizationRole = member.role as OrgRole; + appUser.role = member.role as OrgRole; // Override global role with org role } else { - // User exists but is not a member of this org yet appUser.organizationRole = 'guest'; appUser.role = 'guest'; } } 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(); @@ -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[]) => { return (req: Request, res: Response, next: NextFunction) => { if (!req.appUser) { return res.status(401).json({ error: 'Autenticação necessária.' }); } - // DEV Bypass: Developer has full power if (req.appUser.email === 'admtracksteel@gmail.com') { return next(); } @@ -129,19 +119,9 @@ export const requireRole = (allowedRoles: OrgRole[]) => { }; }; -/** - * Middleware to require admin role - */ export const requireAdmin = requireRole(['admin']); - -/** - * Middleware to require at least user role (user or 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) => { if (!req.appUser) { 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; 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(); }; -/** - * Middleware to require Developer (Super Admin) access - * Hardcoded to specific email for security - */ export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => { if (!req.appUser) { return res.status(401).json({ error: 'Autenticação necessária.' }); } 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.' }); } next(); }; + diff --git a/src/server/scripts/apply_schema.js b/src/server/scripts/apply_schema.js new file mode 100644 index 0000000..5270022 --- /dev/null +++ b/src/server/scripts/apply_schema.js @@ -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(); diff --git a/src/server/scripts/final_postgres_schema.sql b/src/server/scripts/final_postgres_schema.sql new file mode 100644 index 0000000..e4264fc --- /dev/null +++ b/src/server/scripts/final_postgres_schema.sql @@ -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; diff --git a/src/server/services/applicationRecordService.ts b/src/server/services/applicationRecordService.ts index 7893dda..3609bf1 100644 --- a/src/server/services/applicationRecordService.ts +++ b/src/server/services/applicationRecordService.ts @@ -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 }) => { - const newRecord = new ApplicationRecord({ - ...data, - date: data.date ? new Date(data.date) : null, - organizationId: data.organizationId, - createdBy: data.createdBy + 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(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) => { - const query = { projectId, ...(organizationId ? { organizationId } : {}) }; - const records = await ApplicationRecord.find(query).sort({ date: -1 }).lean(); - return records.map(r => ({ ...r, id: r._id.toString() })); + let whereClause = 'WHERE project_id = $1'; + const params: any[] = [projectId]; + 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) => { - 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; - // Organization Check - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { return null; } - // Role/Ownership check const isPowerUser = userRole === 'admin' || isDeveloper; - if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) { - console.warn(`Permission Denied: User ${userId} tried to update record ${id} created by ${existing.createdBy}`); + if (!isPowerUser && existing.created_by && existing.created_by !== userId) { return null; } - const updateData = { - ...data, - date: data.date ? new Date(data.date) : undefined - }; + const fields: string[] = []; + const params: any[] = []; + let i = 1; - if (organizationId && !existing.organizationId) { - updateData.organizationId = organizationId; - } + Object.entries(data).forEach(([key, value]) => { + 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(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } - return null; + params.push(id); + const result = await query( + `UPDATE application_records SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`, + params + ); + return snakeToCamel(result?.rows[0]); }; 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; - // Organization Check - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { return false; } - // Role/Ownership check const isPowerUser = userRole === 'admin' || isDeveloper; - if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) { + if (!isPowerUser && existing.created_by && existing.created_by !== userId) { return false; } - await ApplicationRecord.deleteOne({ _id: id }); + await query('DELETE FROM application_records WHERE id = $1', [id]); return true; }; + + diff --git a/src/server/services/dataSheetService.ts b/src/server/services/dataSheetService.ts index 3031d41..78eea13 100644 --- a/src/server/services/dataSheetService.ts +++ b/src/server/services/dataSheetService.ts @@ -1,174 +1,142 @@ -import TechnicalDataSheet from '../models/TechnicalDataSheet.js'; +import { query } from '../config/database.js'; 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 => { - return new Promise((resolve, reject) => { - const uploadStream = bucket.openUploadStream(filename); - const readStream = fs.createReadStream(localPath); - - readStream.pipe(uploadStream) - .on('error', reject) - .on('finish', () => { - // Remove local file after upload - fs.unlink(localPath, (err) => { - if (err) console.error('Failed to delete local temp file:', err); - }); - resolve(uploadStream.id.toString()); - }); +export const saveFileToDB = async (localPath: string, filename: string): Promise => { + const data = fs.readFileSync(localPath); + const contentType = filename.endsWith('.pdf') ? 'application/pdf' : 'application/octet-stream'; + const result = await query( + 'INSERT INTO stored_files (filename, content_type, data, size) VALUES ($1, $2, $3, $4) RETURNING id', + [filename, contentType, data, data.length] + ); + // Remove local file + fs.unlink(localPath, (err) => { + if (err) console.error('Failed to delete local temp file:', err); }); + return result?.rows[0].id; }; -export const deleteFileFromGridFS = async (fileId: string) => { +export const deleteFileFromDB = async (fileId: string) => { try { - await bucket.delete(new ObjectId(fileId)); + await query('DELETE FROM stored_files WHERE id = $1', [fileId]); return true; } catch (err) { - console.error('Failed to delete file from GridFS:', err); + console.error('Failed to delete file from Postgres:', err); return false; } }; -export const getFileStream = (fileId: string) => { - if (!ObjectId.isValid(fileId)) { - throw new Error('Invalid file ID format'); - } - return bucket.openDownloadStream(new ObjectId(fileId)); +export const getFileStream = async (fileId: string) => { + const result = await query('SELECT data, filename, content_type FROM stored_files WHERE id = $1', [fileId]); + const file = result?.rows[0]; + if (!file) throw new Error('Arquivo não encontrado'); + return file; }; export const getAllDataSheets = async (organizationId?: string) => { - const query = organizationId - ? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] } - : {}; - const sheets = await TechnicalDataSheet.find(query).sort({ uploadDate: -1 }).lean(); - return sheets.map(s => ({ ...s, id: s._id.toString() })); + 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 * FROM technical_data_sheets ${whereClause} ORDER BY upload_date DESC`, params); + return result?.rows || []; }; -export const matchSheets = async (query: string, organizationId?: string) => { - const orgFilter = organizationId - ? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] } - : {}; - - const filter = { - ...orgFilter, - $or: [ - { name: { $regex: query, $options: 'i' } }, - { manufacturer: { $regex: query, $options: 'i' } }, - { type: { $regex: query, $options: 'i' } } - ] - }; - const sheets = await TechnicalDataSheet.find(filter).lean(); - return sheets.map(s => ({ ...s, id: s._id.toString() })); -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const createDataSheet = async (data: any & { organizationId?: string }) => { - let fileId = data.fileUrl; - - // If fileUrl is a local path (exists on disk), move to GridFS - if (data.fileUrl && fs.existsSync(data.fileUrl)) { - fileId = await saveFileToGridFS(data.fileUrl, data.name + '.pdf'); +export const matchSheets = async (searchQuery: string, organizationId?: string) => { + let whereClause = '(name ILIKE $1 OR manufacturer ILIKE $1 OR type ILIKE $1)'; + const params: any[] = [`%${searchQuery}%`]; + + if (organizationId) { + whereClause += ' AND (organization_id = $2 OR organization_id IS NULL)'; + params.push(organizationId); } - const newSheet = new TechnicalDataSheet({ - ...data, - fileUrl: fileId, // Now storing GridFS ID instead of path - uploadDate: new Date(), - organizationId: data.organizationId + const result = await query(`SELECT * FROM technical_data_sheets WHERE ${whereClause} LIMIT 20`, params); + return result?.rows || []; +}; + +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(); - return { ...saved.toObject(), id: saved._id.toString() }; + const result = await query( + `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) => { - // Find first to check permissions - const sheet = await TechnicalDataSheet.findById(id); + const check = await query('SELECT organization_id, file_id FROM technical_data_sheets WHERE id = $1', [id]); + const sheet = check?.rows[0]; if (!sheet) return false; - // Permission Check: - // 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}`); + if (organizationId && sheet.organization_id && sheet.organization_id !== organizationId) { return false; } - // Delete from GridFS if not a full URL - if (sheet.fileUrl && !sheet.fileUrl.startsWith('http')) { - await deleteFileFromGridFS(sheet.fileUrl); + if (sheet.file_id) { + await deleteFileFromDB(sheet.file_id); } - await TechnicalDataSheet.findByIdAndDelete(id); + await query('DELETE FROM technical_data_sheets WHERE id = $1', [id]); 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) => { - // SECURITY FIX: Allow update if: - // 1. Matches ID AND Matches Organization - // 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it! - - const oldSheet = await TechnicalDataSheet.findById(id); + const check = await query('SELECT organization_id, file_id FROM technical_data_sheets WHERE id = $1', [id]); + const oldSheet = check?.rows[0]; if (!oldSheet) return null; - if (organizationId && oldSheet.organizationId && oldSheet.organizationId !== 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 (organizationId && oldSheet.organization_id && oldSheet.organization_id !== organizationId) { + return null; } - // 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); + if (updates.fileUrl && fs.existsSync(updates.fileUrl)) { + const newFileId = await saveFileToDB(updates.fileUrl, (updates.name || oldSheet.name) + '.pdf'); + if (oldSheet.file_id) { + await deleteFileFromDB(oldSheet.file_id); } - - updates.fileUrl = newFileId; + updates.file_id = newFileId; + delete updates.fileUrl; } - if (organizationId && !oldSheet.organizationId) { - updates.organizationId = organizationId; - } + const fields: string[] = []; + const params: any[] = []; + let i = 1; - const updated = await TechnicalDataSheet.findOneAndUpdate({ _id: id }, updates, { new: true }).lean(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } - return null; -}; + Object.entries(updates).forEach(([key, value]) => { + const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + fields.push(`${sqlKey} = $${i++}`); + params.push(value); + }); -export const migrateFilesToGridFS = async () => { - try { - const sheets = await TechnicalDataSheet.find({ fileUrl: { $regex: /^uploads\// } }); - console.log(`[MIGRATION] Found ${sheets.length} sheets to migrate to GridFS`); - - for (const sheet of sheets) { - const localPath = path.join(process.cwd(), sheet.fileUrl); - if (fs.existsSync(localPath)) { - try { - const gridFsId = await saveFileToGridFS(localPath, sheet.name + '.pdf'); - sheet.fileUrl = gridFsId; - await sheet.save(); - console.log(`[MIGRATION] Successfully migrated: ${sheet.name}`); - } catch (err) { - console.error(`[MIGRATION] Error migrating ${sheet.name}:`, err); - } - } else { - console.warn(`[MIGRATION] File not found for ${sheet.name}: ${localPath}`); - } - } - } catch (error) { - console.error('[MIGRATION] Migration failed:', error); - } + params.push(id); + const result = await query( + `UPDATE technical_data_sheets SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`, + params + ); + return result?.rows[0]; }; + diff --git a/src/server/services/inspectionService.ts b/src/server/services/inspectionService.ts index a8e8436..dfdd7bb 100644 --- a/src/server/services/inspectionService.ts +++ b/src/server/services/inspectionService.ts @@ -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 }) => { - const newInspection = new Inspection({ - ...data, - date: data.date ? new Date(data.date) : null, - organizationId: data.organizationId, - createdBy: data.createdBy + 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 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) => { - const query = { projectId, ...(organizationId ? { organizationId } : {}) }; - const inspections = await Inspection.find(query).sort({ date: -1 }).lean(); - return inspections.map(i => ({ ...i, id: i._id.toString() })); + let whereClause = 'WHERE project_id = $1'; + const params: any[] = [projectId]; + 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) => { - 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; - // Organization Check - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { return null; } - // Role/Ownership check const isPowerUser = userRole === 'admin' || isDeveloper; - if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) { - console.warn(`Permission Denied: User ${userId} tried to update inspection ${id} created by ${existing.createdBy}`); + if (!isPowerUser && existing.created_by && existing.created_by !== userId) { return null; } - const updateData = { - ...data, - date: data.date ? new Date(data.date) : undefined - }; + const fields: string[] = []; + const params: any[] = []; + let i = 1; - if (organizationId && !existing.organizationId) { - updateData.organizationId = organizationId; - } + Object.entries(data).forEach(([key, value]) => { + 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(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } - return null; + params.push(id); + const result = await query( + `UPDATE inspections SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`, + params + ); + return snakeToCamel(result?.rows[0]); }; 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; - // Organization Check - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { return false; } - // Role/Ownership check const isPowerUser = userRole === 'admin' || isDeveloper; - if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) { + if (!isPowerUser && existing.created_by && existing.created_by !== userId) { return false; } - await Inspection.deleteOne({ _id: id }); + await query('DELETE FROM inspections WHERE id = $1', [id]); return true; }; export const getAllInspections = async (organizationId?: string) => { - const query = organizationId ? { organizationId } : {}; - const inspections = await Inspection.find(query).lean(); - return inspections.map(i => ({ ...i, id: i._id.toString() })); + let whereClause = ''; + const params: any[] = []; + 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); }; + + diff --git a/src/server/services/notificationService.ts b/src/server/services/notificationService.ts index 1270cd5..c706396 100644 --- a/src/server/services/notificationService.ts +++ b/src/server/services/notificationService.ts @@ -1,96 +1,100 @@ -import Notification, { INotification } from '../models/Notification.js'; -import StockItem from '../models/StockItem.js'; -import Instrument from '../models/Instrument.js'; +import { query } from '../config/database.js'; 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 = { - // Criar uma notificação - async create(data: Partial) { + async create(data: any) { try { - const notification = new Notification(data); - await notification.save(); - return notification; + const columns: string[] = []; + const values: any[] = []; + 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) { console.error('Error creating notification:', error); throw error; } }, - // Verificar se já existe uma notificação recente para evitar spam async isAlreadyNotified(orgId: string, metadata: Record, graceDays: number = 30) { try { const graceDate = new Date(); graceDate.setDate(graceDate.getDate() - graceDays); - - const query: Record = { - organizationId: orgId - }; - - // Adicionar campos de metadata à query - for (const [key, value] of Object.entries(metadata)) { - 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; + + // To check JSONB metadata properly in PG: metadata @> '{"stockItemId": "..."}' + 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] + ); + return !!result?.rows[0]; } catch (error) { console.error('Error checking notification existence:', error); return false; } }, - // Obter notificações de um usuário (ou globais da organização) async getUserNotifications(userId: string, organizationId: string, includeArchived: boolean = false) { try { - const query: Record = { - organizationId, - $or: [ - { recipientId: userId }, - { recipientId: null } // Notificações globais - ], - deletedBy: { $ne: userId } // Não mostrar as deletadas pelo usuário - }; + let where = 'organization_id = $1 AND (user_id = $2 OR user_id IS NULL) AND NOT ($2 = ANY(deleted_by))'; + const params: any[] = [organizationId, userId]; if (!includeArchived) { - // Filtra as arquivadas (pelo usuário ou globalmente) - query.isArchived = false; - query.archivedBy = { $ne: userId }; + where += ' AND is_archived = FALSE AND NOT ($2 = ANY(archived_by))'; } - 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) { console.error('Error fetching notifications:', error); throw error; } }, - // Marcar como lida async markAsRead(id: string) { 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) { console.error('Error marking notification as read:', error); throw error; } }, - // Marcar todas como lidas para um usuário async markAllAsRead(userId: string, organizationId: string) { try { - return await Notification.updateMany( - { - organizationId, - $or: [ - { recipientId: userId }, - { recipientId: null } - ], - isRead: false - }, - { isRead: true } + await query( + 'UPDATE notifications SET read = TRUE WHERE organization_id = $1 AND (user_id = $2 OR user_id IS NULL)', + [organizationId, userId] ); } catch (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) { try { - const notification = await Notification.findById(id); - if (!notification) return null; + const check = await query('SELECT * FROM notifications WHERE id = $1', [id]); + const notif = check?.rows[0]; + if (!notif) return null; - if (notification.recipientId) { - // Notificação pessoal - notification.isArchived = true; - notification.isRead = true; + if (notif.user_id) { + await query('UPDATE notifications SET is_archived = TRUE, read = TRUE WHERE id = $1', [id]); } else { - // Notificação global - if (!notification.archivedBy.includes(userId)) { - notification.archivedBy.push(userId); - } - // Marcar como lida também? Opcional - if (!notification.readBy?.includes(userId)) { - // Nota: se quisermos readBy global, precisaríamos desse campo. - // Para simplificar, vamos assumir que arquivar esconde da lista ativa. - } + await query('UPDATE notifications SET archived_by = array_append(archived_by, $1) WHERE id = $2', [userId, id]); } - return await notification.save(); + return true; } catch (error) { console.error('Error archiving notification:', error); throw error; } }, - // Deletar (esconder) uma notificação para um usuário async softDelete(id: string, userId: string) { try { - const notification = await Notification.findById(id); - if (!notification) return null; + const check = await query('SELECT * FROM notifications WHERE id = $1', [id]); + const notif = check?.rows[0]; + if (!notif) return null; - if (notification.recipientId && notification.recipientId === userId) { - // Se for pessoal, podemos deletar do banco ou apenas marcar - return await Notification.findByIdAndDelete(id); + if (notif.user_id === userId) { + await query('DELETE FROM notifications WHERE id = $1', [id]); } else { - // Se for global, apenas adicionar ao deletedBy - if (!notification.deletedBy.includes(userId)) { - notification.deletedBy.push(userId); - } - return await notification.save(); + await query('UPDATE notifications SET deleted_by = array_append(deleted_by, $1) WHERE id = $2', [userId, id]); } + return true; } catch (error) { console.error('Error soft deleting notification:', 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() { - console.log('Running stock expiration checkJob...'); try { - // Buscar todos os itens de estoque com data de validade que ainda não venceram ou venceram recentemente - // 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 = await StockItem.find({ expirationDate: { $exists: true, $ne: null }, quantity: { $gt: 0 } }); - + const result = await query('SELECT * FROM stock_items WHERE expiration_date IS NOT NULL AND quantity > 0'); + const stockItems = result?.rows || []; const now = new Date(); const twoMonthsFromNow = addMonths(now, 2); const oneMonthFromNow = addMonths(now, 1); for (const item of stockItems) { - if (!item.expirationDate) continue; - - const expirationDate = new Date(item.expirationDate); - const itemId = item._id.toString(); - const orgId = item.organizationId; + const expDate = new Date(item.expiration_date); + const itemId = item.id; + const orgId = item.organization_id; if (!orgId) continue; - let message = ''; - let title = ''; - let type: 'warning' | 'error' = 'warning'; - - // Lógica de notificação - // 1. Vencido - if (isBefore(expirationDate, now)) { - title = 'Item Vencido'; - message = `O item ${item.rrNumber} - Lote ${item.batchNumber} venceu em ${expirationDate.toLocaleDateString()}.`; - 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' } - }); - } + if (isBefore(expDate, now)) { + await this.notifyCheck(orgId, itemId, 'expired', 'Item Vencido', + `O item ${item.rr_number} - Lote ${item.batch_number} venceu em ${expDate.toLocaleDateString()}.`, 'error'); + } else if (isBefore(expDate, oneMonthFromNow)) { + await this.notifyCheck(orgId, itemId, 'expire_1_month', 'Vencimento Próximo (1 mês)', + `O item ${item.rr_number} - Lote ${item.batch_number} vencerá em menos de 1 mês.`, 'warning'); + } else if (isBefore(expDate, twoMonthsFromNow)) { + await this.notifyCheck(orgId, itemId, 'expire_2_months', 'Vencimento em 2 meses', + `O item ${item.rr_number} - Lote ${item.batch_number} vencerá em 2 meses.`, 'info'); } } } catch (error) { @@ -270,78 +169,39 @@ export const notificationService = { } }, - // Verificar calibração de instrumentos - async checkInstrumentCalibrations() { - console.log('Running instrument calibration checkJob...'); - try { - const instruments = await Instrument.find({ - calibrationExpirationDate: { $exists: true, $ne: null }, - status: { $ne: 'inactive' } - }); + async notifyCheck(orgId: string, stockItemId: string, triggerType: string, title: string, message: string, type: string) { + const metadata = { stockItemId, triggerType }; + const notified = await this.isAlreadyNotified(orgId, metadata); + if (!notified) { + await this.create({ organizationId: orgId, title, message, type, metadata }); + } + }, + 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 twoMonthsFromNow = addMonths(now, 2); const oneMonthFromNow = addMonths(now, 1); - for (const instrument of instruments) { - if (!instrument.calibrationExpirationDate) continue; - - const expirationDate = new Date(instrument.calibrationExpirationDate); - const instrumentId = instrument._id.toString(); - const orgId = instrument.organizationId; - + for (const inst of instruments) { + const expDate = new Date(inst.calibration_expiration_date); + const instId = inst.id; + const orgId = inst.organization_id; if (!orgId) continue; - let title = ''; - let message = ''; - let type: 'info' | 'warning' | 'error' = 'info'; - let triggerType = ''; - - // 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(); + if (isBefore(expDate, now)) { + const metadata = { instrumentId: instId, triggerType: 'calibration_expired' }; + if (!(await this.isAlreadyNotified(orgId, metadata))) { + await this.create({ organizationId: orgId, title: 'Calibração Vencida', message: `O instrumento ${inst.name} (${inst.serial_number}) está vencido.`, type: 'error', metadata }); + } + 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) { @@ -349,44 +209,30 @@ export const notificationService = { } }, - // Verificar se o estoque está abaixo do mínimo (Aggregated by Product + Color) async checkLowStock(stockItemId: string) { try { - const item = await StockItem.findById(stockItemId).populate('dataSheetId', 'name manufacturer'); - if (!item || !item.minStock || item.minStock <= 0) return; + const itemResult = 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 + WHERE si.id = $1`, [stockItemId] + ); + const item = itemResult?.rows[0]; + if (!item || !item.min_stock || item.min_stock <= 0) return; - const orgId = item.organizationId; - if (!orgId) return; + const orgId = item.organization_id; + 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 - const siblings = await StockItem.find({ - organizationId: orgId, - 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) { + if (totalQuantity < item.min_stock) { + const metadata = { product_color_key: `${item.data_sheet_id}-${item.color}`, triggerType: 'low_stock_aggregated' }; + if (!(await this.isAlreadyNotified(orgId, metadata, 3))) { await this.create({ organizationId: orgId, 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', - metadata: { - stockItemId, - productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`, - triggerType: 'low_stock_aggregated' - } + metadata }); } } @@ -395,3 +241,4 @@ export const notificationService = { } } }; + diff --git a/src/server/services/paintingSchemeService.ts b/src/server/services/paintingSchemeService.ts index 5058162..9100363 100644 --- a/src/server/services/paintingSchemeService.ts +++ b/src/server/services/paintingSchemeService.ts @@ -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 }) => { - const newScheme = new PaintingScheme({ ...data, organizationId: data.organizationId }); - const saved = await newScheme.save(); - 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 painting_schemes (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`, + values + ); + return snakeToCamel(result?.rows[0]); }; export const getPaintingSchemesByProject = async (projectId: string, organizationId?: string) => { - const query = { projectId, ...(organizationId ? { organizationId } : {}) }; - const schemes = await PaintingScheme.find(query).lean(); - return schemes.map(s => ({ ...s, id: s._id.toString() })); + let whereClause = 'WHERE project_id = $1'; + const params: any[] = [projectId]; + 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) => { - // SECURITY FIX: Allow update if: - // 1. Matches ID AND Matches Organization - // 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); - + const check = await query('SELECT organization_id FROM painting_schemes WHERE id = $1', [id]); + const existing = check?.rows[0]; if (!existing) return null; - // Check ownership - 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 (organizationId && existing.organization_id && existing.organization_id !== organizationId) { + return null; } - // 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 fields: string[] = []; + const params: any[] = []; + let i = 1; - const updated = await PaintingScheme.findOneAndUpdate({ _id: id }, data, { new: true }).lean(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } - return null; + 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) => { - // Find first to check permissions - const existing = await PaintingScheme.findById(id); + const check = await query('SELECT organization_id FROM painting_schemes WHERE id = $1', [id]); + const existing = check?.rows[0]; if (!existing) return; - // Permissions: - // 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}`); + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { return; } - await PaintingScheme.findByIdAndDelete(id); + await query('DELETE FROM painting_schemes WHERE id = $1', [id]); }; export const getAllSchemes = async (organizationId?: string) => { - const query = organizationId ? { organizationId } : {}; - const schemes = await PaintingScheme.find(query).lean(); - return schemes.map(s => ({ ...s, id: s._id.toString() })); + let whereClause = ''; + const params: any[] = []; + 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); }; + + diff --git a/src/server/services/partService.ts b/src/server/services/partService.ts index 19f394f..5975c4b 100644 --- a/src/server/services/partService.ts +++ b/src/server/services/partService.ts @@ -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 }) => { - const newPart = new Part({ ...data, organizationId: data.organizationId }); - const saved = await newPart.save(); - 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 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) => { - const query = isGlobalAdmin - ? { projectId } - : { projectId, $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }; - const parts = await Part.find(query).lean(); - return parts.map(p => ({ ...p, id: p._id.toString() })); + let whereClause = 'WHERE project_id = $1'; + const params: any[] = [projectId]; + + if (!isGlobalAdmin) { + 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) => { - 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 (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) { - console.warn(`Access Denied: Part ${id} belongs to ${existing.organizationId}, user is ${organizationId}`); + if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) { return null; } - if (organizationId && !existing.organizationId) { - data.organizationId = organizationId; // Adopt - } + const fields: string[] = []; + const params: any[] = []; + let i = 1; - const updated = await Part.findOneAndUpdate({ _id: id }, data, { new: true }).lean(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } - return null; + 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 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) => { - 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 (!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'); } - await Part.findByIdAndDelete(id); + await query('DELETE FROM parts WHERE id = $1', [id]); }; export const getAllParts = async (organizationId?: string, isGlobalAdmin: boolean = false) => { - const query = isGlobalAdmin - ? {} - : { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }; - const parts = await Part.find(query).lean(); - return parts.map(p => ({ ...p, id: p._id.toString() })); + let whereClause = ''; + const params: any[] = []; + + if (!isGlobalAdmin) { + 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); }; + + diff --git a/src/server/services/projectService.ts b/src/server/services/projectService.ts index ba5e804..506aa09 100644 --- a/src/server/services/projectService.ts +++ b/src/server/services/projectService.ts @@ -1,8 +1,5 @@ -import Project from '../models/Project.js'; -import Part from '../models/Part.js'; -import PaintingScheme from '../models/PaintingScheme.js'; -import ApplicationRecord from '../models/ApplicationRecord.js'; -import Inspection from '../models/Inspection.js'; +import { query } from '../config/database.js'; +import { snakeToCamel } from '../utils/mapper.js'; interface ProjectData { name: string; @@ -15,207 +12,150 @@ interface ProjectData { } export const createProject = async (data: ProjectData & { organizationId?: string }) => { - const newProject = new Project({ - name: data.name, - client: data.client, - startDate: data.startDate ? new Date(data.startDate) : null, - endDate: data.endDate ? new Date(data.endDate) : null, - technician: data.technician, - environment: data.environment, - organizationId: data.organizationId, - weightKg: data.weightKg - }); - return await newProject.save(); + const result = await query( + `INSERT INTO projects (name, client, organization_id, start_date, end_date, technician, environment, weight_kg, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active') + RETURNING *`, + [data.name, data.client, data.organizationId, data.startDate, data.endDate, data.technician, data.environment, data.weightKg] + ); + return snakeToCamel(result?.rows[0]); }; 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([ - { $match: matchStage }, - { $sort: { name: 1 } }, - { - $lookup: { - from: 'paintingschemes', - localField: '_id', - foreignField: 'projectId', - 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" } - } - } - ]); + const result = await query( + `SELECT p.*, + (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 + FROM projects p + ${whereClause} + ORDER BY p.name ASC`, + params + ); - 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') => { - const statusQuery = status === 'active' - ? { status: { $ne: 'archived' } } - : { status: 'archived' }; + const filters: string[] = []; + const params: any[] = []; - const matchQuery: Record = isGlobalAdmin - ? { ...statusQuery } - : { - ...statusQuery, - $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] - }; + if (status === 'archived') { + filters.push('status = \'archived\''); + } else { + filters.push('status != \'archived\''); + } - const projects = await Project.aggregate([ - { $match: matchQuery }, - { $sort: { name: 1 } }, - { - $lookup: { - from: 'paintingschemes', - localField: '_id', - foreignField: 'projectId', - as: 'paintingSchemes' - } - }, - { - $addFields: { - id: { $toString: "$_id" } - } + if (!isGlobalAdmin) { + if (organizationId) { + 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) => { - 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'); - // Check ownership - if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) { + if (!isGlobalAdmin && organizationId && project.organization_id && project.organization_id !== organizationId) { throw new Error('Sem permissão para arquivar este projeto'); } const newStatus = project.status === 'active' ? 'archived' : 'active'; - const updated = await Project.findByIdAndUpdate(id, { status: newStatus }, { new: true }).lean(); - return updated; + const result = await query( + '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) => { - 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'); - // Security check: Allow if global admin OR matches organization OR project has no organization - if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) { + if (!isGlobalAdmin && organizationId && project.organization_id && project.organization_id !== organizationId) { throw new Error('Acesso negado a este projeto'); } - const [parts, schemes, records, inspections] = await Promise.all([ - Part.find({ projectId: id }).lean(), - PaintingScheme.find({ projectId: id }).populate('paintId thinnerId').lean(), - ApplicationRecord.find({ projectId: id }).lean(), - Inspection.find({ projectId: id }) - .populate({ - path: 'stockItemId', - select: 'batchNumber dataSheetId', - populate: { path: 'dataSheetId', select: 'name' } - }) - .lean() + const [parts, schemes, inspections] = await Promise.all([ + query('SELECT * FROM parts WHERE project_id = $1', [id]), + query('SELECT * FROM painting_schemes WHERE project_id = $1', [id]), + query('SELECT * FROM inspections WHERE project_id = $1', [id]) ]); - return { + return snakeToCamel({ ...project, - id: project._id.toString(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - parts: parts.map((p: any) => ({ ...p, id: p._id.toString() })), - // 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() })) - }; + parts: parts?.rows || [], + paintingSchemes: schemes?.rows || [], + inspections: inspections?.rows || [] + }); }; export const updateProject = async (id: string, data: Partial, 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; - // Check ownership - if (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) { - console.warn(`Access Denied: Project ${id} belongs to ${existing.organizationId}, user is ${organizationId}`); + if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) { return null; } - const updateData: Partial & { updatedAt: Date, organizationId?: string } = { - ...data, - updatedAt: new Date(), - startDate: data.startDate ? new Date(data.startDate) : undefined, - endDate: data.endDate ? new Date(data.endDate) : undefined, - weightKg: data.weightKg, - }; + const fields: string[] = []; + const params: any[] = []; + let i = 1; - // Adopt if needed - if (organizationId && !existing.organizationId) { - updateData.organizationId = organizationId; - } + Object.entries(data).forEach(([key, value]) => { + const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + fields.push(`${sqlKey} = $${i++}`); + params.push(value); + }); - const updated = await Project.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } - return null; + params.push(id); + const result = await query( + `UPDATE projects SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`, + params + ); + + return snakeToCamel(result?.rows[0]); }; 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; - // Check ownership - if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) { + if (!isGlobalAdmin && organizationId && project.organization_id && project.organization_id !== organizationId) { throw new Error('Sem permissão para excluir este projeto'); } - await Project.findByIdAndDelete(id); - - // Also cleanup related data - await Promise.all([ - Part.deleteMany({ projectId: id }), - PaintingScheme.deleteMany({ projectId: id }), - ApplicationRecord.deleteMany({ projectId: id }), - Inspection.deleteMany({ projectId: id }) - ]); + await query('DELETE FROM projects WHERE id = $1', [id]); }; + + diff --git a/src/server/services/stockService.ts b/src/server/services/stockService.ts new file mode 100644 index 0000000..1c6ef99 --- /dev/null +++ b/src/server/services/stockService.ts @@ -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); +}; + diff --git a/src/server/services/yieldStudyService.ts b/src/server/services/yieldStudyService.ts index 337680c..c737688 100644 --- a/src/server/services/yieldStudyService.ts +++ b/src/server/services/yieldStudyService.ts @@ -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) => { - const query = organizationId ? { organizationId } : {}; - const studies = await YieldStudy.find(query).populate('dataSheetId').sort({ createdAt: -1 }).lean(); - return studies.map(s => ({ ...s, id: s._id.toString() })); + let whereClause = ''; + const params: any[] = []; + 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 }) => { - const newStudy = new YieldStudy({ ...data, organizationId: data.organizationId }); - const saved = await newStudy.save(); - return { ...saved.toObject(), id: saved._id.toString() }; + const categories = data.categories || []; + delete data.categories; + + 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) => { - // SECURITY FIX: Allow update if: - // 1. Matches ID AND Matches Organization - // 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it! - - const existing = await YieldStudy.findById(id); + const check = await query('SELECT organization_id FROM yield_studies WHERE id = $1', [id]); + const existing = check?.rows[0]; if (!existing) return null; - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { - console.warn(`Access Denied: Study ${id} belongs to ${existing.organizationId}, user is ${organizationId}`); + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { return null; } - if (organizationId && !existing.organizationId) { - updates.organizationId = organizationId; + const categories = updates.categories; + 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(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } - return null; + return snakeToCamel(study); }; export const deleteStudy = async (id: string, organizationId?: string) => { - // SECURITY FIX: Same logic as update - allow delete if owned OR if orphan - const existing = await YieldStudy.findById(id); + const check = await query('SELECT organization_id FROM yield_studies WHERE id = $1', [id]); + const existing = check?.rows[0]; if (!existing) return false; - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { - console.warn(`Access Denied: Delete Study ${id} belongs to ${existing.organizationId}, user is ${organizationId}`); + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { return false; } - await YieldStudy.findByIdAndDelete(id); + await query('DELETE FROM yield_studies WHERE id = $1', [id]); return true; }; + + diff --git a/src/server/utils/mapper.ts b/src/server/utils/mapper.ts new file mode 100644 index 0000000..2cf542d --- /dev/null +++ b/src/server/utils/mapper.ts @@ -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; +};