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

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

295
package-lock.json generated
View File

@@ -10,8 +10,10 @@
"dependencies": {
"@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",

View File

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

View File

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

View File

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

View File

@@ -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<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
const typeLabel = typeMap[movement.type] || movement.type;
await StockAuditLog.create({
organizationId,
stockItemId: item._id,
movementId: movement._id,
movementNumber: movement.movementNumber,
userId,
userName,
action: 'UPDATE',
details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`,
oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes },
newValues: { date, quantity: newQuantitySigned, notes }
});
// Update Movement
movement.quantity = newQuantitySigned;
if (date) movement.date = date;
if (notes !== undefined) movement.notes = notes;
await movement.save();
res.json(movement);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error updating movement:', error);
res.status(500).json({ error: message });
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<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
const typeLabel = typeMap[movement.type] || movement.type;
await StockAuditLog.create({
organizationId,
stockItemId: item._id,
movementId: movement._id,
movementNumber: movement.movementNumber,
userId,
userName,
action: 'DELETE',
details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`,
oldValues: movement.toObject()
});
await StockMovement.deleteOne({ _id: id });
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 });
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
import pkg from 'pg';
const { Pool } = pkg;
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
dotenv.config();
const applySchema = async () => {
const connectionString = process.env.POSTGRES_URL;
if (!connectionString) {
console.error('POSTGRES_URL not defined');
process.exit(1);
}
const pool = new Pool({
connectionString,
ssl: connectionString.includes('supabase-db') ? false : { rejectUnauthorized: false }
});
try {
console.log('Applying schema to Supabase...');
const schemaPath = path.join(process.cwd(), 'src', 'server', 'scripts', 'final_postgres_schema.sql');
const sql = fs.readFileSync(schemaPath, 'utf8');
const client = await pool.connect();
try {
await client.query('CREATE SCHEMA IF NOT EXISTS gpi;');
await client.query('SET search_path TO gpi, public;');
await client.query(sql);
console.log('✅ Schema applied successfully!');
} finally {
client.release();
}
} catch (error) {
console.error('❌ Failed to apply schema:', error);
} finally {
await pool.end();
}
};
applySchema();

View File

