diff --git a/api/app.ts b/api/app.ts deleted file mode 100644 index 37560d8..0000000 --- a/api/app.ts +++ /dev/null @@ -1,137 +0,0 @@ -import express from 'express'; -import cors from 'cors'; -import projectRoutes from '../src/server/routes/projectRoutes.js'; -import partRoutes from '../src/server/routes/partRoutes.js'; -import paintingSchemeRoutes from '../src/server/routes/paintingSchemeRoutes.js'; -import applicationRecordRoutes from '../src/server/routes/applicationRecordRoutes.js'; -import inspectionRoutes from '../src/server/routes/inspectionRoutes.js'; -import analysisRoutes from '../src/server/routes/analysisRoutes.js'; -import dataSheetRoutes from '../src/server/routes/dataSheetRoutes.js'; -import yieldStudyRoutes from '../src/server/routes/yieldStudyRoutes.js'; -import userRoutes from '../src/server/routes/userRoutes.js'; -import systemSettingsRoutes from '../src/server/routes/systemSettingsRoutes.js'; -import geometryTypeRoutes from '../src/server/routes/geometryTypeRoutes.js'; -import stockRoutes from '../src/server/routes/stockRoutes.js'; -import authRoutes from '../src/server/routes/authRoutes.js'; -import notificationRoutes from '../src/server/routes/notificationRoutes.js'; -import instrumentRoutes from '../src/server/routes/instrumentRoutes.js'; -import { extractUser } from '../src/server/middleware/roleMiddleware.js'; -import path from 'path'; - -const app = express(); - -app.use(cors({ - origin: '*', // Be more specific in production - methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'x-organization-id', 'x-organization-name'] -})); -app.use(express.json()); - -// Global Middleware -import { authMiddleware } from '../src/server/middleware/auth.js'; -app.use(authMiddleware); -app.use(extractUser); - -// Static Uploads -app.use('/uploads', express.static(path.join(process.cwd(), 'uploads'))); - -import pool from '../src/server/config/postgres.js'; - -// ... (existing routes) - -app.get('/api/admin/migrate-to-gpi', async (req, res) => { - const TABLES = [ - "organizations", - "users", - "user_organizations", - "projects", - "parts", - "technical_data_sheets", - "painting_schemes", - "inspections", - "instruments", - "stock_items", - "stock_movements", - "application_records", - "application_record_items" - ]; - - try { - const client = await pool.connect(); - await client.query("CREATE SCHEMA IF NOT EXISTS gpi;"); - - const results = []; - for (const table of TABLES) { - try { - // Try to move from public to gpi - await client.query(`ALTER TABLE public.${table} SET SCHEMA gpi;`); - results.push({ table, action: 'moved', status: 'success' }); - } catch (err: any) { - // If it fails, maybe it's already in gpi? - try { - await client.query(`SELECT 1 FROM gpi.${table} LIMIT 1`); - results.push({ table, action: 'check', status: 'already_in_gpi' }); - } catch (checkErr: any) { - results.push({ table, action: 'move', status: 'failed', error: err.message }); - } - } - } - - // Ensure new tables exist - await client.query(` - CREATE TABLE IF NOT EXISTS gpi.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, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() - ); - `); - results.push({ table: 'files', action: 'create', status: 'success_or_exists' }); - - await client.query(` - CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - organization_id UUID, - stock_item_id UUID, - movement_id UUID, - movement_number INTEGER, - user_id TEXT, - user_name TEXT, - action TEXT, - details TEXT, - old_values JSONB, - new_values JSONB, - timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW() - ); - `); - results.push({ table: 'stock_audit_logs', action: 'create', status: 'success_or_exists' }); - client.release(); - res.json({ message: "Migration check completed", results }); - } catch (error: any) { - res.status(500).json({ error: error.message }); - } -}); - -app.use('/api/auth', authRoutes); -app.use('/api/users', userRoutes); -app.use('/api/projects', projectRoutes); -app.use('/api/parts', partRoutes); -app.use('/api/painting-schemes', paintingSchemeRoutes); -app.use('/api/application-records', applicationRecordRoutes); -app.use('/api/inspections', inspectionRoutes); -app.use('/api', analysisRoutes); -app.use('/api/datasheets', dataSheetRoutes); -app.use('/api/yield-studies', yieldStudyRoutes); -app.use('/api/system-settings', systemSettingsRoutes); -app.use('/api/geometry-types', geometryTypeRoutes); -app.use('/api/stock', stockRoutes); -app.use('/api/notifications', notificationRoutes); -app.use('/api/instruments', instrumentRoutes); - -app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date() }); -}); - -export default app; diff --git a/api/db-test.ts b/api/db-test.ts deleted file mode 100644 index ae89ef7..0000000 --- a/api/db-test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node'; -import mongoose from 'mongoose'; - -export default async function handler(req: VercelRequest, res: VercelResponse) { - try { - const uri = process.env.MONGODB_URI; - if (!uri) throw new Error('MONGODB_URI is missing from Vercel settings'); - - await mongoose.connect(uri); - const state = mongoose.connection.readyState; - await mongoose.disconnect(); - - res.json({ - success: true, - message: 'MongoDB Connection verified!', - state: state === 1 ? 'Connected' : state - }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - const stack = error instanceof Error ? error.stack : undefined; - res.status(500).json({ - success: false, - error: message, - stack: stack - }); - } -} diff --git a/api/index.ts b/api/index.ts deleted file mode 100644 index 56349df..0000000 --- a/api/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node'; -import app from './app.js'; -import mongoose from 'mongoose'; - -export default async function handler(req: VercelRequest, res: VercelResponse) { - try { - console.log('--- API CALL:', req.url); - - // Inline connection to avoid external file dependency issues during boot - if (mongoose.connection.readyState !== 1) { - const uri = process.env.MONGODB_URI; - if (!uri) throw new Error('MONGODB_URI environment variable is missing'); - await mongoose.connect(uri); - } - - // Use the localized app.js - return app(req, res); - } catch (error: unknown) { - console.error('SERVERLESS BOOT ERROR:', error); - const message = error instanceof Error ? error.message : 'Unknown error'; - return res.status(500).json({ - error: 'Serverless Boot Error', - message: message, - path: req.url, - suggestion: 'Check Vercel Logs for module resolution errors' - }); - } -} diff --git a/api/ping.ts b/api/ping.ts deleted file mode 100644 index 8b003df..0000000 --- a/api/ping.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node'; - -export default function handler(req: VercelRequest, res: VercelResponse) { - res.json({ - status: 'ok', - message: 'Vercel API is alive', - time: new Date().toISOString(), - env_check: process.env.MONGODB_URI ? 'URI present' : 'URI MISSING' - }); -} diff --git a/netlify/functions/api.ts b/netlify/functions/api.ts deleted file mode 100644 index 743939e..0000000 --- a/netlify/functions/api.ts +++ /dev/null @@ -1,72 +0,0 @@ -console.log('Loading Netlify Function...'); -import serverless from 'serverless-http'; -import express from 'express'; -import cors from 'cors'; -import mongoose from 'mongoose'; - -// Import local database connection that sets up GridFS/Bucket -import { connectDB } from '../../src/server/config/database.js'; - -// Static imports for routes to ensure esbuild bundles them correctly -import projectRoutes from '../../src/server/routes/projectRoutes.js'; -import partRoutes from '../../src/server/routes/partRoutes.js'; -import paintingSchemeRoutes from '../../src/server/routes/paintingSchemeRoutes.js'; -import applicationRecordRoutes from '../../src/server/routes/applicationRecordRoutes.js'; -import inspectionRoutes from '../../src/server/routes/inspectionRoutes.js'; -import analysisRoutes from '../../src/server/routes/analysisRoutes.js'; -import dataSheetRoutes from '../../src/server/routes/dataSheetRoutes.js'; -import yieldStudyRoutes from '../../src/server/routes/yieldStudyRoutes.js'; -import userRoutes from '../../src/server/routes/userRoutes.js'; -import uploadsRoutes from '../../src/server/routes/uploadsRoutes.js'; -import systemSettingsRoutes from '../../src/server/routes/systemSettingsRoutes.js'; -import geometryTypeRoutes from '../../src/server/routes/geometryTypeRoutes.js'; - -const app = express(); -app.use(cors({ - origin: '*', - methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'x-clerk-user-id', 'x-organization-id'] -})); -app.use(express.json()); - -// Routes -// We register them immediately since we use serverless-http's cold start handling -app.use('/api/users', userRoutes); -app.use('/api/projects', projectRoutes); -app.use('/api/parts', partRoutes); -app.use('/api/painting-schemes', paintingSchemeRoutes); -app.use('/api/application-records', applicationRecordRoutes); -app.use('/api/inspections', inspectionRoutes); -app.use('/api', analysisRoutes); -app.use('/api/datasheets', dataSheetRoutes); -app.use('/api/yield-studies', yieldStudyRoutes); -app.use('/api/system-settings', systemSettingsRoutes); -app.use('/api/geometry-types', geometryTypeRoutes); - -// Serve uploads (from /tmp in serverless or local dir) -app.use('/uploads', uploadsRoutes); - -// Simple test endpoint -app.get('/api/health', (req, res) => { - res.json({ - status: 'ok', - timestamp: new Date(), - mongoState: mongoose.connection.readyState - }); -}); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const handler = async (event: any, context: any) => { - context.callbackWaitsForEmptyEventLoop = false; - - // Ensure DB is connected before processing request - try { - await connectDB(); - } catch (e) { - console.error('DB Connection Failed:', e); - // We let it proceed, maybe it's a health check or will fail gracefully later - } - - const httpHandler = serverless(app); - return await httpHandler(event, context); -}; diff --git a/package.json b/package.json index 6168e31..5141b7e 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,7 @@ }, "dependencies": { "@tailwindcss/postcss": "^4.1.18", - "@types/mongoose": "^5.11.96", "@types/uuid": "^10.0.0", - "@vercel/speed-insights": "^1.3.1", "axios": "^1.13.2", "bcryptjs": "^3.0.3", "clsx": "^2.1.1", @@ -28,8 +26,6 @@ "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "lucide-react": "^0.562.0", - "mongodb": "^7.0.0", - "mongoose": "^9.1.5", "multer": "^2.0.2", "pdf-parse": "^1.1.1", "pg": "^8.20.0", @@ -40,7 +36,6 @@ "react-router-dom": "^7.12.0", "recharts": "^3.7.0", "search-web": "^1.0.3", - "serverless-http": "^4.0.0", "tailwind-merge": "^3.4.0", "tesseract.js": "^7.0.0", "uuid": "^13.0.0" @@ -56,7 +51,6 @@ "@types/pg": "^8.18.0", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", - "@vercel/node": "^5.5.28", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.23", "concurrently": "^9.1.2", diff --git a/src/server/app.ts b/src/server/app.ts index 795f32e..f970589 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -51,34 +51,6 @@ if (!fs.existsSync(uploadsPath)) { app.use('/uploads', express.static(uploadsPath)); -// Migration Route (Temporary) -import pool from './config/postgres.js'; -app.get('/api/admin/migrate-schema', async (req, res) => { - const TABLES = [ - "organizations", "users", "user_organizations", "projects", - "parts", "painting_schemes", "inspections", "instruments", - "stock_items", "stock_movements" - ]; - try { - const client = await pool.connect(); - await client.query("CREATE SCHEMA IF NOT EXISTS gpi;"); - const results = []; - for (const table of TABLES) { - try { - // Try to move from public to gpi - await client.query(`ALTER TABLE public."${table}" SET SCHEMA gpi;`); - results.push({ table, status: 'moved to gpi' }); - } catch (err: any) { - results.push({ table, status: 'error', error: err.message }); - } - } - client.release(); - res.json({ message: "Schema migration finished", results }); - } catch (err: any) { - res.status(500).json({ error: err.message }); - } -}); - // Routes app.use('/api/auth', authRoutes); app.use('/api/users', userRoutes); diff --git a/src/server/controllers/authController.ts b/src/server/controllers/authController.ts index 20fa20d..fe0fd09 100644 --- a/src/server/controllers/authController.ts +++ b/src/server/controllers/authController.ts @@ -1,8 +1,7 @@ import { Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; -import User, { IUser } from '../models/User.js'; -import { IAppUser } from '../middleware/roleMiddleware.js'; +import { query } from '../config/database.js'; import { v4 as uuidv4 } from 'uuid'; const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod'; @@ -16,8 +15,8 @@ export const register = async (req: Request, res: Response): Promise => { return; } - const existingUser = await User.findOne({ email }); - if (existingUser) { + const existingRes = await query('SELECT id FROM gpi.users WHERE email = $1', [email]); + if (existingRes.rows.length > 0) { res.status(400).json({ error: 'Email já cadastrado' }); return; } @@ -25,22 +24,26 @@ export const register = async (req: Request, res: Response): Promise => { const salt = await bcrypt.genSalt(10); const passwordHash = await bcrypt.hash(password, salt); - // Gere um externalId falso apenas para manter retrocompatibilidade no banco + // Gere um fakeAuthId para manter compatibilidade com sistemas que esperam um externalId const fakeAuthId = `user_${uuidv4().replace(/-/g, '')}`; - const newUser = new User({ - name, - email, - passwordHash, - externalId: fakeAuthId, - role: 'member', - isBanned: false - }); + const insertRes = await query( + `INSERT INTO gpi.users (name, email, clerk_id, role, is_banned, updated_at) + VALUES ($1, $2, $3, 'user', false, NOW()) RETURNING *`, + [name, email, fakeAuthId] + ); + + const newUser = insertRes.rows[0]; - await newUser.save(); + // Se houver uma coluna de password_hash no banco (precisamos garantir que exista) + try { + await query('UPDATE gpi.users SET password_hash = $1 WHERE id = $2', [passwordHash, newUser.id]); + } catch (e) { + console.warn('Could not update password_hash, check if column exists', e); + } const token = jwt.sign( - { userId: newUser._id.toString(), externalId: newUser.externalId, role: newUser.role, organizationId: newUser.organizationId }, + { userId: newUser.id, externalId: newUser.clerk_id, role: newUser.role }, JWT_SECRET, { expiresIn: '7d' } ); @@ -48,7 +51,7 @@ export const register = async (req: Request, res: Response): Promise => { res.status(201).json({ message: 'Usuário criado com sucesso', token, - user: { id: newUser._id, name: newUser.name, email: newUser.email, role: newUser.role, externalId: newUser.externalId } + user: { id: newUser.id, name: newUser.name, email: newUser.email, role: newUser.role, externalId: newUser.clerk_id } }); } catch (error) { console.error('Register Error:', error); @@ -65,25 +68,29 @@ export const login = async (req: Request, res: Response): Promise => { return; } - const user = await User.findOne({ email }); + const userRes = await query('SELECT * FROM gpi.users WHERE email = $1', [email]); + const user = userRes.rows[0]; + if (!user) { res.status(400).json({ error: 'Usuário não encontrado' }); return; } - if (!user.passwordHash) { - res.status(400).json({ error: 'Usuário do sistema antigo. Por favor, solicite a redefinição de senha ou recrie sua conta se possível.' }); + // Recuperar password_hash de algum lugar se não estiver no select principal (alguns schemas escondem) + // No nosso caso a query SELECT * deve trazer se existir. + if (!user.password_hash) { + res.status(400).json({ error: 'Usuário sem senha definida ou método de login externo.' }); return; } - const isMatch = await bcrypt.compare(password, user.passwordHash); + const isMatch = await bcrypt.compare(password, user.password_hash); if (!isMatch) { res.status(400).json({ error: 'Credenciais inválidas' }); return; } const token = jwt.sign( - { userId: user._id.toString(), externalId: user.externalId, role: user.role, organizationId: user.organizationId }, + { userId: user.id, externalId: user.clerk_id || user.logto_id, role: user.role }, JWT_SECRET, { expiresIn: '7d' } ); @@ -92,12 +99,11 @@ export const login = async (req: Request, res: Response): Promise => { message: 'Login realizado com sucesso', token, user: { - id: user._id, + id: user.id, name: user.name, email: user.email, role: user.role, - externalId: user.externalId, - organizationId: user.organizationId + externalId: user.clerk_id || user.logto_id } }); } catch (error) { @@ -108,20 +114,12 @@ export const login = async (req: Request, res: Response): Promise => { export const getMe = async (req: Request, res: Response): Promise => { try { - // O usuário é extraído pelo middleware extractUser e colocado em req.appUser if (!req.appUser) { res.status(401).json({ error: 'Não autorizado' }); return; } - res.status(200).json({ - id: req.appUser._id, - name: req.appUser.name, - email: req.appUser.email, - role: req.appUser.role, - externalId: req.appUser.externalId, - organizationId: req.appUser.organizationId - }); + res.status(200).json(req.appUser); } catch (error) { console.error('GetMe Error:', error); res.status(500).json({ error: 'Erro no servidor' }); diff --git a/src/server/controllers/geometryTypeController.ts b/src/server/controllers/geometryTypeController.ts index 577367c..e6c844a 100644 --- a/src/server/controllers/geometryTypeController.ts +++ b/src/server/controllers/geometryTypeController.ts @@ -1,10 +1,5 @@ import { Request, Response } from 'express'; -import GeometryType from '../models/GeometryType.js'; -import { IAppUser } from '../middleware/roleMiddleware.js'; - -interface AuthRequest extends Request { - appUser?: IAppUser; -} +import { query } from '../config/database.js'; // Default geometry types to seed if none exist const DEFAULT_TYPES = [ @@ -22,139 +17,110 @@ const DEFAULT_TYPES = [ { name: 'Peças diversas (outras)', efficiencyLoss: 20 } ]; -export const getAllnames = async (req: AuthRequest, res: Response) => { +export const getAllnames = async (req: Request, res: Response) => { try { const organizationId = req.appUser?.organizationId; const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com'; - console.log(`[GeometryType] Fetching for org: ${organizationId}, globalAdmin: ${isGlobalAdmin}`); if (!organizationId && !isGlobalAdmin) { return res.status(400).json({ error: 'Organization ID missing' }); } - // Search for org-specific types OR orphan types (legacy) - const query = isGlobalAdmin - ? {} - : { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }; + let sql = 'SELECT * FROM gpi.geometry_types'; + const params: any[] = []; - let types = await GeometryType.find(query).sort({ name: 1 }); + if (!isGlobalAdmin) { + sql += ' WHERE organization_id = $1'; + params.push(organizationId); + } - // Auto-seed if empty AND we HAVE an organization (don't seed for global view) - if (types.length === 0 && organizationId) { - console.log(`[GeometryType] No types found. Seeding defaults...`); - try { - const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId })); - types = await GeometryType.insertMany(seedData) as any; - console.log(`[GeometryType] Seeded ${types.length} types successfully.`); - } catch (seedError) { - console.error('[GeometryType] Seeding failed:', seedError); - return res.json([]); + sql += ' ORDER BY name ASC'; + const result = await query(sql, params); + let types = result.rows; + + // Auto-seed if empty for org + if (types.length === 0 && organizationId && !isGlobalAdmin) { + for (const t of DEFAULT_TYPES) { + await query( + 'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', + [organizationId, t.name, t.efficiencyLoss] + ); } + const reFetch = await query('SELECT * FROM gpi.geometry_types WHERE organization_id = $1 ORDER BY name ASC', [organizationId]); + types = reFetch.rows; } res.json(types); - } catch (error: unknown) { - console.error('[GeometryType] Error in getAllnames:', error); - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + } catch (error: any) { + console.error('[GeometryType] Error:', error); + res.status(500).json({ error: error.message }); } }; -export const restoreDefaults = async (req: AuthRequest, res: Response) => { +export const restoreDefaults = async (req: Request, res: Response) => { try { const organizationId = req.appUser?.organizationId; - if (!organizationId) { - return res.status(400).json({ error: 'Organization ID missing' }); + if (!organizationId) return res.status(400).json({ error: 'Organization ID missing' }); + + await query('DELETE FROM gpi.geometry_types WHERE organization_id = $1', [organizationId]); + + for (const t of DEFAULT_TYPES) { + await query( + 'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3)', + [organizationId, t.name, t.efficiencyLoss] + ); } - // Delete all existing types for this org - await GeometryType.deleteMany({ organizationId }); - - // Insert defaults - const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId })); - const types = await GeometryType.insertMany(seedData); - - res.json(types); - } catch (error: unknown) { - console.error('[GeometryType] Error in restoreDefaults:', error); - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + const result = await query('SELECT * FROM gpi.geometry_types WHERE organization_id = $1 ORDER BY name ASC', [organizationId]); + res.json(result.rows); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; -export const createType = async (req: AuthRequest, res: Response) => { +export const createType = async (req: Request, res: Response) => { try { const organizationId = req.appUser?.organizationId; const { name, efficiencyLoss } = req.body; - if (!name) { - return res.status(400).json({ error: 'Name is required' }); - } - - const newType = new GeometryType({ - name, - efficiencyLoss: Number(efficiencyLoss) || 0, - organizationId - }); - - const saved = await newType.save(); - res.status(201).json(saved); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - if (message.includes('E11000')) { - return res.status(409).json({ error: 'A geometry type with this name already exists' }); - } - res.status(500).json({ error: message }); + const result = await query( + 'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3) RETURNING *', + [organizationId, name, Number(efficiencyLoss) || 0] + ); + res.status(201).json(result.rows[0]); + } catch (error: any) { + if (error.code === '23505') return res.status(409).json({ error: 'Already exists' }); + res.status(500).json({ error: error.message }); } }; -export const updateType = async (req: AuthRequest, res: Response) => { +export const updateType = async (req: Request, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; - const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com'; const { name, efficiencyLoss } = req.body; - const query = isGlobalAdmin - ? { _id: id } - : { _id: id, organizationId }; - - const updated = await GeometryType.findOneAndUpdate( - query, - { name, efficiencyLoss: Number(efficiencyLoss) }, - { new: true } + const result = await query( + 'UPDATE gpi.geometry_types SET name = $1, efficiency_loss = $2, updated_at = NOW() WHERE id = $3 AND organization_id = $4 RETURNING *', + [name, Number(efficiencyLoss), id, organizationId] ); - if (!updated) { - return res.status(404).json({ error: 'Record not found' }); - } - - res.json(updated); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; -export const deleteType = async (req: AuthRequest, res: Response) => { +export const deleteType = async (req: Request, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; - const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com'; - - const query = isGlobalAdmin - ? { _id: id } - : { _id: id, organizationId }; - - const deleted = await GeometryType.findOneAndDelete(query); - - if (!deleted) { - return res.status(404).json({ error: 'Record not found' }); - } + const result = await query('DELETE FROM gpi.geometry_types WHERE id = $1 AND organization_id = $2', [id, organizationId]); + if (result.rowCount === 0) return res.status(404).json({ error: 'Not found' }); res.status(204).send(); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - res.status(500).json({ error: message }); + } catch (error: any) { + res.status(500).json({ error: error.message }); } }; diff --git a/src/server/controllers/messageController.ts b/src/server/controllers/messageController.ts index 0c7ec25..b361253 100644 --- a/src/server/controllers/messageController.ts +++ b/src/server/controllers/messageController.ts @@ -1,6 +1,5 @@ import { Request, Response } from 'express'; -import Message from '../models/Message.js'; -import OrganizationMember from '../models/OrganizationMember.js'; +import { query } from '../config/database.js'; // Send a message export const sendMessage = async (req: Request, res: Response) => { @@ -21,36 +20,27 @@ export const sendMessage = async (req: Request, res: Response) => { return res.status(400).json({ error: 'Destinatário e mensagem são obrigatórios.' }); } - if (message.length > 255) { - return res.status(400).json({ error: 'Mensagem muito longa (máximo 255 caracteres).' }); - } - // Check if there's already a pending (unread) message from this user to that user - const existingMessage = await Message.findOne({ - organizationId, - fromUserId, - toUserId, - isRead: false, - }); + const existingRes = await query( + 'SELECT * FROM gpi.messages WHERE organization_id = $1 AND from_user_id = $2 AND to_user_id = $3 AND is_read = false', + [organizationId, fromUserId, toUserId] + ); - if (existingMessage) { - // Update existing message instead of creating a new one - existingMessage.message = message; - existingMessage.updatedAt = new Date(); - await existingMessage.save(); - return res.json(existingMessage); + if (existingRes.rows.length > 0) { + const updatedRes = await query( + 'UPDATE gpi.messages SET message = $1, updated_at = NOW() WHERE id = $2 RETURNING *', + [message, existingRes.rows[0].id] + ); + return res.json(updatedRes.rows[0]); } - // Create new message - const newMessage = new Message({ - organizationId, - fromUserId, - toUserId, - message, - }); + const insertRes = await query( + `INSERT INTO gpi.messages (organization_id, from_user_id, to_user_id, message) + VALUES ($1, $2, $3, $4) RETURNING *`, + [organizationId, fromUserId, toUserId, message] + ); - await newMessage.save(); - res.status(201).json(newMessage); + res.status(201).json(insertRes.rows[0]); } catch (error) { console.error('Error sending message:', error); res.status(500).json({ error: 'Erro ao enviar mensagem.' }); @@ -63,34 +53,25 @@ export const getUnreadMessages = async (req: Request, res: Response) => { const toUserId = req.appUser?.externalId; const organizationId = req.headers['x-organization-id'] as string; - if (!organizationId) { - return res.status(400).json({ error: 'Organização não selecionada.' }); + if (!organizationId || !toUserId) { + return res.status(400).json({ error: 'Contexto incompleto.' }); } - if (!toUserId) { - return res.status(401).json({ error: 'Usuário não autenticado.' }); - } + const sql = ` + SELECT m.*, u.name as "fromUserName", u.email as "fromUserEmail" + FROM gpi.messages m + LEFT JOIN gpi.users u ON m.from_user_id = u.clerk_id OR m.from_user_id = u.logto_id + WHERE m.organization_id = $1 AND m.to_user_id = $2 AND m.is_read = false AND m.is_archived = false AND m.is_deleted_by_recipient = false + ORDER BY m.created_at DESC + `; + const result = await query(sql, [organizationId, toUserId]); + + const messages = result.rows.map(m => ({ + ...m, + fromUser: { name: m.fromUserName, email: m.fromUserEmail } + })); - const messages = await Message.find({ - organizationId, - toUserId, - isRead: false, - isArchived: false, - isDeletedByRecipient: false, - }).sort({ createdAt: -1 }); - - // Populate sender info - const messagesWithSender = await Promise.all( - messages.map(async (msg) => { - const sender = await OrganizationMember.findOne({ userId: msg.fromUserId }); - return { - ...msg.toObject(), - fromUser: sender ? { name: sender.name, email: sender.email } : null, - }; - }) - ); - - res.json(messagesWithSender); + res.json(messages); } catch (error) { console.error('Error getting unread messages:', error); res.status(500).json({ error: 'Erro ao buscar mensagens.' }); @@ -104,29 +85,16 @@ export const markMessageAsRead = async (req: Request, res: Response) => { const userId = req.appUser?.externalId; const organizationId = req.headers['x-organization-id'] as string; - if (!organizationId) { - return res.status(400).json({ error: 'Organização não selecionada.' }); - } + const result = await query( + 'UPDATE gpi.messages SET is_read = true, read_at = NOW() WHERE id = $1 AND organization_id = $2 AND to_user_id = $3 RETURNING *', + [id, organizationId, userId] + ); - if (!userId) { - return res.status(401).json({ error: 'Usuário não autenticado.' }); - } - - const message = await Message.findOne({ - _id: id, - organizationId, - toUserId: userId, - }); - - if (!message) { + if (result.rows.length === 0) { return res.status(404).json({ error: 'Mensagem não encontrada.' }); } - message.isRead = true; - message.readAt = new Date(); - await message.save(); - - res.json(message); + res.json(result.rows[0]); } catch (error) { console.error('Error marking message as read:', error); res.status(500).json({ error: 'Erro ao marcar mensagem como lida.' }); @@ -139,32 +107,25 @@ export const getMyPendingMessages = async (req: Request, res: Response) => { const fromUserId = req.appUser?.externalId; const organizationId = req.headers['x-organization-id'] as string; - if (!organizationId) { - return res.status(400).json({ error: 'Organização não selecionada.' }); + if (!organizationId || !fromUserId) { + return res.status(400).json({ error: 'Contexto incompleto.' }); } - if (!fromUserId) { - return res.status(401).json({ error: 'Usuário não autenticado.' }); - } + const sql = ` + SELECT m.*, u.name as "toUserName", u.email as "toUserEmail" + FROM gpi.messages m + LEFT JOIN gpi.users u ON m.to_user_id = u.clerk_id OR m.to_user_id = u.logto_id + WHERE m.organization_id = $1 AND m.from_user_id = $2 AND m.is_read = false + ORDER BY m.created_at DESC + `; + const result = await query(sql, [organizationId, fromUserId]); + + const messages = result.rows.map(m => ({ + ...m, + toUser: { name: m.toUserName, email: m.toUserEmail } + })); - const messages = await Message.find({ - organizationId, - fromUserId, - isRead: false, - }).sort({ createdAt: -1 }); - - // Populate recipient info - const messagesWithRecipient = await Promise.all( - messages.map(async (msg) => { - const recipient = await OrganizationMember.findOne({ userId: msg.toUserId }); - return { - ...msg.toObject(), - toUser: recipient ? { name: recipient.name, email: recipient.email } : null, - }; - }) - ); - - res.json(messagesWithRecipient); + res.json(messages); } catch (error) { console.error('Error getting pending messages:', error); res.status(500).json({ error: 'Erro ao buscar mensagens pendentes.' }); @@ -178,26 +139,15 @@ export const deleteMessage = async (req: Request, res: Response) => { const userId = req.appUser?.externalId; const organizationId = req.headers['x-organization-id'] as string; - if (!organizationId) { - return res.status(400).json({ error: 'Organização não selecionada.' }); + const result = await query( + 'DELETE FROM gpi.messages WHERE id = $1 AND from_user_id = $2 AND organization_id = $3 AND is_read = false', + [id, userId, organizationId] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Mensagem não encontrada ou já lida.' }); } - if (!userId) { - return res.status(401).json({ error: 'Usuário não autenticado.' }); - } - - const message = await Message.findOne({ - _id: id, - organizationId, - fromUserId: userId, - isRead: false, // Can only delete unread messages - }); - - if (!message) { - return res.status(404).json({ error: 'Mensagem não encontrada ou já foi lida.' }); - } - - await message.deleteOne(); res.json({ message: 'Mensagem deletada com sucesso.' }); } catch (error) { console.error('Error deleting message:', error); @@ -205,20 +155,21 @@ export const deleteMessage = async (req: Request, res: Response) => { } }; -// Recipient deletes/archives a message +// Recipient archives a message export const archiveMessage = async (req: Request, res: Response) => { try { const { id } = req.params; const userId = req.appUser?.externalId; const organizationId = req.headers['x-organization-id'] as string; - const message = await Message.findOne({ _id: id, toUserId: userId, organizationId }); - if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' }); + const result = await query( + 'UPDATE gpi.messages SET is_archived = true, is_read = true WHERE id = $1 AND to_user_id = $2 AND organization_id = $3 RETURNING *', + [id, userId, organizationId] + ); - message.isArchived = true; - message.isRead = true; // Arquivar implica ler - await message.save(); - res.json(message); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mensagem não encontrada.' }); + + res.json(result.rows[0]); } catch (error) { console.error('Error archiving message:', error); res.status(500).json({ error: 'Erro ao arquivar mensagem.' }); @@ -231,11 +182,13 @@ export const recipientDeleteMessage = async (req: Request, res: Response) => { const userId = req.appUser?.externalId; const organizationId = req.headers['x-organization-id'] as string; - const message = await Message.findOne({ _id: id, toUserId: userId, organizationId }); - if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' }); + const result = await query( + 'UPDATE gpi.messages SET is_deleted_by_recipient = true WHERE id = $1 AND to_user_id = $2 AND organization_id = $3 RETURNING *', + [id, userId, organizationId] + ); + + if (result.rows.length === 0) return res.status(404).json({ error: 'Mensagem não encontrada.' }); - message.isDeletedByRecipient = true; - await message.save(); res.json({ message: 'Mensagem excluída com sucesso.' }); } catch (error) { console.error('Error deleting message:', error); diff --git a/src/server/controllers/systemSettingsController.ts b/src/server/controllers/systemSettingsController.ts index 3c56266..e1ad656 100644 --- a/src/server/controllers/systemSettingsController.ts +++ b/src/server/controllers/systemSettingsController.ts @@ -1,23 +1,20 @@ import { Request, Response } from 'express'; -import SystemSettings from '../models/SystemSettings.js'; -import User from '../models/User.js'; -import OrganizationMember from '../models/OrganizationMember.js'; -import Organization from '../models/Organization.js'; +import { query } from '../config/database.js'; import path from 'path'; import fs from 'fs'; import os from 'os'; export const getSettings = async (req: Request, res: Response) => { try { - let settings = await SystemSettings.findOne({ settingsId: 'global' }); + const resSettings = await query('SELECT * FROM gpi.system_settings WHERE settings_id = $1', ['global']); + let settings = resSettings.rows[0]; if (!settings) { - // Create default if not exists - settings = await SystemSettings.create({ - settingsId: 'global', - appName: 'GPI', - appSubtitle: 'Gestão de Pintura Industrial' - }); + const insertRes = await query( + 'INSERT INTO gpi.system_settings (settings_id, app_name, app_subtitle) VALUES ($1, $2, $3) RETURNING *', + ['global', 'GPI', 'Gestão de Pintura Industrial'] + ); + settings = insertRes.rows[0]; } res.json(settings); @@ -31,41 +28,34 @@ export const updateSettings = async (req: Request, res: Response) => { try { const { appName, appSubtitle, appLogoUrl } = req.body; - const settings = await SystemSettings.findOneAndUpdate( - { settingsId: 'global' }, - { - appName, - appSubtitle, - appLogoUrl, - updatedBy: req.appUser?.email - }, - { new: true, upsert: true } // Create if not exists + const result = await query( + `INSERT INTO gpi.system_settings (settings_id, app_name, app_subtitle, app_logo_url, updated_by, updated_at) + VALUES ('global', $1, $2, $3, $4, NOW()) + ON CONFLICT (settings_id) DO UPDATE SET + app_name = EXCLUDED.app_name, + app_subtitle = EXCLUDED.app_subtitle, + app_logo_url = EXCLUDED.app_logo_url, + updated_by = EXCLUDED.updated_by, + updated_at = NOW() + RETURNING *`, + [appName, appSubtitle, appLogoUrl, req.appUser?.email] ); - console.log(`⚙️ System Settings updated by ${req.appUser?.email}`); - res.json(settings); + res.json(result.rows[0]); } catch (error) { console.error('Error updating system settings:', error); res.status(500).json({ error: 'Erro ao atualizar configurações do sistema' }); } }; - export const serveLogo = async (req: Request, res: Response) => { try { const { filename } = req.params as { filename: string }; - - // Check tmp dir first (Serverless/Netlify uploads) - const tmpPath = path.join(os.tmpdir(), 'uploads', filename); - // Check local dir (Development) const localPath = path.join(process.cwd(), 'uploads', filename); - if (fs.existsSync(tmpPath)) { - res.sendFile(tmpPath); - } else if (fs.existsSync(localPath)) { + if (fs.existsSync(localPath)) { res.sendFile(localPath); } else { - console.error(`Logo file not found in tmp or local: ${filename}`); res.status(404).json({ error: 'Imagem não encontrada' }); } } catch (error) { @@ -79,11 +69,7 @@ export const uploadLogo = async (req: Request, res: Response) => { if (!req.file) { return res.status(400).json({ error: 'Nenhum arquivo enviado.' }); } - - // Return the API URL instead of static path - // This ensures requests go through /api proxy and we control serving const fileUrl = `/api/system-settings/logo-image/${req.file.filename}`; - res.json({ url: fileUrl }); } catch (error) { console.error('Error uploading logo:', error); @@ -91,11 +77,10 @@ export const uploadLogo = async (req: Request, res: Response) => { } }; -// Global Admin Functions export const getGlobalUsers = async (req: Request, res: Response) => { try { - const users = await User.find({}).sort({ createdAt: -1 }); - res.json(users); + const resUsers = await query('SELECT * FROM gpi.users ORDER BY created_at DESC'); + res.json(resUsers.rows); } catch (error) { console.error('Error getting global users:', error); res.status(500).json({ error: 'Erro ao buscar usuários globais.' }); @@ -104,51 +89,20 @@ export const getGlobalUsers = async (req: Request, res: Response) => { export const getGlobalOrganizations = async (req: Request, res: Response) => { try { - // Aggregate members to group by org and get full member lists - const organizations = await OrganizationMember.aggregate([ - { - $group: { - _id: '$organizationId', - members: { - $push: { - name: '$name', - email: '$email', - role: '$role', - userId: '$userId', - isBanned: '$isBanned' - } - }, - lastActive: { $max: '$updatedAt' } - } - }, - { - $lookup: { - from: 'organizations', // Ensure this matches the collection name of Organization model - localField: '_id', - foreignField: 'externalId', - as: 'orgDetails' - } - }, - { - $unwind: { - path: '$orgDetails', - preserveNullAndEmptyArrays: true - } - }, - { - $project: { - _id: 1, - lastActive: 1, - members: 1, - memberCount: { $size: '$members' }, - isBanned: { $ifNull: ['$orgDetails.isBanned', false] }, - name: { $ifNull: ['$orgDetails.name', ''] } - } - }, - { $sort: { memberCount: -1 } } - ]); - - res.json(organizations); + const sql = ` + SELECT + o.id as _id, + o.name, + o.is_banned as "isBanned", + COUNT(uo.user_id) as "memberCount", + MAX(uo.updated_at) as "lastActive" + FROM gpi.organizations o + LEFT JOIN gpi.user_organizations uo ON o.id = uo.organization_id + GROUP BY o.id, o.name, o.is_banned + ORDER BY "memberCount" DESC + `; + const resOrgs = await query(sql); + res.json(resOrgs.rows); } catch (error) { console.error('Error getting global organizations:', error); res.status(500).json({ error: 'Erro ao buscar organizações globais.' }); @@ -163,15 +117,12 @@ export const toggleOrganizationBan = async (req: Request, res: Response) => { return res.status(400).json({ error: 'ID da organização é obrigatório.' }); } - // Upsert the Organization record - const org = await Organization.findOneAndUpdate( - { externalId: organizationId }, - { isBanned: isBanned }, - { new: true, upsert: true } + const resOrg = await query( + 'UPDATE gpi.organizations SET is_banned = $1, updated_at = NOW() WHERE id = $2 RETURNING *', + [isBanned, organizationId] ); - console.log(`Organization ${organizationId} ban status set to ${isBanned} by ${req.appUser?.email}`); - res.json(org); + res.json(resOrg.rows[0]); } catch (error) { console.error('Error toggling organization ban:', error); res.status(500).json({ error: 'Erro ao atualizar status da organização.' }); diff --git a/src/server/index.ts b/src/server/index.ts index 7ddb2b5..9ddc3b1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,8 +1,6 @@ import app from './app.js'; import dotenv from 'dotenv'; -import { migrateFilesToGridFS } from './services/dataSheetService.js'; import { connectDB } from './config/database.js'; -import mongoose from 'mongoose'; import { notificationService } from './services/notificationService.js'; dotenv.config(); @@ -14,21 +12,15 @@ const startServer = async () => { const PORT = process.env.PORT || 3000; app.listen(Number(PORT), '0.0.0.0', async () => { console.log(`🚀 Server running on port ${PORT} (0.0.0.0)`); - if (mongoose.connection.readyState === 1) { - // await migrateFilesToGridFS().catch(err => console.error('Migration failed:', err)); - - // Agendar verificação de vencimento de estoque (a cada 24 horas) - console.log('📅 Scheduling stock expiration check...'); - setInterval(() => { - notificationService.checkStockExpirations(); - }, 24 * 60 * 60 * 1000); - - // Executar uma vez no início para garantir (opcional, bom para dev) + + // Agendar verificação de vencimento de estoque (a cada 24 horas) + console.log('📅 Scheduling stock expiration check...'); + setInterval(() => { notificationService.checkStockExpirations(); + }, 24 * 60 * 60 * 1000); - } else { - console.warn('⚠️ MongoDB is not connected. Skipping migrations.'); - } + // Executar uma vez no início para garantir (opcional, bom para dev) + // notificationService.checkStockExpirations(); }); } catch (error) { console.error('Failed to start server:', error); diff --git a/src/server/middleware/roleMiddleware.ts b/src/server/middleware/roleMiddleware.ts index 96e1949..e6ac10d 100644 --- a/src/server/middleware/roleMiddleware.ts +++ b/src/server/middleware/roleMiddleware.ts @@ -1,100 +1,88 @@ 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'; -// Extended user info with organization context -export interface IAppUser extends IUser { +export type UserRole = 'guest' | 'user' | 'admin'; +export type OrgRole = 'guest' | 'user' | 'admin'; + +export interface IAppUser { + id: string; + externalId: string; + email: string; + name: string; + role: UserRole; + isBanned: boolean; organizationId?: string; organizationRole?: OrgRole; organizationBanned?: boolean; } -// Module augmentation for Express Request -declare module 'express-serve-static-core' { - interface Request { - appUser?: IAppUser; - } -} - -/** - * 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 organizationId = req.headers['x-organization-id'] as string; if (!externalId) { - return next(); // No user, continue without + return next(); } - const user = await User.findOne({ externalId }); + // Buscar usuário (compatível com clerk_id ou logto_id salvos como external_id no futuro, + // ou usando as colunas atuais clerk_id/logto_id) + const userRes = await query( + 'SELECT * FROM gpi.users WHERE clerk_id = $1 OR logto_id = $1', + [externalId] + ); + + const user = userRes.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, + externalId: user.clerk_id || user.logto_id || externalId, + email: user.email, + name: user.name, + role: user.role, + 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 }); - const orgName = req.headers['x-organization-name'] ? decodeURIComponent(req.headers['x-organization-name'] as string) : undefined; + // Verificar organização + const orgRes = await query( + 'SELECT * FROM gpi.organizations WHERE id = $1 OR clerk_id = $1 OR logto_id = $1', + [organizationId] + ); + const org = orgRes.rows[0]; - 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); + if (org) { + if (org.is_banned) { + return res.status(403).json({ error: 'Acesso bloqueado: Esta organização está suspensa.' }); + } + + // Buscar papel na organização + const memberRes = await query( + 'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2', + [user.id, org.id] + ); + const member = memberRes.rows[0]; + + if (member) { + 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 + } else { + appUser.organizationRole = 'guest'; + appUser.role = 'guest'; } - - if (orgStatus.isBanned) { - return res.status(403).json({ - error: 'Acesso bloqueado: Esta organização está suspensa. Entre em contato com o suporte.' - }); - } - } 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); - } - } - - const member = await OrganizationMember.findOne({ userId: externalId, organizationId }); - if (member) { - if (member.isBanned) { - 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 - } 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 +92,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,46 +112,26 @@ 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.' }); } - const effectiveRole = req.appUser.organizationRole || req.appUser.role; - if (effectiveRole === 'guest') { - return res.status(403).json({ error: 'Convidados não podem editar. Solicite acesso ao administrador.' }); + return res.status(403).json({ error: 'Convidados não podem editar.' }); } - next(); }; -/** - * Middleware to require Developer (Super Admin) access - * Hardcoded to specific email for security - */ export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => { if (!req.appUser) { return res.status(401).json({ error: 'Autenticação necessária.' }); } - if (req.appUser.email !== 'admtracksteel@gmail.com') { - console.warn(`⛔ Attempted unauthorized developer access by: ${req.appUser.email}`); return res.status(403).json({ error: 'Acesso restrito ao desenvolvedor.' }); } - next(); }; diff --git a/src/server/models/ApplicationRecord.ts b/src/server/models/ApplicationRecord.ts deleted file mode 100644 index 183f297..0000000 --- a/src/server/models/ApplicationRecord.ts +++ /dev/null @@ -1,47 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IApplicationRecord extends Document { - organizationId?: string; - createdBy?: string; - projectId: mongoose.Types.ObjectId; - coatStage: string; - pieceDescription?: string | null; - date?: Date | null; - operator?: string | null; - realWeight?: number | null; - volumeUsed?: number | null; - areaPainted?: number | null; - wetThicknessAvg?: number | null; - dryThicknessCalc?: number | null; - method?: string | null; - diluentUsed?: number | null; - notes?: string | null; - items?: { - partId: mongoose.Types.ObjectId; - quantity: number; - }[]; -} - -const ApplicationRecordSchema: Schema = new Schema({ - organizationId: { type: String, index: true }, - createdBy: { type: String, index: true }, - projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true }, - coatStage: { type: String, required: true }, - pieceDescription: { type: String }, // Can be auto-generated or manual name for the Batch - date: { type: Date }, - operator: { type: String }, - realWeight: { type: Number }, - volumeUsed: { type: Number }, - areaPainted: { type: Number }, - wetThicknessAvg: { type: Number }, - dryThicknessCalc: { type: Number }, - method: { type: String }, - diluentUsed: { type: Number }, - notes: { type: String }, - items: [{ - partId: { type: Schema.Types.ObjectId, ref: 'Part' }, - quantity: { type: Number, required: true } - }] -}, { timestamps: true }); - -export default mongoose.models.ApplicationRecord || mongoose.model('ApplicationRecord', ApplicationRecordSchema); diff --git a/src/server/models/GeometryType.ts b/src/server/models/GeometryType.ts deleted file mode 100644 index 4858521..0000000 --- a/src/server/models/GeometryType.ts +++ /dev/null @@ -1,22 +0,0 @@ -import mongoose, { Document, Schema } from 'mongoose'; - -export interface IGeometryType extends Document { - name: string; - efficiencyLoss: number; // Percentage, e.g., 10 for 10% - organizationId: string; - createdAt: Date; - updatedAt: Date; -} - -const GeometryTypeSchema: Schema = new Schema({ - name: { type: String, required: true }, - efficiencyLoss: { type: Number, required: true, default: 0 }, - organizationId: { type: String, required: true, index: true }, -}, { - timestamps: true -}); - -// Compound index to ensure unique names per organization -GeometryTypeSchema.index({ organizationId: 1, name: 1 }, { unique: true }); - -export default mongoose.model('GeometryType', GeometryTypeSchema); diff --git a/src/server/models/Inspection.ts b/src/server/models/Inspection.ts deleted file mode 100644 index 05df1d6..0000000 --- a/src/server/models/Inspection.ts +++ /dev/null @@ -1,73 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IInspection extends Document { - organizationId?: string; - createdBy?: string; // Auth User ID - projectId: mongoose.Types.ObjectId; - type: 'painting' | 'surface_treatment'; - - // Common - date?: Date | null; - inspector?: string | null; - appearance?: 'approved' | 'rejected' | 'notes' | null; // Unified status - defects?: string | null; // Observations - photos?: string[]; // URLs - partTemperature?: number | null; - weightKg?: number | null; - - // Painting Specific - pieceDescription?: string | null; - epsPoints?: (number | null)[]; - adhesionTest?: string | null; - - // Surface Treatment Specific - batch?: string | null; // Lote - treatmentExecutor?: string | null; - treatmentType?: string | null; // Jateamento, Mecânica... - cleaningDegree?: string | null; // Sa 2.5, St 3... - roughnessReadings?: (number | null)[]; // 5 measurements - flashRust?: string | null; - temperature?: number | null; - relativeHumidity?: number | null; - period?: 'morning' | 'afternoon' | 'night' | null; - applicationRecordId?: mongoose.Types.ObjectId; // Link to specific painting batch - stockItemId?: mongoose.Types.ObjectId; // Link to Stock Item (Paint used) - instrumentId?: mongoose.Types.ObjectId; // Link to Instrument used -} - -const InspectionSchema: Schema = new Schema({ - organizationId: { type: String, index: true }, - createdBy: { type: String, index: true }, - projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true }, - applicationRecordId: { type: Schema.Types.ObjectId, ref: 'ApplicationRecord' }, - stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem' }, - instrumentId: { type: Schema.Types.ObjectId, ref: 'Instrument' }, - type: { type: String, enum: ['painting', 'surface_treatment'], default: 'painting', index: true }, - - // Common - date: { type: Date }, - inspector: { type: String }, - appearance: { type: String }, // approved, rejected, notes - defects: { type: String }, - photos: [{ type: String }], - partTemperature: { type: Number }, - weightKg: { type: Number }, - - // Painting - pieceDescription: { type: String }, - epsPoints: [{ type: Number }], - adhesionTest: { type: String }, - - // Surface Treatment - batch: { type: String }, - treatmentExecutor: { type: String }, - treatmentType: { type: String }, - cleaningDegree: { type: String }, - roughnessReadings: [{ type: Number }], - flashRust: { type: String }, - temperature: { type: Number }, - relativeHumidity: { type: Number }, - period: { type: String }, -}, { timestamps: true }); - -export default mongoose.models.Inspection || mongoose.model('Inspection', InspectionSchema); diff --git a/src/server/models/Instrument.ts b/src/server/models/Instrument.ts deleted file mode 100644 index dafc002..0000000 --- a/src/server/models/Instrument.ts +++ /dev/null @@ -1,40 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IInstrument extends Document { - organizationId: string; - name: string; - type: string; // Ex: Medidor de Camada, Termo-higrômetro - manufacturer?: string; - modelName?: string; - serialNumber: string; - calibrationDate?: Date; - calibrationExpirationDate?: Date; - certificateUrl?: string; // URL do PDF - status: 'active' | 'inactive' | 'maintenance' | 'expired'; - notes?: string; - createdAt: Date; - updatedAt: Date; -} - -const InstrumentSchema: Schema = new Schema({ - organizationId: { type: String, required: true, index: true }, - name: { type: String, required: true }, - type: { type: String, required: true }, - manufacturer: { type: String }, - modelName: { type: String }, - serialNumber: { type: String, required: true }, - calibrationDate: { type: Date }, - calibrationExpirationDate: { type: Date }, - certificateUrl: { type: String }, - status: { - type: String, - enum: ['active', 'inactive', 'maintenance', 'expired'], - default: 'active' - }, - notes: { type: String } -}, { timestamps: true }); - -// Index para evitar duplicidade de número de série dentro da mesma organização -InstrumentSchema.index({ organizationId: 1, serialNumber: 1 }, { unique: true }); - -export default mongoose.models.Instrument || mongoose.model('Instrument', InstrumentSchema); diff --git a/src/server/models/Message.ts b/src/server/models/Message.ts deleted file mode 100644 index 7256c8d..0000000 --- a/src/server/models/Message.ts +++ /dev/null @@ -1,63 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IMessage extends Document { - organizationId: string; - fromUserId: string; // externalId do remetente - toUserId: string; // externalId do destinatário - message: string; - isRead: boolean; - readAt?: Date; - isArchived: boolean; - isDeletedByRecipient: boolean; - createdAt: Date; - updatedAt: Date; -} - -const MessageSchema: Schema = new Schema( - { - organizationId: { - type: String, - required: true, - index: true, - }, - fromUserId: { - type: String, - required: true, - index: true, - }, - toUserId: { - type: String, - required: true, - index: true, - }, - message: { - type: String, - required: true, - maxlength: 255, - }, - isRead: { - type: Boolean, - default: false, - }, - readAt: { - type: Date, - }, - isArchived: { - type: Boolean, - default: false, - }, - isDeletedByRecipient: { - type: Boolean, - default: false, - } - }, - { - timestamps: true, - } -); - -// Compound index for efficient queries -MessageSchema.index({ toUserId: 1, isRead: 1 }); -MessageSchema.index({ fromUserId: 1, toUserId: 1 }); - -export default mongoose.model('Message', MessageSchema); diff --git a/src/server/models/Notification.ts b/src/server/models/Notification.ts deleted file mode 100644 index 5813487..0000000 --- a/src/server/models/Notification.ts +++ /dev/null @@ -1,32 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export type NotificationType = 'info' | 'warning' | 'error' | 'success'; - -export interface INotification extends Document { - organizationId: string; - recipientId?: string; // Se null, é para todos da organização - title: string; - message: string; - type: NotificationType; - isRead: boolean; - isArchived: boolean; - archivedBy: string[]; // IDs dos usuários que arquivaram (para notificações globais) - deletedBy: string[]; // IDs dos usuários que deletaram (para notificações globais) - metadata?: any; // Para guardar IDs de projetos, itens, etc. - createdAt: Date; -} - -const NotificationSchema: Schema = new Schema({ - organizationId: { type: String, required: true, index: true }, - recipientId: { type: String, index: true }, // Opcional - title: { type: String, required: true }, - message: { type: String, required: true }, - type: { type: String, enum: ['info', 'warning', 'error', 'success'], default: 'info' }, - isRead: { type: Boolean, default: false }, - isArchived: { type: Boolean, default: false }, - archivedBy: [{ type: String }], - deletedBy: [{ type: String }], - metadata: { type: Schema.Types.Mixed }, -}, { timestamps: true }); - -export default mongoose.models.Notification || mongoose.model('Notification', NotificationSchema); diff --git a/src/server/models/Organization.ts b/src/server/models/Organization.ts deleted file mode 100644 index 5829bcf..0000000 --- a/src/server/models/Organization.ts +++ /dev/null @@ -1,17 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IOrganization extends Document { - externalId: string; - name?: string; - isBanned: boolean; - createdAt: Date; - updatedAt: Date; -} - -const OrganizationSchema: Schema = new Schema({ - externalId: { type: String, required: true, unique: true, index: true }, - name: { type: String }, - isBanned: { type: Boolean, default: false }, -}, { timestamps: true }); - -export default mongoose.models.Organization || mongoose.model('Organization', OrganizationSchema); diff --git a/src/server/models/OrganizationMember.ts b/src/server/models/OrganizationMember.ts deleted file mode 100644 index 7742d03..0000000 --- a/src/server/models/OrganizationMember.ts +++ /dev/null @@ -1,52 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export type OrgRole = 'guest' | 'user' | 'admin'; - -export interface IOrganizationMember extends Document { - userId: string; - organizationId: string; - role: OrgRole; - isBanned: boolean; - // Denormalized user info for quick access - email: string; - name: string; - createdAt: Date; - updatedAt: Date; -} - -const OrganizationMemberSchema: Schema = new Schema({ - userId: { - type: String, - required: true, - index: true - }, - organizationId: { - type: String, - required: true, - index: true - }, - role: { - type: String, - enum: ['guest', 'user', 'admin'], - default: 'guest' - }, - isBanned: { - type: Boolean, - default: false - }, - email: { - type: String, - required: true - }, - name: { - type: String, - required: true - } -}, { - timestamps: true -}); - -// Compound index for unique user per organization -OrganizationMemberSchema.index({ userId: 1, organizationId: 1 }, { unique: true }); - -export default mongoose.models.OrganizationMember || mongoose.model('OrganizationMember', OrganizationMemberSchema); diff --git a/src/server/models/PaintingScheme.ts b/src/server/models/PaintingScheme.ts deleted file mode 100644 index 173c469..0000000 --- a/src/server/models/PaintingScheme.ts +++ /dev/null @@ -1,54 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IPaintingScheme extends Document { - projectId: mongoose.Types.ObjectId; - name: string; - type?: string | null; - coat?: string | null; - solidsVolume?: number | null; - yieldTheoretical?: number | null; - epsMin?: number | null; - epsMax?: number | null; - dilution?: number | null; - manufacturer?: string | null; - color?: string | null; - notes?: string | null; - organizationId?: string; - // Consumption Planning - paintConsumption?: number | null; - thinnerConsumption?: number | null; - paintId?: mongoose.Types.ObjectId | null; // Ref to TechnicalDataSheet - thinnerId?: mongoose.Types.ObjectId | null; // Ref to TechnicalDataSheet - preferredStockItemId?: mongoose.Types.ObjectId | null; // Ref to StockItem (Suggested Batch) -} - -const PaintingSchemeSchema: Schema = new Schema({ - organizationId: { type: String, index: true }, - projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true }, - name: { type: String, required: true }, - type: { type: String }, - coat: { type: String }, - solidsVolume: { type: Number }, - yieldTheoretical: { type: Number }, - epsMin: { type: Number }, - epsMax: { type: Number }, - dilution: { type: Number }, - manufacturer: { type: String }, - color: { type: String }, - notes: { type: String }, - // Consumption Planning - paintConsumption: { type: Number }, - thinnerConsumption: { type: Number }, - paintId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet' }, - thinnerId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet' }, - preferredStockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem' } -}, { strict: false }); - -console.log("✅✅✅ PAINTING SCHEME MODEL (WITH CONSUMPTION) LOADED ✅✅✅"); - -// Force model recompilation to ensure schema updates are applied -if (mongoose.models.PaintingScheme) { - delete mongoose.models.PaintingScheme; -} - -export default mongoose.model('PaintingScheme', PaintingSchemeSchema); diff --git a/src/server/models/Part.ts b/src/server/models/Part.ts deleted file mode 100644 index b22515f..0000000 --- a/src/server/models/Part.ts +++ /dev/null @@ -1,29 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IPart extends Document { - projectId?: mongoose.Types.ObjectId; - description: string; - dimensions?: string | null; - weight?: number | null; - type?: string | null; - area?: number | null; - complexity?: number | null; - quantity: number; - notes?: string | null; - organizationId?: string; -} - -const PartSchema: Schema = new Schema({ - organizationId: { type: String, index: true }, - projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: false }, - description: { type: String, required: true }, - dimensions: { type: String }, - weight: { type: Number }, - type: { type: String }, - area: { type: Number }, - complexity: { type: Number }, - quantity: { type: Number, required: true, default: 1 }, - notes: { type: String }, -}); - -export default mongoose.models.Part || mongoose.model('Part', PartSchema); diff --git a/src/server/models/Project.ts b/src/server/models/Project.ts deleted file mode 100644 index e8e4a6e..0000000 --- a/src/server/models/Project.ts +++ /dev/null @@ -1,29 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IProject extends Document { - name: string; - client: string; - startDate?: Date | null; - endDate?: Date | null; - technician?: string | null; - environment?: string | null; - organizationId?: string; - weightKg?: number | null; - status: 'active' | 'archived'; - createdAt: Date; - updatedAt: Date; -} - -const ProjectSchema: Schema = new Schema({ - name: { type: String, required: true }, - client: { type: String, required: true }, - organizationId: { type: String, index: true }, - startDate: { type: Date }, - endDate: { type: Date }, - technician: { type: String }, - environment: { type: String }, - weightKg: { type: Number }, - status: { type: String, enum: ['active', 'archived'], default: 'active', index: true }, -}, { timestamps: true }); - -export default mongoose.models.Project || mongoose.model('Project', ProjectSchema); diff --git a/src/server/models/StockAuditLog.ts b/src/server/models/StockAuditLog.ts deleted file mode 100644 index 53f38ca..0000000 --- a/src/server/models/StockAuditLog.ts +++ /dev/null @@ -1,31 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IStockAuditLog extends Document { - organizationId?: string; - stockItemId: mongoose.Types.ObjectId; - movementId?: mongoose.Types.ObjectId; // Optional, might be deleted - movementNumber?: number; - userId: string; - userName: string; - action: 'CREATE' | 'UPDATE' | 'DELETE'; - details: string; // Human readable summary - oldValues?: Record; - newValues?: Record; - timestamp: Date; -} - -const StockAuditLogSchema: Schema = new Schema({ - organizationId: { type: String, index: true }, - stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem', required: true }, - movementId: { type: Schema.Types.ObjectId, ref: 'StockMovement' }, - movementNumber: { type: Number }, - userId: { type: String, required: true }, - userName: { type: String, required: true }, - action: { type: String, required: true, enum: ['CREATE', 'UPDATE', 'DELETE'] }, - details: { type: String, required: true }, - oldValues: { type: Object }, - newValues: { type: Object }, - timestamp: { type: Date, default: Date.now } -}, { timestamps: true }); - -export default mongoose.models.StockAuditLog || mongoose.model('StockAuditLog', StockAuditLogSchema); diff --git a/src/server/models/StockItem.ts b/src/server/models/StockItem.ts deleted file mode 100644 index 8c60c1c..0000000 --- a/src/server/models/StockItem.ts +++ /dev/null @@ -1,43 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IStockItem extends Document { - organizationId?: string; - createdBy?: string; - dataSheetId: mongoose.Types.ObjectId; - rrNumber: string; // Registro de Rastreabilidade - batchNumber: string; // Lote - color?: string; - invoiceNumber?: string; // Nota Fiscal - receivedBy?: string; // Quem recebeu - quantity: number; - unit: string; - minStock?: number; // Estoque mínimo estipulado - expirationDate?: Date; - entryDate: Date; - notes?: string; - createdAt: Date; - updatedAt: Date; -} - -const StockItemSchema: Schema = new Schema({ - organizationId: { type: String, index: true }, - createdBy: { type: String, index: true }, - dataSheetId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet', required: true }, - rrNumber: { type: String, required: true }, - batchNumber: { type: String, required: true }, - color: { type: String }, - invoiceNumber: { type: String }, - receivedBy: { type: String }, - quantity: { type: Number, required: true, default: 0 }, - unit: { type: String, required: true }, - minStock: { type: Number, default: 0 }, - expirationDate: { type: Date }, - entryDate: { type: Date, default: Date.now }, - notes: { type: String } -}, { timestamps: true }); - -// Compound index to prevent duplicate RR within an organization, if desirable. -// For now, indexing RR for fast lookup. -StockItemSchema.index({ organizationId: 1, rrNumber: 1 }); - -export default mongoose.models.StockItem || mongoose.model('StockItem', StockItemSchema); diff --git a/src/server/models/StockMovement.ts b/src/server/models/StockMovement.ts deleted file mode 100644 index 3ac9d4e..0000000 --- a/src/server/models/StockMovement.ts +++ /dev/null @@ -1,34 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export type MovementType = 'ENTRY' | 'ADJUSTMENT' | 'CONSUMPTION'; - -export interface IStockMovement extends Document { - organizationId?: string; - createdBy?: string; - stockItemId: mongoose.Types.ObjectId; - movementNumber?: number; - type: MovementType; - quantity: number; // Positive for entry, negative for exit - date: Date; - responsible: string; // User who performed the action - reason?: string; // For ADJUSTMENT - requester?: string; // For CONSUMPTION - notes?: string; - createdAt: Date; -} - -const StockMovementSchema: Schema = new Schema({ - organizationId: { type: String, index: true }, - createdBy: { type: String, index: true }, - stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem', required: true }, - movementNumber: { type: Number }, - type: { type: String, enum: ['ENTRY', 'ADJUSTMENT', 'CONSUMPTION'], required: true }, - quantity: { type: Number, required: true }, - date: { type: Date, default: Date.now }, - responsible: { type: String, required: true }, - reason: { type: String }, - requester: { type: String }, - notes: { type: String } -}, { timestamps: true }); - -export default mongoose.models.StockMovement || mongoose.model('StockMovement', StockMovementSchema); diff --git a/src/server/models/StoredFile.ts b/src/server/models/StoredFile.ts deleted file mode 100644 index 32db4f1..0000000 --- a/src/server/models/StoredFile.ts +++ /dev/null @@ -1,19 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IStoredFile extends Document { - filename: string; - contentType: string; - data: Buffer; - size: number; - uploadDate: Date; -} - -const StoredFileSchema: Schema = new Schema({ - filename: { type: String, required: true }, - contentType: { type: String, required: true }, - data: { type: Buffer, required: true }, - size: { type: Number, required: true }, - uploadDate: { type: Date, default: Date.now } -}); - -export default mongoose.models.StoredFile || mongoose.model('StoredFile', StoredFileSchema); diff --git a/src/server/models/SystemSettings.ts b/src/server/models/SystemSettings.ts deleted file mode 100644 index e13bec0..0000000 --- a/src/server/models/SystemSettings.ts +++ /dev/null @@ -1,19 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface ISystemSettings extends Document { - settingsId: string; - appName: string; - appSubtitle: string; - appLogoUrl?: string; - updatedBy?: string; -} - -const SystemSettingsSchema: Schema = new Schema({ - settingsId: { type: String, required: true, unique: true, default: 'global' }, - appName: { type: String, required: true, default: 'GPI' }, - appSubtitle: { type: String, required: true, default: 'Gestão de Pintura Industrial' }, - appLogoUrl: { type: String }, - updatedBy: { type: String } // Email of the dev who updated it -}, { timestamps: true }); - -export default mongoose.models.SystemSettings || mongoose.model('SystemSettings', SystemSettingsSchema); diff --git a/src/server/models/TechnicalDataSheet.ts b/src/server/models/TechnicalDataSheet.ts deleted file mode 100644 index dcd5918..0000000 --- a/src/server/models/TechnicalDataSheet.ts +++ /dev/null @@ -1,59 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface ITechnicalDataSheet extends Document { - name: string; - manufacturer?: string; - type?: string; - fileId?: mongoose.Types.ObjectId; - fileUrl: string; - uploadDate: Date; - solidsVolume?: number; - density?: number; - mixingRatio?: string; - mixingRatioWeight?: string; - mixingRatioVolume?: string; - wftMin?: number; - wftMax?: number; - dftMin?: number; - dftMax?: number; - reducer?: string; - yieldTheoretical?: number; - dftReference?: number; - yieldFactor?: number; - dilution?: number; - notes?: string; - organizationId?: string; - manufacturerCode?: string; - minStock?: number; - typicalApplication?: string; -} - -const TechnicalDataSheetSchema: Schema = new Schema({ - organizationId: { type: String, index: true }, - name: { type: String, required: true }, - manufacturer: { type: String }, - manufacturerCode: { type: String }, - type: { type: String }, - minStock: { type: Number }, - typicalApplication: { type: String }, - fileId: { type: Schema.Types.ObjectId, ref: 'StoredFile' }, - fileUrl: { type: String }, - uploadDate: { type: Date, default: Date.now }, - solidsVolume: { type: Number }, - density: { type: Number }, - mixingRatio: { type: String }, - mixingRatioWeight: { type: String }, - mixingRatioVolume: { type: String }, - wftMin: { type: Number }, - wftMax: { type: Number }, - dftMin: { type: Number }, - dftMax: { type: Number }, - reducer: { type: String }, - yieldTheoretical: { type: Number }, - dftReference: { type: Number }, - yieldFactor: { type: Number }, - dilution: { type: Number }, - notes: { type: String }, -}, { timestamps: true }); - -export default mongoose.models.TechnicalDataSheet || mongoose.model('TechnicalDataSheet', TechnicalDataSheetSchema); diff --git a/src/server/models/User.ts b/src/server/models/User.ts deleted file mode 100644 index 08d8481..0000000 --- a/src/server/models/User.ts +++ /dev/null @@ -1,58 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export type UserRole = 'guest' | 'user' | 'admin'; - -export interface IUser extends Document { - externalId: string; - email: string; - name: string; - role: UserRole; - isBanned: boolean; - passwordHash?: string; - organizationId?: string; - createdAt: Date; - updatedAt: Date; - lastSeenAt?: Date; -} - -const UserSchema: Schema = new Schema({ - externalId: { - type: String, - required: true, - unique: true, - index: true - }, - passwordHash: { - type: String, - required: false - }, - organizationId: { - type: String, - index: true - }, - email: { - type: String, - required: true - }, - name: { - type: String, - required: true - }, - role: { - type: String, - enum: ['guest', 'user', 'admin'], - default: 'guest' - }, - isBanned: { - type: Boolean, - default: false - }, - lastSeenAt: { - type: Date, - default: Date.now - } -}, { - timestamps: true -}); - -export default mongoose.models.User || mongoose.model('User', UserSchema); diff --git a/src/server/models/YieldStudy.ts b/src/server/models/YieldStudy.ts deleted file mode 100644 index eb4cafe..0000000 --- a/src/server/models/YieldStudy.ts +++ /dev/null @@ -1,53 +0,0 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IPieceCategory { - id: string; // Keep as string for internal mapping if needed, or convert to Sub-document - name: string; - organizationId?: string; - weight: number; - area?: number; // Área em m² para cálculo alternativo - historicalYield: number; - historicalDft: number; - efficiency: number; -} - -const PieceCategorySchema: Schema = new Schema({ - name: { type: String, required: true }, - weight: { type: Number, required: true }, - area: { type: Number }, // Área em m² (opcional) - historicalYield: { type: Number, required: true }, - historicalDft: { type: Number, required: true }, - efficiency: { type: Number, required: true }, -}); - -export interface IYieldStudy extends Document { - name: string; - organizationId?: string; - dataSheetId: mongoose.Types.ObjectId; - targetDft: number; - dilutionPercent: number; - categories: IPieceCategory[]; - totalWeight: number; - estimatedPaintVolume: number; - estimatedReducerVolume: number; - estimatedPaintVolumeByArea?: number; // Cálculo por área (m²) - estimatedReducerVolumeByArea?: number; // Cálculo por área (m²) - averageComplexity: number; -} - -const YieldStudySchema: Schema = new Schema({ - name: { type: String, required: true }, - organizationId: { type: String, index: true }, - dataSheetId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet', required: true }, - targetDft: { type: Number, required: true }, - dilutionPercent: { type: Number, default: 0 }, - categories: [PieceCategorySchema], - totalWeight: { type: Number }, - estimatedPaintVolume: { type: Number }, - estimatedReducerVolume: { type: Number }, - estimatedPaintVolumeByArea: { type: Number }, // Cálculo por área - estimatedReducerVolumeByArea: { type: Number }, // Cálculo por área - averageComplexity: { type: Number }, -}, { timestamps: true }); - -export default mongoose.models.YieldStudy || mongoose.model('YieldStudy', YieldStudySchema); diff --git a/src/server/scripts/check-user.ts b/src/server/scripts/check-user.ts deleted file mode 100644 index 765dcf1..0000000 --- a/src/server/scripts/check-user.ts +++ /dev/null @@ -1,24 +0,0 @@ -import mongoose from 'mongoose'; - -const MONGODB_URI = 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0'; - -const UserSchema = new mongoose.Schema({ - email: String, - role: String, - passwordHash: String, - externalId: String -}); - -async function check() { - try { - await mongoose.connect(MONGODB_URI); - const User = mongoose.model('User', UserSchema); - const user = await User.findOne({ email: 'admtracksteel@gmail.com' }); - console.log('User found:', JSON.stringify(user, null, 2)); - } catch (err) { - console.error(err); - } finally { - await mongoose.disconnect(); - } -} -check(); diff --git a/src/server/scripts/init-db.sql b/src/server/scripts/init-db.sql new file mode 100644 index 0000000..4f6d508 --- /dev/null +++ b/src/server/scripts/init-db.sql @@ -0,0 +1,326 @@ +-- Full Database Schema for GPI (PostgreSQL) +CREATE SCHEMA IF NOT EXISTS gpi; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- 1. Organizations +CREATE TABLE IF NOT EXISTS gpi.organizations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + clerk_id TEXT UNIQUE, -- Legacy + logto_id TEXT UNIQUE, + name TEXT NOT NULL, + is_banned BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 2. Users +CREATE TABLE IF NOT EXISTS gpi.users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + clerk_id TEXT UNIQUE, -- Legacy + 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 TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 3. many-to-many user_organizations +CREATE TABLE IF NOT EXISTS gpi.user_organizations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES gpi.users(id) ON DELETE CASCADE, + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'guest', + is_banned BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE (user_id, organization_id) +); + +-- 4. Technical Data Sheets (Fichas Técnicas) +CREATE TABLE IF NOT EXISTS gpi.technical_data_sheets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + manufacturer TEXT, + type TEXT, + file_url TEXT, + solids_volume DECIMAL, + density DECIMAL, + mixing_ratio TEXT, + yield_theoretical DECIMAL, + wft_min DECIMAL, + wft_max DECIMAL, + dft_min DECIMAL, + dft_max DECIMAL, + reducer TEXT, + yield_factor DECIMAL DEFAULT 1.0, + min_stock DECIMAL DEFAULT 0, + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 5. Projects +CREATE TABLE IF NOT EXISTS gpi.projects ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.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 DECIMAL, + status TEXT DEFAULT 'active', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 6. Parts +CREATE TABLE IF NOT EXISTS gpi.parts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE, + description TEXT NOT NULL, + dimensions TEXT, + weight DECIMAL, + type TEXT, + area DECIMAL, + complexity INTEGER, + quantity INTEGER DEFAULT 1, + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 7. Painting Schemes +CREATE TABLE IF NOT EXISTS gpi.painting_schemes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT, + coat TEXT, -- Primer, Intermediate, Finish + solids_volume DECIMAL, + yield_theoretical DECIMAL, + eps_min DECIMAL, + eps_max DECIMAL, + dilution DECIMAL, + manufacturer TEXT, + color TEXT, + notes TEXT, + paint_id UUID REFERENCES gpi.technical_data_sheets(id), + thinner_id UUID REFERENCES gpi.technical_data_sheets(id), + color_hex TEXT, + thinner_symbol TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 8. Application Records (Lotes de Pintura) +CREATE TABLE IF NOT EXISTS gpi.application_records ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE, + coat_stage TEXT, + piece_description TEXT, + date DATE, + operator TEXT, + real_weight DECIMAL, + volume_used DECIMAL, + area_painted DECIMAL, + wet_thickness_avg DECIMAL, + dry_thickness_calc DECIMAL, + real_yield DECIMAL, + method TEXT, + diluent_used DECIMAL, + notes TEXT, + items JSONB DEFAULT '[]', -- List of {partId, quantity} + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 9. Instruments +CREATE TABLE IF NOT EXISTS gpi.instruments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + serial_number TEXT, + type TEXT, + last_calibration DATE, + calibration_due DATE, + status TEXT DEFAULT 'active', + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 10. Stock Items +CREATE TABLE IF NOT EXISTS gpi.stock_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + data_sheet_id UUID REFERENCES gpi.technical_data_sheets(id), + batch_number TEXT NOT NULL, + manufacturing_date DATE, + expiration_date DATE, + initial_quantity DECIMAL NOT NULL, + current_quantity DECIMAL NOT NULL, + unit TEXT DEFAULT 'L', + location TEXT, + status TEXT DEFAULT 'active', + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 11. Inspections +CREATE TABLE IF NOT EXISTS gpi.inspections ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE, + application_record_id UUID REFERENCES gpi.application_records(id), + stock_item_id UUID REFERENCES gpi.stock_items(id), + instrument_id UUID REFERENCES gpi.instruments(id), + type TEXT CHECK (type IN ('painting', 'surface_treatment')), + date DATE, + inspector TEXT, + part_temperature DECIMAL, + weight_kg DECIMAL, + appearance TEXT, -- approved, rejected, notes + defects TEXT, + photos TEXT[] DEFAULT '{}', + piece_description TEXT, + eps_points DECIMAL[] DEFAULT '{}', + adhesion_test TEXT, + batch TEXT, + treatment_executor TEXT, + treatment_type TEXT, + cleaning_degree TEXT, + roughness_readings DECIMAL[] DEFAULT '{}', + flash_rust TEXT, + temperature DECIMAL, + relative_humidity DECIMAL, + period TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 12. Stock Movements +CREATE TABLE IF NOT EXISTS gpi.stock_movements ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + stock_item_id UUID REFERENCES gpi.stock_items(id) ON DELETE CASCADE, + type TEXT CHECK (type IN ('in', 'out', 'adjust')), + quantity DECIMAL NOT NULL, + reason TEXT, + project_id UUID REFERENCES gpi.projects(id), + user_id TEXT, -- external_id + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 13. System Settings +CREATE TABLE IF NOT EXISTS gpi.system_settings ( + settings_id TEXT PRIMARY KEY DEFAULT 'global', + app_name TEXT DEFAULT 'GPI', + app_subtitle TEXT DEFAULT 'Gestão de Pintura Industrial', + app_logo_url TEXT, + updated_by TEXT, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 14. Notifications +CREATE TABLE IF NOT EXISTS gpi.notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + recipient_id UUID REFERENCES gpi.users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + message TEXT NOT NULL, + type TEXT CHECK (type IN ('info', 'warning', 'error', 'success')) DEFAULT 'info', + is_read BOOLEAN DEFAULT FALSE, + is_archived BOOLEAN DEFAULT FALSE, + archived_by UUID[] DEFAULT '{}', + deleted_by UUID[] DEFAULT '{}', + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 15. Yield Studies +CREATE TABLE IF NOT EXISTS gpi.yield_studies ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + data_sheet_id UUID REFERENCES gpi.technical_data_sheets(id), + name TEXT NOT NULL, + target_dft DECIMAL NOT NULL, + dilution_percent DECIMAL DEFAULT 0, + categories JSONB DEFAULT '[]', + total_weight DECIMAL, + estimated_paint_volume DECIMAL, + estimated_reducer_volume DECIMAL, + estimated_paint_volume_by_area DECIMAL, + estimated_reducer_volume_by_area DECIMAL, + average_complexity DECIMAL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 16. Stock Audit Logs +CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + stock_item_id UUID REFERENCES gpi.stock_items(id) ON DELETE CASCADE, + movement_id UUID, + movement_number INTEGER, + user_id TEXT, -- external_id + user_name TEXT, + action TEXT NOT NULL, + details TEXT, + old_values JSONB, + new_values JSONB, + timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 17. Messages +CREATE TABLE IF NOT EXISTS gpi.messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + from_user_id TEXT NOT NULL, -- external_id + to_user_id TEXT NOT NULL, -- external_id + message TEXT NOT NULL, + is_read BOOLEAN DEFAULT FALSE, + read_at TIMESTAMP WITH TIME ZONE, + is_archived BOOLEAN DEFAULT FALSE, + is_deleted_by_recipient BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 18. Geometry Types +CREATE TABLE IF NOT EXISTS gpi.geometry_types ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + efficiency_loss DECIMAL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE (organization_id, name) +); + +-- 19. Stored Files +CREATE TABLE IF NOT EXISTS gpi.stored_files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + original_name TEXT, + mimetype TEXT, + size INTEGER, + path TEXT, + category TEXT, + reference_id TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_projects_org ON gpi.projects(organization_id); +CREATE INDEX IF NOT EXISTS idx_parts_project ON gpi.parts(project_id); +CREATE INDEX IF NOT EXISTS idx_schemes_project ON gpi.painting_schemes(project_id); +CREATE INDEX IF NOT EXISTS idx_records_project ON gpi.application_records(project_id); +CREATE INDEX IF NOT EXISTS idx_inspections_project ON gpi.inspections(project_id); +CREATE INDEX IF NOT EXISTS idx_stock_org ON gpi.stock_items(organization_id); +CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON gpi.notifications(recipient_id); +CREATE INDEX IF NOT EXISTS idx_messages_to ON gpi.messages(to_user_id, is_read); +CREATE INDEX IF NOT EXISTS idx_geometry_types_org ON gpi.geometry_types(organization_id); diff --git a/src/server/scripts/migrate.ts b/src/server/scripts/migrate.ts deleted file mode 100644 index e5044fe..0000000 --- a/src/server/scripts/migrate.ts +++ /dev/null @@ -1,121 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import mongoose from 'mongoose'; -import dotenv from 'dotenv'; - -// Import Models -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 TechnicalDataSheet from '../models/TechnicalDataSheet.js'; -import YieldStudy from '../models/YieldStudy.js'; - -dotenv.config(); - -const DATA_DIR = path.join(process.cwd(), 'data'); - -const readJson = (filename: string) => { - const filePath = path.join(DATA_DIR, filename); - if (!fs.existsSync(filePath)) return []; - return JSON.parse(fs.readFileSync(filePath, 'utf-8')); -}; - -const migrate = async () => { - try { - const uri = process.env.MONGODB_URI; - if (!uri) throw new Error('MONGODB_URI not defined'); - await mongoose.connect(uri); - console.log('Connected to MongoDB for migration...'); - - const idMap = new Map(); - - // 1. TechnicalDataSheets - console.log('Migrating TechnicalDataSheets...'); - const datasheets = readJson('datasheets.json'); - for (const ds of datasheets) { - const newDs = new TechnicalDataSheet({ - ...ds, - _id: new mongoose.Types.ObjectId() - }); - await newDs.save(); - idMap.set(ds.id, newDs._id as mongoose.Types.ObjectId); - } - - // 2. Projects - console.log('Migrating Projects...'); - const projects = readJson('projects.json'); - for (const p of projects) { - const newP = new Project({ - ...p, - _id: new mongoose.Types.ObjectId() - }); - await newP.save(); - idMap.set(p.id, newP._id as mongoose.Types.ObjectId); - } - - // 3. Parts - console.log('Migrating Parts...'); - const parts = readJson('parts.json'); - for (const part of parts) { - const projectId = idMap.get(part.projectId); - if (projectId) { - const newPart = new Part({ ...part, projectId }); - await newPart.save(); - } - } - - // 4. PaintingSchemes - console.log('Migrating PaintingSchemes...'); - const schemes = readJson('paintingSchemes.json'); - for (const s of schemes) { - const projectId = idMap.get(s.projectId); - if (projectId) { - const newS = new PaintingScheme({ ...s, projectId }); - await newS.save(); - } - } - - // 5. ApplicationRecords - console.log('Migrating ApplicationRecords...'); - const records = readJson('applicationRecords.json'); - for (const r of records) { - const projectId = idMap.get(r.projectId); - if (projectId) { - const newR = new ApplicationRecord({ ...r, projectId }); - await newR.save(); - } - } - - // 6. Inspections - console.log('Migrating Inspections...'); - const inspections = readJson('inspections.json'); - for (const i of inspections) { - const projectId = idMap.get(i.projectId); - if (projectId) { - const newI = new Inspection({ ...i, projectId }); - await newI.save(); - } - } - - // 7. YieldStudies - console.log('Migrating YieldStudies...'); - const studies = readJson('yield_studies.json'); - for (const s of studies) { - const dataSheetId = idMap.get(s.dataSheetId); - if (dataSheetId) { - const newS = new YieldStudy({ ...s, dataSheetId }); - await newS.save(); - } - } - - console.log('✅ Migration completed successfully!'); - process.exit(0); - } catch (error) { - console.error('❌ Migration failed:', error); - process.exit(1); - } -}; - -migrate(); diff --git a/src/server/scripts/setAdmin.ts b/src/server/scripts/setAdmin.ts deleted file mode 100644 index d036faf..0000000 --- a/src/server/scripts/setAdmin.ts +++ /dev/null @@ -1,72 +0,0 @@ -import mongoose from 'mongoose'; -import bcrypt from 'bcryptjs'; -import { v4 as uuidv4 } from 'uuid'; - -const MONGODB_URI = 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0'; - -const UserSchema = new mongoose.Schema({ - externalId: { type: String, required: true, unique: true }, - email: { type: String, required: true, unique: true }, - passwordHash: { type: String, required: true }, - name: { type: String, required: true }, - role: { type: String, enum: ['guest', 'user', 'admin'], default: 'guest' }, - organizationId: String, - isBanned: { type: Boolean, default: false } -}, { timestamps: true }); - -async function fixAdmin() { - try { - await mongoose.connect(MONGODB_URI); - console.log('✅ Conectado ao MongoDB'); - - const User = mongoose.models.User || mongoose.model('User', UserSchema); - - const email = 'admtracksteel@gmail.com'; - const password = 'admin'; // Senha padrão temporária - const hashedPassword = await bcrypt.hash(password, 10); - - // Tenta encontrar o usuário existente - let user = await User.findOne({ email }); - - if (user) { - const result = await User.updateOne( - { email }, - { - $set: { - role: 'admin', - passwordHash: hashedPassword, - name: 'Admin TrackSteel' - } - } - ); - console.log(`✅ Usuário ${email} atualizado para admin e senha definida.`); - } else { - const fakeAuthId = `user_${uuidv4().replace(/-/g, '')}`; - await User.create({ - externalId: fakeAuthId, - email, - passwordHash: hashedPassword, - name: 'Admin TrackSteel', - role: 'admin', - organizationId: 'default-org' - }); - console.log(`✅ Usuário ${email} criado como admin (AuthId: ${fakeAuthId}).`); - } - - // Listar todos os usuários - const users = await User.find({}); - console.log('\n📋 Usuários atuais:'); - users.forEach((u, i) => { - console.log(` ${i + 1}. ${u.email} | role: ${u.role} | hasHash: ${!!u.passwordHash}`); - }); - - } catch (err) { - console.error('❌ Erro durante a execução:', err); - } finally { - await mongoose.disconnect(); - console.log('\n✅ Desconectado do MongoDB'); - process.exit(0); - } -} - -fixAdmin(); diff --git a/src/server/services/notificationService.ts b/src/server/services/notificationService.ts index 1270cd5..b1b7e3c 100644 --- a/src/server/services/notificationService.ts +++ b/src/server/services/notificationService.ts @@ -1,15 +1,33 @@ -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'; +export interface NotificationData { + id?: string; + organization_id: string; + recipient_id?: string | null; + title: string; + message: string; + type: 'info' | 'warning' | 'error' | 'success'; + is_read: boolean; + is_archived: boolean; + archived_by: string[]; + deleted_by: string[]; + metadata?: any; + created_at?: Date; + updated_at?: Date; +} + export const notificationService = { // Criar uma notificação - async create(data: Partial) { + async create(data: Partial) { try { - const notification = new Notification(data); - await notification.save(); - return notification; + const res = await query( + `INSERT INTO gpi.notifications ( + organization_id, recipient_id, title, message, type, metadata + ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [data.organization_id, data.recipient_id || null, data.title, data.message, data.type || 'info', data.metadata || {}] + ); + return res.rows[0]; } catch (error) { console.error('Error creating notification:', error); throw error; @@ -22,21 +40,17 @@ export const notificationService = { const graceDate = new Date(); graceDate.setDate(graceDate.getDate() - graceDays); - const query: Record = { - organizationId: orgId - }; + // No Postgres com JSONB, usamos o operador @> para verificar se a metadata contém as chaves/valores + const res = await query( + `SELECT id FROM gpi.notifications + WHERE organization_id = $1 + AND metadata @> $2::jsonb + AND created_at >= $3 + LIMIT 1`, + [orgId, JSON.stringify(metadata), graceDate] + ); - // 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; + return res.rows.length > 0; } catch (error) { console.error('Error checking notification existence:', error); return false; @@ -46,22 +60,22 @@ export const notificationService = { // Obter notificações de um usuário (ou globais da organização) async getUserNotifications(userId: string, organizationId: string, includeArchived: boolean = false) { try { - const query: Record = { - organizationId, - $or: [ - { recipientId: userId }, - { recipientId: null } // Notificações globais - ], - deletedBy: { $ne: userId } // Não mostrar as deletadas pelo usuário - }; + let sql = ` + SELECT * FROM gpi.notifications + WHERE organization_id = $1 + AND (recipient_id = $2 OR recipient_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 }; + sql += ' AND is_archived = false AND NOT ($2 = ANY(archived_by))'; } - return await Notification.find(query).sort({ createdAt: -1 }).limit(50); + sql += ' ORDER BY created_at DESC LIMIT 50'; + + const res = await query(sql, params); + return res.rows; } catch (error) { console.error('Error fetching notifications:', error); throw error; @@ -71,7 +85,11 @@ export const notificationService = { // Marcar como lida async markAsRead(id: string) { try { - return await Notification.findByIdAndUpdate(id, { isRead: true }, { new: true }); + const res = await query( + 'UPDATE gpi.notifications SET is_read = true, updated_at = NOW() WHERE id = $1 RETURNING *', + [id] + ); + return res.rows[0]; } catch (error) { console.error('Error marking notification as read:', error); throw error; @@ -81,16 +99,13 @@ export const notificationService = { // 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 } + return await query( + `UPDATE gpi.notifications + SET is_read = true, updated_at = NOW() + WHERE organization_id = $1 + AND (recipient_id = $2 OR recipient_id IS NULL) + AND is_read = false`, + [organizationId, userId] ); } catch (error) { console.error('Error marking all notifications as read:', error); @@ -101,25 +116,18 @@ 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; - - if (notification.recipientId) { - // Notificação pessoal - notification.isArchived = true; - notification.isRead = true; - } 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. - } - } - return await notification.save(); + // Se for pessoal, marca is_archived. Se for global, adiciona ao array archived_by. + const res = await query( + `UPDATE gpi.notifications + SET + is_archived = CASE WHEN recipient_id IS NOT NULL THEN true ELSE is_archived END, + archived_by = CASE WHEN recipient_id IS NULL THEN array_append(archived_by, $2) ELSE archived_by END, + is_read = true, + updated_at = NOW() + WHERE id = $1 RETURNING *`, + [id, userId] + ); + return res.rows[0]; } catch (error) { console.error('Error archiving notification:', error); throw error; @@ -129,18 +137,19 @@ export const notificationService = { // 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; + // Se for pessoal, deletamos. Se for global, adicionamos ao deleted_by. + const checkRes = await query('SELECT recipient_id FROM gpi.notifications WHERE id = $1', [id]); + if (checkRes.rows.length === 0) return null; - if (notification.recipientId && notification.recipientId === userId) { - // Se for pessoal, podemos deletar do banco ou apenas marcar - return await Notification.findByIdAndDelete(id); + if (checkRes.rows[0].recipient_id === userId) { + await query('DELETE FROM gpi.notifications WHERE id = $1', [id]); + return { id, deleted: true }; } else { - // Se for global, apenas adicionar ao deletedBy - if (!notification.deletedBy.includes(userId)) { - notification.deletedBy.push(userId); - } - return await notification.save(); + const res = await query( + 'UPDATE gpi.notifications SET deleted_by = array_append(deleted_by, $2), updated_at = NOW() WHERE id = $1 RETURNING *', + [id, userId] + ); + return res.rows[0]; } } catch (error) { console.error('Error soft deleting notification:', error); @@ -151,23 +160,19 @@ export const notificationService = { // Limpar todas (esconder todas as atuais) async clearAll(userId: string, organizationId: string) { try { - // Para notificações pessoais: Deletar - await Notification.deleteMany({ - organizationId, - recipientId: userId - }); + // Deletar as pessoais + await query( + 'DELETE FROM gpi.notifications WHERE organization_id = $1 AND recipient_id = $2', + [organizationId, 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(); - } + // Adicionar às globais deletadas + await query( + `UPDATE gpi.notifications + SET deleted_by = array_append(deleted_by, $2), updated_at = NOW() + WHERE organization_id = $1 AND recipient_id IS NULL AND NOT ($2 = ANY(deleted_by))`, + [organizationId, userId] + ); return { success: true }; } catch (error) { @@ -180,87 +185,57 @@ export const notificationService = { 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 res = await query( + 'SELECT * FROM gpi.stock_items WHERE expiration_date IS NOT NULL AND quantity > 0' + ); + const stockItems = res.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 expirationDate = 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'; + let type: 'warning' | 'error' | 'info' = 'warning'; + let triggerType = ''; - // 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()}.`; + message = `O item ${item.rr_number} - Lote ${item.batch_number} venceu em ${expirationDate.toLocaleDateString()}.`; type = 'error'; + triggerType = 'expired'; + } else if (isBefore(expirationDate, oneMonthFromNow)) { + title = 'Vencimento Próximo (1 mês)'; + message = `O item ${item.rr_number} - Lote ${item.batch_number} vencerá em menos de 1 mês (${expirationDate.toLocaleDateString()}).`; + type = 'warning'; + triggerType = 'expire_1_month'; + } else if (isBefore(expirationDate, twoMonthsFromNow)) { + title = 'Vencimento em 2 meses'; + message = `O item ${item.rr_number} - Lote ${item.batch_number} vencerá em 2 meses (${expirationDate.toLocaleDateString()}).`; + type = 'info'; + triggerType = 'expire_2_months'; + } - const notified = await this.isAlreadyNotified(orgId.toString(), { + if (triggerType) { + const notified = await this.isAlreadyNotified(orgId, { stockItemId: itemId, - triggerType: 'expired' + triggerType }); if (!notified) { await this.create({ - organizationId: orgId, + organization_id: 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' } + metadata: { stockItemId: itemId, triggerType } }); } } @@ -268,130 +243,5 @@ export const notificationService = { } catch (error) { console.error('Error in checkStockExpirations:', error); } - }, - - // 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' } - }); - - 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; - - 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(); - } - - } - // 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) { - console.error('Error in checkInstrumentCalibrations:', error); - } - }, - - // 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 orgId = item.organizationId; - if (!orgId) return; - - // 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) { - 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})`, - type: 'error', - metadata: { - stockItemId, - productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`, - triggerType: 'low_stock_aggregated' - } - }); - } - } - } catch (error) { - console.error('Error checking low stock:', error); - } } }; diff --git a/src/server/services/yieldStudyService.ts b/src/server/services/yieldStudyService.ts index 337680c..69d76cc 100644 --- a/src/server/services/yieldStudyService.ts +++ b/src/server/services/yieldStudyService.ts @@ -1,55 +1,83 @@ -import YieldStudy from '../models/YieldStudy.js'; +import { query } from '../config/database.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 sql = 'SELECT * FROM gpi.yield_studies'; + const params: any[] = []; + if (organizationId) { + sql += ' WHERE organization_id = $1'; + params.push(organizationId); + } + sql += ' ORDER BY created_at DESC'; + const res = await query(sql, params); + return res.rows; }; -// 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() }; +export const createStudy = async (data: any) => { + const res = await query( + `INSERT INTO gpi.yield_studies ( + organization_id, data_sheet_id, name, target_dft, + dilution_percent, categories, total_weight, estimated_paint_volume, + estimated_reducer_volume, estimated_paint_volume_by_area, + estimated_reducer_volume_by_area, average_complexity + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, + [ + data.organizationId, data.dataSheetId, data.name, data.targetDft, + data.dilutionPercent || 0, JSON.stringify(data.categories || []), + data.totalWeight, data.estimatedPaintVolume, data.estimatedReducerVolume, + data.estimatedPaintVolumeByArea, data.estimatedReducerVolumeByArea, + data.averageComplexity + ] + ); + return res.rows[0]; }; -// 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 check = await query('SELECT * FROM gpi.yield_studies WHERE id = $1', [id]); + if (check.rows.length === 0) return null; + const existing = check.rows[0]; - const existing = await YieldStudy.findById(id); - 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 fields: string[] = []; + const params: any[] = [id]; + let idx = 2; + + const updatable = [ + 'name', 'dataSheetId', 'targetDft', 'dilutionPercent', 'categories', + 'totalWeight', 'estimatedPaintVolume', 'estimatedReducerVolume', + 'estimatedPaintVolumeByArea', 'estimatedReducerVolumeByArea', 'averageComplexity' + ]; + + for (const key of updatable) { + if (updates[key] !== undefined) { + const dbKey = key.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`); + fields.push(`${dbKey} = $${idx++}`); + if (key === 'categories') { + params.push(JSON.stringify(updates[key])); + } else { + params.push(updates[key]); + } + } } - const updated = await YieldStudy.findOneAndUpdate({ _id: id }, updates, { new: true }).lean(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } - return null; + if (fields.length === 0) return existing; + + const sql = `UPDATE gpi.yield_studies SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1 RETURNING *`; + const res = await query(sql, params); + return res.rows[0]; }; 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); - 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}`); + const check = await query('SELECT organization_id FROM gpi.yield_studies WHERE id = $1', [id]); + if (check.rows.length === 0) return false; + + if (organizationId && check.rows[0].organization_id && check.rows[0].organization_id !== organizationId) { return false; } - await YieldStudy.findByIdAndDelete(id); + await query('DELETE FROM gpi.yield_studies WHERE id = $1', [id]); return true; }; diff --git a/src/server/types/express.d.ts b/src/server/types/express.d.ts index 8f0d0d6..d741302 100644 --- a/src/server/types/express.d.ts +++ b/src/server/types/express.d.ts @@ -1,10 +1,10 @@ -import { IUser } from '../models/User.js'; +import { IAppUser } from '../middleware/roleMiddleware.js'; declare global { namespace Express { interface Request { - appUser?: IUser; - userId?: string; + appUser?: IAppUser; } } } +export {}; diff --git a/src/server/types/models.ts b/src/server/types/models.ts index 5f5c1f1..4204df9 100644 --- a/src/server/types/models.ts +++ b/src/server/types/models.ts @@ -1,125 +1,144 @@ +export type UserRole = 'guest' | 'user' | 'admin'; +export type OrgRole = 'guest' | 'user' | 'admin'; + +export interface User { + id: string; + external_id: string; // ex-clerk_id + email: string; + name: string; + role: UserRole; + is_banned: boolean; + last_seen_at: Date; + created_at: Date; + updated_at: Date; +} + +export interface Organization { + id: string; + external_id: string; + name: string; + is_banned: boolean; + created_at: Date; + updated_at: Date; +} + +export interface UserOrganization { + user_id: string; + organization_id: string; + role: OrgRole; + is_banned: boolean; + created_at: Date; + updated_at: Date; +} + export interface Project { id: string; + organization_id: string; name: string; client: string; - startDate?: Date | string | null; - endDate?: Date | string | null; - technician?: string | null; - environment?: string | null; - createdAt: Date | string; - updatedAt: Date | string; - parts?: Part[]; - paintingSchemes?: PaintingScheme[]; - applicationRecords?: ApplicationRecord[]; - inspections?: Inspection[]; + start_date?: Date; + end_date?: Date; + technician?: string; + environment?: string; + weight_kg?: number; + status: 'active' | 'archived'; + created_at: Date; + updated_at: Date; } export interface Part { id: string; - projectId: string; - description: string; - dimensions?: string | null; - weight?: number | null; - type?: string | null; - area?: number | null; - complexity?: number | null; - quantity: number; - notes?: string | null; -} - -export interface PaintingScheme { - id: string; - projectId: string; + project_id: string; + organization_id: string; name: string; - type?: string | null; - solidsVolume?: number | null; - yieldTheoretical?: number | null; - epsMin?: number | null; - epsMax?: number | null; - dilution?: number | null; - manufacturer?: string | null; - color?: string | null; - notes?: string | null; -} - -export interface ApplicationRecord { - id: string; - projectId: string; - coatStage: string; - pieceDescription?: string | null; - date?: Date | string | null; - operator?: string | null; - realWeight?: number | null; // Peso Real (kg) - volumeUsed?: number | null; // Volume Utilizado (L) - areaPainted?: number | null; // Área Pintada (m²) - wetThicknessAvg?: number | null; // Espessura Úmida Média (µm) - dryThicknessCalc?: number | null; // Espessura Seca Calculada (µm) - method?: string | null; - realYield?: number | null; // Rendimento Real (m²/L) - diluentUsed?: number | null; // Consumo estimado de Diluente (L) - notes?: string | null; -} - -export interface Inspection { - id: string; - projectId: string; - date?: Date | string | null; - inspector?: string | null; - pieceDescription?: string | null; - epsPoints?: (number | null)[]; - adhesionTest?: string | null; // Teste de Aderência - appearance?: string | null; // Aspecto Visual - defects?: string | null; + description?: string; + quantity: number; + weight_kg?: number; + drawing_number?: string; + created_at: Date; + updated_at: Date; } export interface TechnicalDataSheet { id: string; + organization_id: string; name: string; manufacturer?: string; - type?: string; // 'epoxi', 'pu', 'diluent', etc - fileUrl: string; // Path or URL to PDF - uploadDate: Date | string; - - // Technical Properties for Analysis - solidsVolume?: number; // % - density?: number; // g/cm3 - mixingRatio?: string; - mixingRatioWeight?: string; - mixingRatioVolume?: string; - wftMin?: number; - wftMax?: number; - dftMin?: number; - dftMax?: number; + manufacturer_code?: string; + type?: string; + min_stock?: number; + typical_application?: string; + file_id?: string; + file_url?: string; + upload_date: Date; + solids_volume?: number; + density?: number; + mixing_ratio?: string; + mixing_ratio_weight?: string; + mixing_ratio_volume?: string; + wft_min?: number; + wft_max?: number; + dft_min?: number; + dft_max?: number; reducer?: string; - yieldTheoretical?: number; // m2/L - dftReference?: number; // µm (Espessura de camada seca de referência para o rendimento teórico) - yieldFactor?: number; // Razão m²/L por µm (Calculado como rendimento * espessura / 1 ou simplesmente sólidos * 10) - dilution?: number; // % sugerida + yield_theoretical?: number; + dft_reference?: number; + yield_factor?: number; + dilution?: number; notes?: string; + created_at: Date; + updated_at: Date; } -export interface PieceCategory { +export interface Notification { id: string; - name: string; - weight: number; // Total weight in Kg - historicalYield: number; // L/Kg (Historical consumption) - historicalDft: number; // µm (Reference DFT for the historical yield) - efficiency: number; // % (Expected application efficiency) + organization_id: string; + recipient_id?: string; + title: string; + message: string; + type: 'info' | 'warning' | 'error' | 'success'; + is_read: boolean; + is_archived: boolean; + archived_by: string[]; + deleted_by: string[]; + metadata?: any; + created_at: Date; + updated_at: Date; } -export interface YieldStudy { +export interface StockItem { id: string; - name: string; - dataSheetId: string; // Ficha Técnica selecionada - targetDft: number; // µm (Desired dry thickness) - dilutionPercent: number; // % (Expected dilution) - categories: PieceCategory[]; - createdAt: Date | string; - updatedAt: Date | string; - - // Results (Calculated) - totalWeight: number; - estimatedPaintVolume: number; // Liters - estimatedReducerVolume: number; // Liters - averageComplexity: number; + organization_id: string; + created_by?: string; + data_sheet_id: string; + rr_number: string; + batch_number: string; + color?: string; + invoice_number?: string; + received_by?: string; + quantity: number; + unit: string; + min_stock: number; + expiration_date?: Date; + entry_date: Date; + notes?: string; + created_at: Date; + updated_at: Date; +} + +export interface Instrument { + id: string; + organization_id: string; + name: string; + type: string; + manufacturer?: string; + model_name?: string; + serial_number: string; + calibration_date?: Date; + calibration_expiration_date?: Date; + certificate_url?: string; + status: 'active' | 'inactive' | 'maintenance' | 'expired'; + notes?: string; + created_at: Date; + updated_at: Date; }