@@ -0,0 +1,272 @@
-- Final Postgres Schema for GPI
-- Author: Antigravity
-- Date: 2026-03-24
CREATE SCHEMA IF NOT EXISTS gpi;
SET search_path TO gpi, public;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Organizations
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
is_banned BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Users
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clerk_id TEXT UNIQUE,
logto_id TEXT UNIQUE,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'guest',
is_banned BOOLEAN DEFAULT FALSE,
last_seen_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- User Organizations (Many-to-Many with Roles)
CREATE TABLE IF NOT EXISTS user_organizations (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'user',
is_banned BOOLEAN DEFAULT FALSE,
PRIMARY KEY (user_id, organization_id)
);
-- Technical Data Sheets (New)
CREATE TABLE IF NOT EXISTS technical_data_sheets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
manufacturer TEXT,
manufacturer_code TEXT,
type TEXT,
min_stock NUMERIC,
typical_application TEXT,
file_id UUID, -- References stored_files.id
file_url TEXT,
upload_date TIMESTAMPTZ DEFAULT NOW(),
solids_volume NUMERIC,
density NUMERIC,
mixing_ratio TEXT,
mixing_ratio_weight TEXT,
mixing_ratio_volume TEXT,
wft_min NUMERIC,
wft_max NUMERIC,
dft_min NUMERIC,
dft_max NUMERIC,
reducer TEXT,
yield_theoretical NUMERIC,
dft_reference NUMERIC,
yield_factor NUMERIC,
dilution NUMERIC,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Projects
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client TEXT NOT NULL,
start_date DATE,
end_date DATE,
technician TEXT,
environment TEXT,
weight_kg NUMERIC,
status TEXT CHECK (status IN ('active', 'archived')) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Parts
CREATE TABLE IF NOT EXISTS parts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
quantity INTEGER DEFAULT 1,
weight_kg NUMERIC,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Painting Schemes
CREATE TABLE IF NOT EXISTS painting_schemes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT,
coat TEXT,
color TEXT,
color_hex TEXT,
thinner_symbol TEXT,
eps_min NUMERIC,
eps_max NUMERIC,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Inspections
CREATE TABLE IF NOT EXISTS inspections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
part_id UUID REFERENCES parts(id) ON DELETE CASCADE,
inspector_id UUID REFERENCES users(id),
date DATE NOT NULL,
status TEXT,
weight_kg NUMERIC,
created_by TEXT, -- Logto user ID
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Stock Items (Update/Create)
CREATE TABLE IF NOT EXISTS stock_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
data_sheet_id UUID REFERENCES technical_data_sheets(id),
rr_number TEXT NOT NULL,
batch_number TEXT NOT NULL,
color TEXT,
invoice_number TEXT,
received_by TEXT,
quantity NUMERIC DEFAULT 0,
unit TEXT,
min_stock NUMERIC DEFAULT 0,
expiration_date DATE,
entry_date DATE DEFAULT CURRENT_DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Stock Movements
CREATE TABLE IF NOT EXISTS stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
stock_item_id UUID REFERENCES stock_items(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
type TEXT CHECK (type IN ('entry', 'exit', 'adjustment')),
quantity NUMERIC NOT NULL,
reason TEXT,
date TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Instruments
CREATE TABLE IF NOT EXISTS instruments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
serial_number TEXT,
calibration_date DATE,
status TEXT DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Application Records
CREATE TABLE IF NOT EXISTS application_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
coat_stage TEXT NOT NULL,
piece_description TEXT,
date DATE,
operator TEXT,
real_weight NUMERIC,
volume_used NUMERIC,
area_painted NUMERIC,
wet_thickness_avg NUMERIC,
dry_thickness_calc NUMERIC,
method TEXT,
diluent_used NUMERIC,
notes TEXT,
created_by TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Yield Studies
CREATE TABLE IF NOT EXISTS yield_studies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
data_sheet_id UUID REFERENCES technical_data_sheets(id) ON DELETE CASCADE,
name TEXT NOT NULL,
target_dft NUMERIC,
dilution_percent NUMERIC DEFAULT 0,
total_weight NUMERIC,
estimated_paint_volume NUMERIC,
estimated_reducer_volume NUMERIC,
estimated_paint_volume_by_area NUMERIC,
estimated_reducer_volume_by_area NUMERIC,
average_complexity NUMERIC,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Yield Study Categories (Sub-document representation)
CREATE TABLE IF NOT EXISTS yield_study_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
yield_study_id UUID REFERENCES yield_studies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
weight NUMERIC NOT NULL,
area NUMERIC,
historical_yield NUMERIC,
historical_dft NUMERIC,
efficiency NUMERIC
);
-- Geometry Types
CREATE TABLE IF NOT EXISTS geometry_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
complexity_factor NUMERIC DEFAULT 1.0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Stored Files
CREATE TABLE IF NOT EXISTS stored_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
data BYTEA NOT NULL,
size INTEGER NOT NULL,
upload_date TIMESTAMPTZ DEFAULT NOW()
);
-- Notifications
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
type TEXT,
title TEXT,
message TEXT,
read BOOLEAN DEFAULT FALSE,
is_archived BOOLEAN DEFAULT FALSE,
archived_by UUID[] DEFAULT '{}',
deleted_by UUID[] DEFAULT '{}',
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Migrations/Updates
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS is_archived BOOLEAN DEFAULT FALSE;
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS archived_by UUID[] DEFAULT '{}';
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS deleted_by UUID[] DEFAULT '{}';
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS metadata JSONB;

View File

@@ -1,72 +1,83 @@
import ApplicationRecord from '../models/ApplicationRecord.js';
import { query } from '../config/database.js';
import { snakeToCamel } from '../utils/mapper.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createApplicationRecord = async (data: any & { organizationId?: string, createdBy?: string }) => {
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;
};

View File

@@ -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<string> => {
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<string> => {
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];
};

View File

@@ -1,81 +1,97 @@
import Inspection from '../models/Inspection.js';
import { query } from '../config/database.js';
import { snakeToCamel } from '../utils/mapper.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createInspection = async (data: any & { organizationId?: string, createdBy?: string }) => {
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);
};

View File

@@ -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<INotification>) {
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<string, string>, graceDays: number = 30) {
try {
const graceDate = new Date();
graceDate.setDate(graceDate.getDate() - graceDays);
const query: Record<string, unknown> = {
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<string, unknown> = {
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} vence 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 = {
}
}
};

View File

@@ -1,72 +1,86 @@
import PaintingScheme from '../models/PaintingScheme.js';
import { query } from '../config/database.js';
import { snakeToCamel } from '../utils/mapper.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createPaintingScheme = async (data: any & { organizationId?: string }) => {
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);
};

View File

@@ -1,60 +1,98 @@
import Part from '../models/Part.js';
import { query } from '../config/database.js';
import { snakeToCamel } from '../utils/mapper.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createPart = async (data: any & { organizationId?: string }) => {
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);
};

View File

@@ -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<string, unknown> = 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<ProjectData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
const existing = await Project.findById(id);
const check = await query('SELECT organization_id FROM projects WHERE id = $1', [id]);
const existing = check?.rows[0];
if (!existing) return null;
// 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<ProjectData> & { 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]);
};

View File

@@ -0,0 +1,101 @@
import { query } from '../config/database.js';
import { snakeToCamel } from '../utils/mapper.js';
export const createStockItem = async (data: any & { organizationId?: string, createdBy?: string }) => {
const columns: string[] = [];
const values: any[] = [];
let i = 1;
Object.entries(data).forEach(([key, value]) => {
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
columns.push(sqlKey);
values.push(value);
});
const result = await query(
`INSERT INTO stock_items (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`,
values
);
return snakeToCamel(result?.rows[0]);
};
export const getStockItems = async (organizationId?: string) => {
let whereClause = '';
const params: any[] = [];
if (organizationId) {
whereClause = 'WHERE (organization_id = $1 OR organization_id IS NULL)';
params.push(organizationId);
}
const result = await query(`SELECT si.*, tds.name as data_sheet_name
FROM stock_items si
LEFT JOIN technical_data_sheets tds ON si.data_sheet_id = tds.id
${whereClause}
ORDER BY si.entry_date DESC`, params);
return (result?.rows || []).map(snakeToCamel);
};
export const updateStockItem = async (id: string, data: any, organizationId?: string) => {
const check = await query('SELECT organization_id FROM stock_items WHERE id = $1', [id]);
const existing = check?.rows[0];
if (!existing) return null;
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
return null;
}
const fields: string[] = [];
const params: any[] = [];
let i = 1;
Object.entries(data).forEach(([key, value]) => {
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
fields.push(`${sqlKey} = $${i++}`);
params.push(value);
});
params.push(id);
const result = await query(
`UPDATE stock_items SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`,
params
);
return snakeToCamel(result?.rows[0]);
};
export const deleteStockItem = async (id: string, organizationId?: string) => {
const check = await query('SELECT organization_id FROM stock_items WHERE id = $1', [id]);
const existing = check?.rows[0];
if (!existing) return;
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
return;
}
await query('DELETE FROM stock_items WHERE id = $1', [id]);
};
export const createStockMovement = async (data: any & { organizationId?: string, userId?: string }) => {
const result = await query(
`INSERT INTO stock_movements (organization_id, stock_item_id, user_id, type, quantity, reason, date)
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING *`,
[data.organizationId, data.stockItemId, data.userId, data.type, data.quantity, data.reason]
);
// Update stock item quantity
const multiplier = data.type === 'entry' ? 1 : -1;
await query('UPDATE stock_items SET quantity = quantity + $1 WHERE id = $2', [data.quantity * multiplier, data.stockItemId]);
return snakeToCamel(result?.rows[0]);
};
export const getMovementsByItem = async (stockItemId: string, organizationId?: string) => {
const result = await query(
`SELECT sm.*, u.name as user_name
FROM stock_movements sm
LEFT JOIN users u ON sm.user_id = u.id
WHERE sm.stock_item_id = $1 ${organizationId ? 'AND sm.organization_id = $2' : ''}
ORDER BY sm.date DESC`,
organizationId ? [stockItemId, organizationId] : [stockItemId]
);
return (result?.rows || []).map(snakeToCamel);
};

View File

@@ -1,56 +1,115 @@
import YieldStudy from '../models/YieldStudy.js';
import { query } from '../config/database.js';
import { snakeToCamel } from '../utils/mapper.js';
export const getAllStudies = async (organizationId?: string) => {
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;
};

View File

@@ -0,0 +1,34 @@
/**
* Utility to convert snake_case object keys to camelCase.
* Handles single objects and arrays or nested structures.
*/
export const snakeToCamel = (obj: any): any => {
if (Array.isArray(obj)) {
return obj.map(v => snakeToCamel(v));
} else if (obj !== null && obj.constructor === Object) {
return Object.keys(obj).reduce(
(result, key) => ({
...result,
[key.replace(/(_\w)/g, m => m[1].toUpperCase())]: snakeToCamel(obj[key]),
}),
{},
);
}
return obj;
};
/**
* Utility to convert camelCase object keys to snake_case.
*/
export const camelToSnake = (obj: any): any => {
if (Array.isArray(obj)) {
return obj.map(v => camelToSnake(v));
} else if (obj !== null && obj.constructor === Object) {
return Object.keys(obj).reduce((acc, key) => {
const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
acc[snakeKey] = camelToSnake(obj[key]);
return acc;
}, {} as any);
}
return obj;
};