From e88d145df78dafff6e7b2b145700a66af7cce9c7 Mon Sep 17 00:00:00 2001 From: admtracksteel Date: Mon, 16 Mar 2026 08:16:14 -0300 Subject: [PATCH] Migrate major controllers and services to PostgreSQL (gpi schema), fix build errors, add file and audit log support --- api/app.ts | 49 ++- create_tables.ts | 19 + src/server/controllers/dataSheetController.ts | 98 +---- .../controllers/inspectionController.ts | 18 +- .../controllers/instrumentController.ts | 63 +--- src/server/controllers/projectController.ts | 7 +- src/server/controllers/stockController.ts | 348 ++++-------------- src/server/controllers/userController.ts | 212 ++--------- .../services/applicationRecordService.ts | 204 +++++++--- src/server/services/dataSheetService.ts | 271 ++++++-------- src/server/services/fileService.ts | 18 + src/server/services/inspectionService.ts | 202 ++++++---- src/server/services/instrumentService.ts | 91 +++++ src/server/services/paintingSchemeService.ts | 132 ++++--- src/server/services/partService.ts | 140 ++++--- src/server/services/projectService.ts | 30 +- src/server/services/stockItemService.ts | 163 ++++++++ src/server/services/userService.ts | 145 ++++++++ 18 files changed, 1212 insertions(+), 998 deletions(-) create mode 100644 create_tables.ts create mode 100644 src/server/services/fileService.ts create mode 100644 src/server/services/instrumentService.ts create mode 100644 src/server/services/stockItemService.ts create mode 100644 src/server/services/userService.ts diff --git a/api/app.ts b/api/app.ts index 38cb311..37560d8 100644 --- a/api/app.ts +++ b/api/app.ts @@ -46,11 +46,14 @@ app.get('/api/admin/migrate-to-gpi', async (req, res) => { "user_organizations", "projects", "parts", + "technical_data_sheets", "painting_schemes", "inspections", "instruments", "stock_items", - "stock_movements" + "stock_movements", + "application_records", + "application_record_items" ]; try { @@ -60,14 +63,52 @@ app.get('/api/admin/migrate-to-gpi', async (req, res) => { 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: 'success' }); + results.push({ table, action: 'moved', status: 'success' }); } catch (err: any) { - results.push({ table, status: 'failed', error: err.message }); + // 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 completed", results }); + res.json({ message: "Migration check completed", results }); } catch (error: any) { res.status(500).json({ error: error.message }); } diff --git a/create_tables.ts b/create_tables.ts new file mode 100644 index 0000000..01c63ad --- /dev/null +++ b/create_tables.ts @@ -0,0 +1,19 @@ +import { query } from './src/server/config/database.js'; + +async function main() { + try { + console.log("Creating gpi.files..."); + await 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());"); + + console.log("Creating gpi.stock_audit_logs..."); + await 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());"); + + console.log("Done."); + process.exit(0); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } +} + +main(); diff --git a/src/server/controllers/dataSheetController.ts b/src/server/controllers/dataSheetController.ts index 54d9e29..b5b9cf7 100644 --- a/src/server/controllers/dataSheetController.ts +++ b/src/server/controllers/dataSheetController.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import * as pdfExtractionService from '../services/pdfExtractionService.js'; import { IAppUser } from '../middleware/roleMiddleware.js'; import { notificationService } from '../services/notificationService.js'; +import * as fileService from '../services/fileService.js'; interface AuthRequest extends Request { appUser?: IAppUser; @@ -12,9 +13,7 @@ interface AuthRequest extends Request { export const getAllDataSheets = async (req: AuthRequest, res: Response) => { try { const organizationId = req.appUser?.organizationId; - console.log('Backend: Fetching datasheets for org:', organizationId); const sheets = await dataSheetService.getAllDataSheets(organizationId); - console.log(`Backend: Found ${sheets.length} sheets`); res.json(sheets); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -32,7 +31,6 @@ export const extractData = async (req: AuthRequest, res: Response) => { const fileBuffer = fs.readFileSync(file.path); const data = await pdfExtractionService.extractDataFromPdf(fileBuffer); - // Return extracted data AND the file path so we don't need to re-upload res.json({ ...data, tempFilePath: file.path @@ -44,7 +42,6 @@ export const extractData = async (req: AuthRequest, res: Response) => { } }; - export const createDataSheet = async (req: AuthRequest, res: Response) => { try { const file = req.file; @@ -58,31 +55,15 @@ export const createDataSheet = async (req: AuthRequest, res: Response) => { } = req.body; const organizationId = req.appUser?.organizationId; - // Note: New logic prefers 'file' upload which we store in DB. - // If fileUrl is provided (legacy or external link), we use that but don't store binary. - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let fileId: any = undefined; + let fileId: string | undefined = undefined; let finalFileUrl = fileUrl || ''; if (file) { - // Read file buffer const buffer = fs.readFileSync(file.path); + const savedFile = await fileService.saveFile(file.originalname, file.mimetype, buffer); + fileId = savedFile.id; + finalFileUrl = savedFile.id; // Using ID as reference - // Save to StoredFile collection - const { default: StoredFile } = await import('../models/StoredFile.js'); - const newFile = await StoredFile.create({ - filename: file.originalname, - contentType: file.mimetype, - data: buffer, - size: file.size, - uploadDate: new Date() - }); - - fileId = newFile._id; - finalFileUrl = newFile._id.toString(); // Use ID as URL reference for consistency with frontend expectations if possible, or we might need to adjust frontend to use /api/datasheets/file/:id - - // Clean up temp file try { fs.unlinkSync(file.path); } catch (error) { @@ -90,16 +71,6 @@ export const createDataSheet = async (req: AuthRequest, res: Response) => { } } - if (!fileId && !finalFileUrl) { - // Check if fileUrl allows empty. The schema says optional now, but logically a datasheet usually has a file. - // However, for simplified Diluent registration, we might not have one. - // If the user didn't send a file and didn't send a URL, and schema is optional, we can proceed. - // But let's check if we want to enforce it. - // If manufacturerCode (Diluent indicator?) is present, maybe skip check? - // Actually, I removed 'required' from schema, so I should probably relax this check too. - // return res.status(400).json({ error: 'File is required' }); - } - const newSheet = await dataSheetService.createDataSheet({ name, manufacturer, @@ -127,14 +98,13 @@ export const createDataSheet = async (req: AuthRequest, res: Response) => { organizationId }); - // Notificação de Nova Ficha Técnica if (organizationId) { await notificationService.create({ organizationId, title: 'Nova Ficha Técnica', message: `A ficha técnica "${name}" (${manufacturer}) foi adicionada à biblioteca.`, type: 'info', - metadata: { dataSheetId: newSheet._id, triggerType: 'datasheet_created' } + metadata: { dataSheetId: newSheet.id, triggerType: 'datasheet_created' } }); } @@ -150,10 +120,6 @@ export const deleteDataSheet = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; - - // Find sheet to delete file if exists - // (Optional: Implement file deletion logic here if strict cleanup needed) - const success = await dataSheetService.deleteDataSheet(id as string, organizationId); if (success) { res.status(204).send(); @@ -179,8 +145,7 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => { notes, fileUrl } = req.body; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const updates: Record = { + const updates: any = { name, manufacturer, type, @@ -202,23 +167,11 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => { }; if (file) { - // Read file buffer const buffer = fs.readFileSync(file.path); + const savedFile = await fileService.saveFile(file.originalname, file.mimetype, buffer); + updates.fileId = savedFile.id; + updates.fileUrl = savedFile.id; - // Save to StoredFile collection - const { default: StoredFile } = await import('../models/StoredFile.js'); - const newFile = await StoredFile.create({ - filename: file.originalname, - contentType: file.mimetype, - data: buffer, - size: file.size, - uploadDate: new Date() - }); - - updates.fileId = newFile._id; - updates.fileUrl = newFile._id.toString(); - - // Clean up temp file try { fs.unlinkSync(file.path); } catch (error) { @@ -226,12 +179,6 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => { } } else if (fileUrl) { updates.fileUrl = String(fileUrl); - // If fileUrl is being updated but not file, we might lose fileId reference? - // If the user sends the same fileUrl (which is the ID), it's fine. - // But if they send a new external URL, we should probably unset fileId. - // For now, let's assume if it's an external URL, fileId should remain unless explicitly cleared? - // Safer: if fileUrl is explicitly sent and doesn't match an ID format, maybe clear fileId? - // Actually, keep it simple. } const updatedSheet = await dataSheetService.updateDataSheet(id, updates, organizationId); @@ -250,15 +197,14 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => { export const getFile = async (req: Request, res: Response) => { try { - const id_or_filename = req.params.id as string; + const id = req.params.id as string; - // Check if it's a MongoDB ObjectId (24 hex chars) - if (/^[0-9a-fA-F]{24}$/.test(id_or_filename)) { - const { default: StoredFile } = await import('../models/StoredFile.js'); - const fileDoc = await StoredFile.findById(id_or_filename); + // Check if it's a UUID + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) { + const fileDoc = await fileService.getFileById(id); if (fileDoc) { - res.set('Content-Type', fileDoc.contentType || 'application/pdf'); + res.set('Content-Type', fileDoc.content_type || 'application/pdf'); res.set('Content-Disposition', `inline; filename="${fileDoc.filename}"`); res.set('Access-Control-Allow-Origin', '*'); res.set('Cache-Control', 'public, max-age=3600'); @@ -266,19 +212,7 @@ export const getFile = async (req: Request, res: Response) => { } } - // Fallback to file system (legacy) - const stream = dataSheetService.getFileStream(id_or_filename); - - stream.on('file', (file) => { - res.set('Content-Type', 'application/pdf'); - res.set('Content-Disposition', `inline; filename="${file.filename}"`); - }); - - stream.on('error', () => { - res.status(404).json({ error: 'File not found' }); - }); - - stream.pipe(res); + res.status(404).json({ error: 'File not found' }); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Error getting file:', error); diff --git a/src/server/controllers/inspectionController.ts b/src/server/controllers/inspectionController.ts index fefe91d..e487bdc 100644 --- a/src/server/controllers/inspectionController.ts +++ b/src/server/controllers/inspectionController.ts @@ -1,7 +1,8 @@ import { Request, Response } from 'express'; import * as inspectionService from '../services/inspectionService.js'; import { notificationService } from '../services/notificationService.js'; -import '../middleware/roleMiddleware.js'; // Ensure type augmentation +import * as fileService from '../services/fileService.js'; +import fs from 'fs'; export const createInspection = async (req: Request, res: Response) => { try { @@ -13,14 +14,13 @@ export const createInspection = async (req: Request, res: Response) => { createdBy }); - // Notificação de Inspeção Reprovada if (req.body.appearance === 'rejected' && organizationId) { await notificationService.create({ organizationId, title: 'Inspeção Reprovada', message: `Uma inspeção foi reprovada na obra (ID: ${req.body.projectId}).`, type: 'error', - metadata: { inspectionId: inspection._id, projectId: req.body.projectId, triggerType: 'inspection_rejected' } + metadata: { inspectionId: inspection.id, projectId: req.body.projectId, triggerType: 'inspection_rejected' } }); } @@ -105,9 +105,15 @@ export const uploadPhoto = async (req: Request, res: Response) => { return res.status(400).json({ error: 'No file uploaded' }); } - // Return the public URL for the file - // Assuming 'uploads' is served statically at /uploads - const fileUrl = `/uploads/${req.file.filename}`; + const buffer = fs.readFileSync(req.file.path); + const savedFile = await fileService.saveFile(req.file.originalname, req.file.mimetype, buffer); + + // Clean up temp file + fs.unlinkSync(req.file.path); + + // We'll use /api/datasheets/file/:id to serve these if we use the same endpoint + // or create a new general /api/files/:id + const fileUrl = `/api/datasheets/file/${savedFile.id}`; res.json({ url: fileUrl }); } catch (error: unknown) { diff --git a/src/server/controllers/instrumentController.ts b/src/server/controllers/instrumentController.ts index 7fd879b..7cc0797 100644 --- a/src/server/controllers/instrumentController.ts +++ b/src/server/controllers/instrumentController.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import Instrument from '../models/Instrument.js'; +import * as instrumentService from '../services/instrumentService.js'; import { IAppUser } from '../middleware/roleMiddleware.js'; interface AuthRequest extends Request { @@ -9,33 +9,10 @@ interface AuthRequest extends Request { export const createInstrument = async (req: AuthRequest, res: Response) => { try { const organizationId = req.appUser?.organizationId; - const { name, type, manufacturer, modelName, serialNumber, calibrationDate, calibrationExpirationDate, certificateUrl, notes } = req.body; - - const existing = await Instrument.findOne({ organizationId, serialNumber }); - if (existing) { - return res.status(400).json({ error: 'Já existe um instrumento com este número de série.' }); - } - - // Determinar status inicial baseado na validade - let status = 'active'; - if (calibrationExpirationDate && new Date(calibrationExpirationDate) < new Date()) { - status = 'expired'; - } - - const instrument = await Instrument.create({ - organizationId, - name, - type, - manufacturer, - modelName, - serialNumber, - calibrationDate, - calibrationExpirationDate, - certificateUrl, - status, - notes + const instrument = await instrumentService.createInstrument({ + ...req.body, + organizationId }); - res.status(201).json(instrument); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -47,12 +24,7 @@ export const getInstruments = async (req: AuthRequest, res: Response) => { try { const organizationId = req.appUser?.organizationId; const { status } = req.query; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const query: any = { organizationId }; - if (status) query.status = status; - - const instruments = await Instrument.find(query).sort({ name: 1 }); + const instruments = await instrumentService.getInstruments(organizationId, status as string); res.json(instruments); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -64,24 +36,7 @@ export const updateInstrument = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; - - // Recalcular status se data de validade mudar - const updates = { ...req.body }; - if (updates.calibrationExpirationDate) { - if (new Date(updates.calibrationExpirationDate) < new Date()) { - updates.status = 'expired'; - } else if (updates.status === 'expired') { - // Se estava expirado e a data é futura, reativar (se o usuário não setou outro status) - updates.status = 'active'; - } - } - - const instrument = await Instrument.findOneAndUpdate( - { _id: id, organizationId }, - updates, - { new: true } - ); - + const instrument = await instrumentService.updateInstrument(id, req.body, organizationId); if (!instrument) return res.status(404).json({ error: 'Instrumento não encontrado.' }); res.json(instrument); } catch (error: unknown) { @@ -94,10 +49,8 @@ export const deleteInstrument = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; - - const deleted = await Instrument.findOneAndDelete({ _id: id, organizationId }); - if (!deleted) return res.status(404).json({ error: 'Instrumento não encontrado.' }); - + const success = await instrumentService.deleteInstrument(id, organizationId); + if (!success) return res.status(404).json({ error: 'Instrumento não encontrado.' }); res.status(204).send(); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/src/server/controllers/projectController.ts b/src/server/controllers/projectController.ts index 2f70b2e..688f228 100644 --- a/src/server/controllers/projectController.ts +++ b/src/server/controllers/projectController.ts @@ -9,10 +9,8 @@ interface AuthRequest extends Request { export const createProject = async (req: AuthRequest, res: Response) => { try { - console.log('Backend creating project. Body:', req.body); const organizationId = req.appUser?.organizationId; const project = await projectService.createProject({ ...req.body, organizationId }); - console.log('Project created successfully:', project._id); res.status(201).json(project); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -74,16 +72,13 @@ export const updateProject = async (req: AuthRequest, res: Response) => { const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com'; const project = await projectService.updateProject(req.params.id as string, req.body, organizationId, isGlobalAdmin); - // Notificação se Peso mudar (Exemplo simplificado, idealmente compararíamos com valor anterior) - // Como o update retorna o objeto atualizado, podemos assumir que se o body tem weightKg, houve intenção de mudar. - // Para ser mais preciso, deveríamos buscar o antigo antes, mas para MVP vamos notificar se houver o campo no body. if (req.body.weightKg !== undefined && organizationId) { await notificationService.create({ organizationId, title: 'Atualização de Obra', message: `O peso da obra "${project.name}" foi atualizado para ${project.weightKg}kg.`, type: 'info', - metadata: { projectId: project._id, triggerType: 'project_update' } + metadata: { projectId: project.id, triggerType: 'project_update' } }); } diff --git a/src/server/controllers/stockController.ts b/src/server/controllers/stockController.ts index 8c285eb..9a58414 100644 --- a/src/server/controllers/stockController.ts +++ b/src/server/controllers/stockController.ts @@ -1,7 +1,5 @@ import { Request, Response } from 'express'; -import StockItem from '../models/StockItem.js'; -import StockMovement from '../models/StockMovement.js'; - +import * as stockItemService from '../services/stockItemService.js'; import { IAppUser } from '../middleware/roleMiddleware.js'; import { notificationService } from '../services/notificationService.js'; @@ -14,80 +12,26 @@ export const createStockItem = async (req: AuthRequest, res: Response) => { const organizationId = req.appUser?.organizationId; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const { - dataSheetId, - rrNumber, - batchNumber, - quantity, - unit, - expirationDate, - notes, - color, - invoiceNumber, - receivedBy, - minStock + dataSheetId, rrNumber, batchNumber, quantity, unit, + expirationDate, notes, color, invoiceNumber, receivedBy, minStock } = req.body; - // Validation if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) { return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' }); } - // Check for duplicate RR within Org - const existing = await StockItem.findOne({ organizationId, rrNumber }); - if (existing) { - return res.status(400).json({ error: `Já existe um item com o RR ${rrNumber}.` }); - } - - // --- Min Stock Inheritance Logic --- - let finalMinStock = Number(minStock) || 0; - - // If user didn't provide a specific minStock (or provided 0), try to inherit from existing group - if (finalMinStock === 0) { - const existingGroupItem = await StockItem.findOne({ - organizationId, - dataSheetId, - color - }).sort({ updatedAt: -1 }); // Get latest active config - - if (existingGroupItem && existingGroupItem.minStock > 0) { - finalMinStock = existingGroupItem.minStock; - } - } else { - // If user DID provide a minStock, update all existing items in that group to match? - // User requested: "a regra de estoque minimo definido no cadastro precisa estar clonado para novos cadastros" - // And "soma dessas 'mesmas' tintas sejam comparadas com o estoque minimo cadastrado a elas" - // This implies the rule is a Property of the Group. So create/update should enforce consistency. - if (finalMinStock > 0) { - await StockItem.updateMany( - { organizationId, dataSheetId, color }, - { $set: { minStock: finalMinStock } } - ); - } - } - - const newItem = new StockItem({ + const savedItem = await stockItemService.createStockItem({ + ...req.body, organizationId, createdBy: req.appUser?.externalId, - dataSheetId, - rrNumber, - batchNumber, quantity: Number(quantity), - unit, - minStock: finalMinStock, - expirationDate, - notes, - color, - invoiceNumber, - receivedBy + minStock: Number(minStock) || 0 }); - const savedItem = await newItem.save(); - - // Create Initial Movement (ENTRY) - await StockMovement.create({ + await stockItemService.createStockMovement({ organizationId, createdBy: req.appUser?.externalId, - stockItemId: savedItem._id, + stockItemId: savedItem.id, movementNumber: 1, type: 'ENTRY', quantity: Number(quantity), @@ -95,24 +39,19 @@ export const createStockItem = async (req: AuthRequest, res: Response) => { notes: 'Abertura de Lote / Entrada Inicial' }); - // Notificação de Recebimento if (organizationId) { await notificationService.create({ organizationId, title: 'Recebimento de Material', - message: `Recebido: ${quantity}${unit} de ${savedItem.rrNumber} (Lote: ${batchNumber}).`, + message: `Recebido: ${quantity}${unit} de ${savedItem.rr_number} (Lote: ${batchNumber}).`, type: 'info', - metadata: { stockItemId: savedItem._id, triggerType: 'stock_received' } + metadata: { stockItemId: savedItem.id, triggerType: 'stock_received' } }); } - // Check Low Stock immediately - await notificationService.checkLowStock(savedItem._id.toString()); - res.status(201).json(savedItem); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; - console.error('Error creating stock item:', error); res.status(500).json({ error: message }); } }; @@ -121,46 +60,16 @@ export const updateStockItem = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; - // Only allow updating metadata, NOT quantity directly (quantity must be via adjustments) - // Adjusting logic: Admin might need to fix typo in quantity without movement record? - // Better enforcing movements. If quantity changes, user should use "Adjustment". - // Here we create a general update for details like Notes, Dates, etc. - - const { quantity, ...otherData } = req.body; // Separate quantity + const { quantity, ...otherData } = req.body; if (quantity !== undefined) { return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' }); } - // Check if Min Stock is being updated - if (otherData.minStock !== undefined) { - const item = await StockItem.findOne({ _id: id, organizationId }); - if (item) { - // Propagate to all siblings (same Product + Color) - await StockItem.updateMany( - { - organizationId, - dataSheetId: item.dataSheetId, - color: item.color - }, - { $set: { minStock: otherData.minStock } } - ); - } - } - - const updated = await StockItem.findOneAndUpdate( - { _id: id, organizationId }, - otherData, - { new: true } - ); - + const updated = await stockItemService.updateStockItem(id, organizationId!, otherData); if (!updated) return res.status(404).json({ error: 'Item não encontrado.' }); - // Check Low Stock (in case minStock changed) - await notificationService.checkLowStock(updated._id.toString()); - res.json(updated); - } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); @@ -172,43 +81,32 @@ export const adjustStock = async (req: AuthRequest, res: Response) => { const { id } = req.params; const organizationId = req.appUser?.organizationId; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; - const { quantityDelta, reason } = req.body; // quantityDelta: +10 or -5 + const { quantityDelta, reason } = req.body; if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' }); - if (!quantityDelta || isNaN(quantityDelta)) return res.status(400).json({ error: 'Quantidade inválida.' }); - - const item = await StockItem.findOne({ _id: id, organizationId }); + + const item = await stockItemService.getStockItemById(id, organizationId!); if (!item) return res.status(404).json({ error: 'Item não encontrado.' }); - // Calculate new quantity const newQuantity = Number(item.quantity) + Number(quantityDelta); if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' }); - item.quantity = newQuantity; - await item.save(); + await stockItemService.updateStockItemQuantity(id, newQuantity); - // Calculate next movement number - const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 }); - const count = await StockMovement.countDocuments({ stockItemId: item._id }); - const movementNumber = (lastMov?.movementNumber || count) + 1; - - // Register Movement - await StockMovement.create({ + const lastNum = await stockItemService.getLatestMovementNumber(id); + + await stockItemService.createStockMovement({ organizationId, createdBy: req.appUser?.externalId, - stockItemId: item._id, - movementNumber, + stockItemId: id, + movementNumber: lastNum + 1, type: 'ADJUSTMENT', quantity: Number(quantityDelta), responsible: userName, reason }); - // Check Low Stock - await notificationService.checkLowStock(item._id.toString()); - - res.json(item); - + res.json({ ...item, quantity: newQuantity }); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); @@ -222,40 +120,29 @@ export const consumeStock = async (req: AuthRequest, res: Response) => { const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const { quantityConsumed, requester, date } = req.body; - if (!requester) return res.status(400).json({ error: 'Solicitante é obrigatório.' }); - if (!quantityConsumed || Number(quantityConsumed) <= 0) return res.status(400).json({ error: 'Quantidade deve ser maior que zero.' }); - - const item = await StockItem.findOne({ _id: id, organizationId }); + const item = await stockItemService.getStockItemById(id, organizationId!); if (!item) return res.status(404).json({ error: 'Item não encontrado.' }); if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' }); - item.quantity -= Number(quantityConsumed); - await item.save(); + const newQuantity = item.quantity - Number(quantityConsumed); + await stockItemService.updateStockItemQuantity(id, newQuantity); - // Calculate next movement number - const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 }); - const count = await StockMovement.countDocuments({ stockItemId: item._id }); - const movementNumber = (lastMov?.movementNumber || count) + 1; + const lastNum = await stockItemService.getLatestMovementNumber(id); - // Register Movement (Negative quantity for consumption) - await StockMovement.create({ + await stockItemService.createStockMovement({ organizationId, createdBy: req.appUser?.externalId, - stockItemId: item._id, - movementNumber, + stockItemId: id, + movementNumber: lastNum + 1, type: 'CONSUMPTION', - quantity: -Number(quantityConsumed), // Negative + quantity: -Number(quantityConsumed), responsible: userName, requester, date: date || new Date() }); - // Check Low Stock - await notificationService.checkLowStock(item._id.toString()); - - res.json(item); - + res.json({ ...item, quantity: newQuantity }); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: message }); @@ -266,17 +153,8 @@ export const deleteStockItem = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; - - // Optional: Block delete if there are movements other than ENTRY? - // For simplicity allow Admin to nuke it. - - const deleted = await StockItem.findOneAndDelete({ _id: id, organizationId }); - if (!deleted) return res.status(404).json({ error: 'Item não encontrado.' }); - - // Cleanup movements & logs - await StockMovement.deleteMany({ stockItemId: id }); - await StockAuditLog.deleteMany({ stockItemId: id }); - + const success = await stockItemService.deleteStockItem(id, organizationId!); + if (!success) return res.status(404).json({ error: 'Item não encontrado.' }); res.status(204).send(); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -288,16 +166,7 @@ export const getStockItems = async (req: AuthRequest, res: Response) => { try { const organizationId = req.appUser?.organizationId; const { dataSheetId } = req.query; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const query: any = { organizationId }; - if (dataSheetId) query.dataSheetId = dataSheetId; - - // Sort by Expiration Date ASC (First to expire first) - const items = await StockItem.find(query) - .populate('dataSheetId', 'name manufacturer type') - .sort({ expirationDate: 1 }); - + const items = await stockItemService.getStockItems(organizationId!, dataSheetId as string); res.json(items); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -309,12 +178,8 @@ export const getStockItemById = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const organizationId = req.appUser?.organizationId; - - const item = await StockItem.findOne({ _id: id, organizationId }) - .populate('dataSheetId', 'name manufacturer type'); - + const item = await stockItemService.getStockItemById(id, organizationId!); if (!item) return res.status(404).json({ error: 'Item não encontrado.' }); - res.json(item); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -324,12 +189,9 @@ export const getStockItemById = async (req: AuthRequest, res: Response) => { export const getStockMovements = async (req: AuthRequest, res: Response) => { try { - const { id } = req.params; // StockItem ID + const { id } = req.params; const organizationId = req.appUser?.organizationId; - - const movements = await StockMovement.find({ stockItemId: id, organizationId }) - .sort({ date: -1 }); - + const movements = await stockItemService.getStockMovements(id, organizationId!); res.json(movements); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -337,91 +199,41 @@ export const getStockMovements = async (req: AuthRequest, res: Response) => { } }; -// ------------------------------------------------------------------ -// CRUD & Auditing for Movements -// ------------------------------------------------------------------ - -import StockAuditLog from '../models/StockAuditLog.js'; - export const updateStockMovement = async (req: AuthRequest, res: Response) => { try { - const { id } = req.params; // Movement ID + const { id } = req.params; const organizationId = req.appUser?.organizationId; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const userId = req.appUser?.externalId || 'system'; - const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin'; - if (!isAdmin) { - return res.status(403).json({ error: 'Apenas administradores podem editar movimentações.' }); - } + const oldMovement = await stockItemService.getStockMovementById(id, organizationId!); + if (!oldMovement) return res.status(404).json({ error: 'Movimentação não encontrada.' }); - const { date, quantity, notes } = req.body; + const item = await stockItemService.getStockItemById(oldMovement.stock_item_id, organizationId!); + const quantityDiff = Number(req.body.quantity) - Number(oldMovement.quantity); - const movement = await StockMovement.findOne({ _id: id, organizationId }); - if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' }); - - const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId }); - if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' }); - - // Calculate Delta - // If quantity changed, we need to adjust the item balance - // Note: 'quantity' in movement is signed (+ for entry, - for consumption) - // If the user edits a Consumption (-10) to (-15), the val passed in body might be absolute or signed? - // Let's assume the frontend sends the SIGNED value consistent with the movement type? - // Actually best to stick to specific logic: - // If movement type is ENTRY/ADJUSTMENT, quantity is usually positive (unless neg adjustment). - // If CONSUMPTION, quantity is stored negative. - // Let's expect the frontend to send the 'raw' new value. - // Be careful: if frontend sends positive 10 for a consumption, we must flip it? - // Let's assume frontend sends the value exactly as it should be stored. - - // HOWEVER, it's safer if we check type. - const newQuantitySigned = Number(quantity); - - // Validation: Consumption should generally be negative, Entry positive. - // But for flexibility let's just trust the arithmetic diff for now, - // but warn if sign flips unexpectedly? - - const oldQuantity = Number(movement.quantity); - const quantityDiff = newQuantitySigned - oldQuantity; - - // Update Item const newStockLevel = Number(item.quantity) + quantityDiff; - if (newStockLevel < 0) { - return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' }); - } + if (newStockLevel < 0) return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' }); - item.quantity = newStockLevel; - await item.save(); + await stockItemService.updateStockItemQuantity(item.id, newStockLevel); - // Audit Log - const typeMap: Record = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' }; - const typeLabel = typeMap[movement.type] || movement.type; - - await StockAuditLog.create({ + await stockItemService.createAuditLog({ organizationId, - stockItemId: item._id, - movementId: movement._id, - movementNumber: movement.movementNumber, + stockItemId: item.id, + movementId: id, + movementNumber: oldMovement.movement_number, userId, userName, action: 'UPDATE', - details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`, - oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes }, - newValues: { date, quantity: newQuantitySigned, notes } + details: `Edição de Movimentação: Qtd ${oldMovement.quantity} -> ${req.body.quantity}`, + oldValues: oldMovement, + newValues: req.body }); - // Update Movement - movement.quantity = newQuantitySigned; - if (date) movement.date = date; - if (notes !== undefined) movement.notes = notes; - await movement.save(); - - res.json(movement); - + const updated = await stockItemService.updateStockMovement(id, organizationId!, req.body); + res.json(updated); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; - console.error('Error updating movement:', error); res.status(500).json({ error: message }); } }; @@ -432,68 +244,42 @@ export const deleteStockMovement = async (req: AuthRequest, res: Response) => { const organizationId = req.appUser?.organizationId; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const userId = req.appUser?.externalId || 'system'; - const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin'; - if (!isAdmin) { - return res.status(403).json({ error: 'Apenas administradores podem excluir movimentações.' }); - } - - const movement = await StockMovement.findOne({ _id: id, organizationId }); + const movement = await stockItemService.getStockMovementById(id, organizationId!); if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' }); - const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId }); - if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' }); + const item = await stockItemService.getStockItemById(movement.stock_item_id, organizationId!); + const newStockLevel = Number(item.quantity) - Number(movement.quantity); - // Reverse the effect - // If we delete an Entry (+10), we MUST subtract 10 from Item. - // If we delete a Consumption (-10), we MUST add 10 (subtract -10) to Item. - // So: Item.quantity -= movement.quantity + if (newStockLevel < 0) return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' }); - const reverseQty = Number(movement.quantity); - const newStockLevel = Number(item.quantity) - reverseQty; + await stockItemService.updateStockItemQuantity(item.id, newStockLevel); - if (newStockLevel < 0) { - return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' }); - } - - item.quantity = newStockLevel; - await item.save(); - - // Audit Log - const typeMap: Record = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' }; - const typeLabel = typeMap[movement.type] || movement.type; - - await StockAuditLog.create({ + await stockItemService.createAuditLog({ organizationId, - stockItemId: item._id, - movementId: movement._id, - movementNumber: movement.movementNumber, + stockItemId: item.id, + movementId: id, + movementNumber: movement.movement_number, userId, userName, action: 'DELETE', - details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`, - oldValues: movement.toObject() + details: `Exclusão de Movimentação: Qtd ${movement.quantity}`, + oldValues: movement }); - await StockMovement.deleteOne({ _id: id }); - + await stockItemService.deleteStockMovement(id, organizationId!); res.status(204).send(); - } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; - console.error('Error deleting movement:', error); res.status(500).json({ error: message }); } }; export const getStockAuditLogs = async (req: AuthRequest, res: Response) => { try { - const { id } = req.params; // StockItem ID + const { id } = req.params; const organizationId = req.appUser?.organizationId; - - const logs = await StockAuditLog.find({ stockItemId: id, organizationId }) - .sort({ timestamp: -1 }); - + const logs = await stockItemService.getStockAuditLogs(id, organizationId!); res.json(logs); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/src/server/controllers/userController.ts b/src/server/controllers/userController.ts index ae95bce..5bc703d 100644 --- a/src/server/controllers/userController.ts +++ b/src/server/controllers/userController.ts @@ -1,25 +1,11 @@ import { Request, Response } from 'express'; -import User, { IUser } from '../models/User.js'; -import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js'; - -// Define locally to avoid import cycle risks -interface IAppUser extends IUser { - organizationId?: string; - organizationRole?: OrgRole; - organizationBanned?: boolean; -} +import * as userService from '../services/userService.js'; interface AuthRequest extends Request { - appUser?: IAppUser; + appUser?: any; } -/** - * Sync user from Auth to MongoDB - * Creates user if doesn't exist, updates if exists - * Also creates/updates OrganizationMember for the current organization - */ export const syncUser = async (req: Request, res: Response) => { - console.log('--- syncUser called ---', req.body); try { const { externalId, email, name, organizationId, clerkRole } = req.body; @@ -27,75 +13,21 @@ export const syncUser = async (req: Request, res: Response) => { return res.status(400).json({ error: 'externalId, email e name são obrigatórios.' }); } - // 1. Upsert the global User record - let user = await User.findOne({ externalId }); - - if (user) { - user.email = email; - user.name = name; - await user.save(); - } else { - user = await User.create({ - externalId, - email, - name, - role: 'guest', // Default global role - isBanned: false - }); - } - - if (organizationId) { - - // Map Auth role to our app role - let appRole: OrgRole = 'guest'; - if (clerkRole === 'org:admin') { - appRole = 'admin'; - } else if (clerkRole === 'org:member') { - appRole = 'user'; - } - - // Use findOneAndUpdate with upsert to handle race conditions atomically - // This avoids the need for try/catch on create and handles existing members too - const member = await OrganizationMember.findOneAndUpdate( - { userId: externalId, organizationId }, - { - $set: { - name, - email, - // Only update role if it's the first time (creation) - // Or we can optionally update it if needed. - // For now, let's NOT overwrite role on update to preserve local changes, - // UNLESS we want to force sync with Auth. - // Let's use $setOnInsert for fields we only want to set on creation. - }, - $setOnInsert: { - role: appRole, - isBanned: false - } - }, - { upsert: true, new: true, setDefaultsOnInsert: true } - ); - - // Return combined info - return res.json({ - ...user.toObject(), - organizationRole: member.role, - organizationBanned: member.isBanned - }); - } + const user = await userService.syncUser({ + externalId, + email, + name, + organizationId, + clerkRole + }); res.json(user); } catch (error) { console.error('Error syncing user:', error); - // Retornar 200 mesmo com erro para não travar o frontend se for algo não crítico, - // mas aqui é crítico. Vamos logar melhor. res.status(500).json({ error: 'Erro ao sincronizar usuário: ' + (error instanceof Error ? error.message : String(error)) }); } }; -/** - * Get current user data with organization context - */ export const getCurrentUser = async (req: AuthRequest, res: Response) => { try { if (!req.appUser) { @@ -103,45 +35,28 @@ export const getCurrentUser = async (req: AuthRequest, res: Response) => { } const organizationId = req.headers['x-organization-id'] as string; - - if (organizationId) { - const member = await OrganizationMember.findOne({ - userId: req.appUser.externalId, - organizationId - }); - - if (member) { - return res.json({ - ...req.appUser.toObject(), - role: member.role, - isBanned: member.isBanned, - organizationId - }); - } + const user = await userService.getCurrentUser(req.appUser.clerk_id || req.appUser.logto_id || req.appUser.externalId, organizationId); + + if (!user) { + return res.status(404).json({ error: 'Usuário não encontrado.' }); } - res.json(req.appUser); + res.json(user); } catch (error) { console.error('Error getting current user:', error); res.status(500).json({ error: 'Erro ao buscar usuário.' }); } }; -/** - * Get all users for the current organization (admin only) - */ export const getAllUsers = async (req: Request, res: Response) => { try { const organizationId = req.headers['x-organization-id'] as string; - console.log('getAllUsers called with organizationId:', organizationId); - if (!organizationId) { return res.status(400).json({ error: 'Organização não selecionada.' }); } - const members = await OrganizationMember.find({ organizationId }).sort({ createdAt: -1 }); - console.log(`Found ${members.length} members for org ${organizationId}:`, members.map(m => ({ name: m.name, email: m.email, externalId: m.userId }))); + const members = await userService.getAllUsersInOrg(organizationId); res.json(members); } catch (error) { console.error('Error getting users:', error); @@ -149,9 +64,6 @@ export const getAllUsers = async (req: Request, res: Response) => { } }; -/** - * Update user role within organization (admin only) - */ export const updateUserRole = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; @@ -162,36 +74,18 @@ export const updateUserRole = async (req: AuthRequest, res: Response) => { return res.status(400).json({ error: 'Organização não selecionada.' }); } - if (!['guest', 'user', 'admin'].includes(role)) { - return res.status(400).json({ error: 'Role inválido. Use: guest, user ou admin.' }); - } - - const member = await OrganizationMember.findById(id); - if (!member || member.organizationId !== organizationId) { + const member = await userService.updateUserRole(id, organizationId, role); + if (!member) { return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' }); } - // Prevent removing the last admin - if (member.role === 'admin' && role !== 'admin') { - const adminCount = await OrganizationMember.countDocuments({ organizationId, role: 'admin' }); - if (adminCount <= 1) { - return res.status(400).json({ error: 'Não é possível remover o último administrador.' }); - } - } - - member.role = role as OrgRole; - await member.save(); - res.json(member); } catch (error) { - console.error('Error toggling ban:', error); - res.status(500).json({ error: 'Erro ao alterar status de banimento.' }); + console.error('Error updating role:', error); + res.status(500).json({ error: 'Erro ao alterar role.' }); } }; -/** - * Ban or unban user within organization (admin only) - */ export const toggleBanUser = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; @@ -202,24 +96,11 @@ export const toggleBanUser = async (req: AuthRequest, res: Response) => { return res.status(400).json({ error: 'Organização não selecionada.' }); } - const member = await OrganizationMember.findById(id); - if (!member || member.organizationId !== organizationId) { + const member = await userService.toggleBanUser(id, organizationId, isBanned); + if (!member) { return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' }); } - // Prevent banning yourself - if (req.appUser && member.userId === req.appUser.externalId) { - return res.status(400).json({ error: 'Você não pode banir a si mesmo.' }); - } - - // Prevent banning another admin - if (member.role === 'admin') { - return res.status(400).json({ error: 'Não é possível banir um administrador.' }); - } - - member.isBanned = isBanned; - await member.save(); - res.json(member); } catch (error) { console.error('Error toggling ban:', error); @@ -227,62 +108,29 @@ export const toggleBanUser = async (req: AuthRequest, res: Response) => { } }; -/** - * Update current user's lastSeenAt timestamp - */ export const heartbeat = async (req: AuthRequest, res: Response) => { try { if (!req.appUser) { return res.status(401).json({ error: 'Não autenticado.' }); } - - // Update User model - await User.findByIdAndUpdate(req.appUser._id, { lastSeenAt: new Date() }); - - // Also update Organization Member for tighter query - // But for now User model is enough if we join correctly, or just use User model for presence. - // Actually, since we want to show users per organization, we should filter by Org. - // Our 'User.ts' has organizationId, but it might be just the 'default' one. - // Let's rely on OrganizationMember for the list, but we need to update lastSeenAt there too? - // Strategy: Update User (global), and when querying active users, join or filter. - // Better: Update OrganizationMember too if we want org-specific presence? - // Simpler: Just update User. When fetching active users, we fetch OrganizationMembers and populate User details, filtering by User.lastSeenAt. - + await userService.heartbeat(req.appUser.id); res.status(200).send(); } catch (error) { - // Silent fail for heartbeat console.error('Heartbeat error:', error); res.status(500).send(); } }; -/** - * Get active users in the same organization (seen in last 2 mins) - */ export const getActiveUsers = async (req: AuthRequest, res: Response) => { try { const organizationId = req.headers['x-organization-id'] as string; - const currentUserId = req.appUser?._id; + const currentUserId = req.appUser?.id; if (!organizationId) { return res.status(400).json([]); } - // Find members of this org - const members = await OrganizationMember.find({ organizationId }); - - // Get their Auth IDs - const externalIds = members.map(m => m.userId); - - // Find Users who were seen recently (2 minutes) - const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); - - const activeUsers = await User.find({ - externalId: { $in: externalIds }, - lastSeenAt: { $gte: twoMinutesAgo }, - _id: { $ne: currentUserId } // Optional: exclude self - }).select('name email lastSeenAt externalId'); // Only needed fields - + const activeUsers = await userService.getActiveUsers(organizationId, currentUserId); res.json(activeUsers); } catch (error) { console.error('Error getting active users:', error); @@ -290,7 +138,6 @@ export const getActiveUsers = async (req: AuthRequest, res: Response) => { } }; -// Delete organization member export const deleteUser = async (req: Request, res: Response) => { try { const { id } = req.params; @@ -300,18 +147,13 @@ export const deleteUser = async (req: Request, res: Response) => { return res.status(400).json({ error: 'Organização não selecionada.' }); } - console.log(`Deleting member ${id} from organization ${organizationId}`); + const success = await userService.deleteUserFromOrg(id, organizationId); - // Delete from OrganizationMember collection - const result = await OrganizationMember.findByIdAndDelete(id); - - if (!result) { + if (!success) { return res.status(404).json({ error: 'Membro não encontrado.' }); } - console.log(`Member ${result.name} deleted successfully`); - - res.json({ message: 'Membro removido com sucesso.', deletedMember: result }); + res.json({ message: 'Membro removido com sucesso.' }); } catch (error) { console.error('Error deleting user:', error); res.status(500).json({ error: 'Erro ao remover membro.' }); diff --git a/src/server/services/applicationRecordService.ts b/src/server/services/applicationRecordService.ts index 7893dda..5e27b26 100644 --- a/src/server/services/applicationRecordService.ts +++ b/src/server/services/applicationRecordService.ts @@ -1,72 +1,162 @@ -import ApplicationRecord from '../models/ApplicationRecord.js'; +import { query } from '../config/database.js'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const createApplicationRecord = async (data: any & { organizationId?: string, createdBy?: string }) => { - const newRecord = new ApplicationRecord({ - ...data, - date: data.date ? new Date(data.date) : null, - organizationId: data.organizationId, - createdBy: data.createdBy - }); - const saved = await newRecord.save(); - return { ...saved.toObject(), id: saved._id.toString() }; +interface ApplicationRecordItem { + partId: string; + quantity: number; +} + +interface ApplicationRecordData { + organizationId?: string; + createdBy?: string; + projectId: string; + coatStage: string; + pieceDescription?: string; + date?: Date; + operator?: string; + realWeight?: number; + volumeUsed?: number; + areaPainted?: number; + wetThicknessAvg?: number; + dryThicknessCalc?: number; + method?: string; + diluentUsed?: number; + notes?: string; + items?: ApplicationRecordItem[]; +} + +export const createApplicationRecord = async (data: ApplicationRecordData) => { + const result = await query( + `INSERT INTO gpi.application_records ( + organization_id, created_by, project_id, coat_stage, piece_description, + date, operator, real_weight, volume_used, area_painted, + wet_thickness_avg, dry_thickness_calc, method, diluent_used, notes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING *`, + [ + data.organizationId, data.createdBy, data.projectId, data.coatStage, data.pieceDescription, + data.date, data.operator, data.realWeight, data.volumeUsed, data.areaPainted, + data.wetThicknessAvg, data.dryThicknessCalc, data.method, data.diluentUsed, data.notes + ] + ); + + const record = result.rows[0]; + + if (data.items && data.items.length > 0) { + for (const item of data.items) { + await query( + 'INSERT INTO gpi.application_record_items (application_record_id, part_id, quantity) VALUES ($1, $2, $3)', + [record.id, item.partId, item.quantity] + ); + } + } + + return record; }; export const getApplicationRecordsByProject = async (projectId: string, organizationId?: string) => { - const query = { projectId, ...(organizationId ? { organizationId } : {}) }; - const records = await ApplicationRecord.find(query).sort({ date: -1 }).lean(); - return records.map(r => ({ ...r, id: r._id.toString() })); + let sql = 'SELECT * FROM gpi.application_records WHERE project_id = $1'; + const params: any[] = [projectId]; + + if (organizationId) { + sql += ' AND organization_id = $2'; + params.push(organizationId); + } + + sql += ' ORDER BY date DESC'; + + const result = await query(sql, params); + + // Fetch items for each record + const records = []; + for (const row of result.rows) { + const itemsResult = await query( + 'SELECT part_id as "partId", quantity FROM gpi.application_record_items WHERE application_record_id = $1', + [row.id] + ); + records.push({ ...row, items: itemsResult.rows }); + } + + return records; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const updateApplicationRecord = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => { - const existing = await ApplicationRecord.findById(id); - if (!existing) return null; +export const updateApplicationRecord = async ( + id: string, + data: Partial, + organizationId?: string, + userId?: string, + userRole?: string, + isDeveloper: boolean = false +) => { + const checkResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]); + if (checkResult.rows.length === 0) return null; + const existing = checkResult.rows[0]; - // Organization Check - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { - return null; - } - - // Role/Ownership check - const isPowerUser = userRole === 'admin' || isDeveloper; - if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) { - console.warn(`Permission Denied: User ${userId} tried to update record ${id} created by ${existing.createdBy}`); - return null; - } - - const updateData = { - ...data, - date: data.date ? new Date(data.date) : undefined - }; - - if (organizationId && !existing.organizationId) { - updateData.organizationId = organizationId; - } - - const updated = await ApplicationRecord.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { return null; + } + + const isPowerUser = userRole === 'admin' || isDeveloper; + if (!isPowerUser && existing.created_by && existing.created_by !== userId) { + return null; + } + + const fields: string[] = []; + const params: any[] = [id]; + let paramIdx = 2; + + const updatableFields = [ + 'coatStage', 'pieceDescription', 'date', 'operator', 'realWeight', + 'volumeUsed', 'areaPainted', 'wetThicknessAvg', 'dryThicknessCalc', + 'method', 'diluentUsed', 'notes' + ]; + + for (const key of updatableFields) { + if ((data as any)[key] !== undefined) { + // Convert camelCase to snake_case + const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + fields.push(`${dbKey} = $${paramIdx++}`); + params.push((data as any)[key]); + } + } + + if (fields.length > 0) { + await query( + `UPDATE gpi.application_records SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1`, + params + ); + } + + // Handle items update (replace all) + if (data.items) { + await query('DELETE FROM gpi.application_record_items WHERE application_record_id = $1', [id]); + for (const item of data.items) { + await query( + 'INSERT INTO gpi.application_record_items (application_record_id, part_id, quantity) VALUES ($1, $2, $3)', + [id, item.partId, item.quantity] + ); + } + } + + const finalResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]); + const itemsFinal = await query('SELECT part_id as "partId", quantity FROM gpi.application_record_items WHERE application_record_id = $1', [id]); + + return { ...finalResult.rows[0], items: itemsFinal.rows }; }; export const deleteApplicationRecord = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => { - const existing = await ApplicationRecord.findById(id); - if (!existing) return false; + const checkResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]); + if (checkResult.rows.length === 0) return false; + const existing = checkResult.rows[0]; - // Organization Check - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { - return false; - } + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { + return false; + } - // Role/Ownership check - const isPowerUser = userRole === 'admin' || isDeveloper; - if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) { - return false; - } + const isPowerUser = userRole === 'admin' || isDeveloper; + if (!isPowerUser && existing.created_by && existing.created_by !== userId) { + return false; + } - await ApplicationRecord.deleteOne({ _id: id }); - return true; + await query('DELETE FROM gpi.application_records WHERE id = $1', [id]); + return true; }; diff --git a/src/server/services/dataSheetService.ts b/src/server/services/dataSheetService.ts index 3031d41..84dcab5 100644 --- a/src/server/services/dataSheetService.ts +++ b/src/server/services/dataSheetService.ts @@ -1,174 +1,133 @@ -import TechnicalDataSheet from '../models/TechnicalDataSheet.js'; -import fs from 'fs'; -import path from 'path'; -import { bucket } from '../config/database.js'; -import { ObjectId } from 'mongodb'; +import { query } from '../config/database.js'; -export const saveFileToGridFS = (localPath: string, filename: string): Promise => { - return new Promise((resolve, reject) => { - const uploadStream = bucket.openUploadStream(filename); - const readStream = fs.createReadStream(localPath); - - readStream.pipe(uploadStream) - .on('error', reject) - .on('finish', () => { - // Remove local file after upload - fs.unlink(localPath, (err) => { - if (err) console.error('Failed to delete local temp file:', err); - }); - resolve(uploadStream.id.toString()); - }); - }); -}; - -export const deleteFileFromGridFS = async (fileId: string) => { - try { - await bucket.delete(new ObjectId(fileId)); - return true; - } catch (err) { - console.error('Failed to delete file from GridFS:', err); - return false; - } -}; - -export const getFileStream = (fileId: string) => { - if (!ObjectId.isValid(fileId)) { - throw new Error('Invalid file ID format'); - } - return bucket.openDownloadStream(new ObjectId(fileId)); -}; +interface DataSheetData { + organizationId?: string; + name: string; + manufacturer?: string; + manufacturerCode?: string; + type?: string; + minStock?: number; + typicalApplication?: string; + fileId?: string; + fileUrl?: string; + 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; +} export const getAllDataSheets = async (organizationId?: string) => { - const query = organizationId - ? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] } - : {}; - const sheets = await TechnicalDataSheet.find(query).sort({ uploadDate: -1 }).lean(); - return sheets.map(s => ({ ...s, id: s._id.toString() })); + let sql = 'SELECT * FROM gpi.technical_data_sheets'; + const params: any[] = []; + + if (organizationId) { + sql += ' WHERE (organization_id = $1 OR organization_id IS NULL)'; + params.push(organizationId); + } + + sql += ' ORDER BY created_at DESC'; + + const result = await query(sql, params); + return result.rows; }; -export const matchSheets = async (query: string, organizationId?: string) => { - const orgFilter = organizationId - ? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] } - : {}; +export const matchSheets = async (searchTerm: string, organizationId?: string) => { + let sql = ` + SELECT * FROM gpi.technical_data_sheets + WHERE (name ILIKE $1 OR manufacturer ILIKE $1 OR type ILIKE $1)`; + const params: any[] = [`%${searchTerm}%`]; - const filter = { - ...orgFilter, - $or: [ - { name: { $regex: query, $options: 'i' } }, - { manufacturer: { $regex: query, $options: 'i' } }, - { type: { $regex: query, $options: 'i' } } - ] - }; - const sheets = await TechnicalDataSheet.find(filter).lean(); - return sheets.map(s => ({ ...s, id: s._id.toString() })); + if (organizationId) { + sql += ' AND (organization_id = $2 OR organization_id IS NULL)'; + params.push(organizationId); + } + + const result = await query(sql, params); + return result.rows; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const createDataSheet = async (data: any & { organizationId?: string }) => { - let fileId = data.fileUrl; +export const createDataSheet = async (data: DataSheetData) => { + const result = await query( + `INSERT INTO gpi.technical_data_sheets ( + organization_id, name, manufacturer, manufacturer_code, type, + min_stock, typical_application, file_id, file_url, solids_volume, + density, mixing_ratio, mixing_ratio_weight, mixing_ratio_volume, + wft_min, wft_max, dft_min, dft_max, reducer, yield_theoretical, + dft_reference, yield_factor, dilution, notes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) + RETURNING *`, + [ + data.organizationId, data.name, data.manufacturer, data.manufacturerCode, data.type, + data.minStock, data.typicalApplication, data.fileId, data.fileUrl, data.solidsVolume, + data.density, data.mixingRatio, data.mixingRatioWeight, data.mixingRatioVolume, + data.wftMin, data.wftMax, data.dftMin, data.dftMax, data.reducer, data.yieldTheoretical, + data.dftReference, data.yieldFactor, data.dilution, data.notes + ] + ); + return result.rows[0]; +}; - // If fileUrl is a local path (exists on disk), move to GridFS - if (data.fileUrl && fs.existsSync(data.fileUrl)) { - fileId = await saveFileToGridFS(data.fileUrl, data.name + '.pdf'); +export const updateDataSheet = async (id: string, data: Partial, organizationId?: string) => { + const checkResult = await query('SELECT * FROM gpi.technical_data_sheets WHERE id = $1', [id]); + if (checkResult.rows.length === 0) return null; + const existing = checkResult.rows[0]; + + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { + return null; + } + + const fields: string[] = []; + const params: any[] = [id]; + let paramIdx = 2; + + const updatableFields = [ + 'name', 'manufacturer', 'manufacturerCode', 'type', 'minStock', + 'typicalApplication', 'fileId', 'fileUrl', 'solidsVolume', 'density', + 'mixingRatio', 'mixingRatioWeight', 'mixingRatioVolume', 'wftMin', + 'wftMax', 'dftMin', 'dftMax', 'reducer', 'yieldTheoretical', + 'dftReference', 'yieldFactor', 'dilution', 'notes' + ]; + + for (const key of updatableFields) { + if ((data as any)[key] !== undefined) { + const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + fields.push(`${dbKey} = $${paramIdx++}`); + params.push((data as any)[key]); } + } - const newSheet = new TechnicalDataSheet({ - ...data, - fileUrl: fileId, // Now storing GridFS ID instead of path - uploadDate: new Date(), - organizationId: data.organizationId - }); + if (fields.length === 0) return existing; - const saved = await newSheet.save(); - return { ...saved.toObject(), id: saved._id.toString() }; + const updateSql = ` + UPDATE gpi.technical_data_sheets + SET ${fields.join(', ')}, updated_at = NOW() + WHERE id = $1 + RETURNING *`; + + const result = await query(updateSql, params); + return result.rows[0]; }; export const deleteDataSheet = async (id: string, organizationId?: string) => { - // Find first to check permissions - const sheet = await TechnicalDataSheet.findById(id); - if (!sheet) return false; + const checkResult = await query('SELECT * FROM gpi.technical_data_sheets WHERE id = $1', [id]); + if (checkResult.rows.length === 0) return false; + const existing = checkResult.rows[0]; - // Permission Check: - // If current user is in an Org, and Sheet is in a DIFFERENT Org, deny. - // Explicitly allow if Sheet has NO Org (Legacy/Global). - if (organizationId && sheet.organizationId && sheet.organizationId !== organizationId) { - console.warn(`[Delete DataSheet] Access Denied. User Org: ${organizationId}, Sheet Org: ${sheet.organizationId}`); - return false; - } + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { + return false; + } - // Delete from GridFS if not a full URL - if (sheet.fileUrl && !sheet.fileUrl.startsWith('http')) { - await deleteFileFromGridFS(sheet.fileUrl); - } - - await TechnicalDataSheet.findByIdAndDelete(id); - return true; + await query('DELETE FROM gpi.technical_data_sheets WHERE id = $1', [id]); + return true; }; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const updateDataSheet = async (id: string, updates: any, organizationId?: string) => { - // SECURITY FIX: Allow update if: - // 1. Matches ID AND Matches Organization - // 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it! - - const oldSheet = await TechnicalDataSheet.findById(id); - if (!oldSheet) return null; - - if (organizationId && oldSheet.organizationId && oldSheet.organizationId !== organizationId) { - console.warn(`Access Denied: Sheet ${id} belongs to ${oldSheet.organizationId}, user is ${organizationId}`); - return null; // Return null effectively hides it or acts as fail - } - - // If new file is uploaded (path exists locally) - if (updates.fileUrl && updates.fileUrl !== oldSheet.fileUrl && fs.existsSync(updates.fileUrl)) { - // Upload new file - const newFileId = await saveFileToGridFS(updates.fileUrl, (updates.name || oldSheet.name) + '.pdf'); - - // Delete old file from GridFS - if (oldSheet.fileUrl && !oldSheet.fileUrl.startsWith('http')) { - await deleteFileFromGridFS(oldSheet.fileUrl); - } - - updates.fileUrl = newFileId; - } - - if (organizationId && !oldSheet.organizationId) { - updates.organizationId = organizationId; - } - - const updated = await TechnicalDataSheet.findOneAndUpdate({ _id: id }, updates, { new: true }).lean(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } - return null; -}; - -export const migrateFilesToGridFS = async () => { - try { - const sheets = await TechnicalDataSheet.find({ fileUrl: { $regex: /^uploads\// } }); - console.log(`[MIGRATION] Found ${sheets.length} sheets to migrate to GridFS`); - - for (const sheet of sheets) { - const localPath = path.join(process.cwd(), sheet.fileUrl); - if (fs.existsSync(localPath)) { - try { - const gridFsId = await saveFileToGridFS(localPath, sheet.name + '.pdf'); - sheet.fileUrl = gridFsId; - await sheet.save(); - console.log(`[MIGRATION] Successfully migrated: ${sheet.name}`); - } catch (err) { - console.error(`[MIGRATION] Error migrating ${sheet.name}:`, err); - } - } else { - console.warn(`[MIGRATION] File not found for ${sheet.name}: ${localPath}`); - } - } - } catch (error) { - console.error('[MIGRATION] Migration failed:', error); - } -}; - - - - diff --git a/src/server/services/fileService.ts b/src/server/services/fileService.ts new file mode 100644 index 0000000..7f38e86 --- /dev/null +++ b/src/server/services/fileService.ts @@ -0,0 +1,18 @@ +import { query } from '../config/database.js'; + +export const saveFile = async (filename: string, contentType: string, data: Buffer) => { + const result = await query( + 'INSERT INTO gpi.files (filename, content_type, data, size) VALUES ($1, $2, $3, $4) RETURNING *', + [filename, contentType, data, data.length] + ); + return result.rows[0]; +}; + +export const getFileById = async (id: string) => { + const result = await query('SELECT * FROM gpi.files WHERE id = $1', [id]); + return result.rows[0]; +}; + +export const deleteFile = async (id: string) => { + await query('DELETE FROM gpi.files WHERE id = $1', [id]); +}; diff --git a/src/server/services/inspectionService.ts b/src/server/services/inspectionService.ts index a8e8436..9636078 100644 --- a/src/server/services/inspectionService.ts +++ b/src/server/services/inspectionService.ts @@ -1,81 +1,155 @@ -import Inspection from '../models/Inspection.js'; +import { query } from '../config/database.js'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const createInspection = async (data: any & { organizationId?: string, createdBy?: string }) => { - const newInspection = new Inspection({ - ...data, - date: data.date ? new Date(data.date) : null, - organizationId: data.organizationId, - createdBy: data.createdBy - }); - const saved = await newInspection.save(); - return { ...saved.toObject(), id: saved._id.toString() }; +interface InspectionData { + projectId: string; + partId: string; + type: string; + status: string; + notes?: string; + inspectorId?: string; + organizationId?: string; + createdBy?: string; +} + +export const createInspection = async (data: InspectionData) => { + const { + projectId, + partId, + type, + status, + notes, + inspectorId, + organizationId + } = data; + + const result = await query( + `INSERT INTO gpi.inspections ( + project_id, + part_id, + type, + status, + notes, + inspector_id, + organization_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [projectId, partId, type, status, notes, inspectorId, organizationId] + ); + + return result.rows[0]; }; export const getInspectionsByProject = async (projectId: string, organizationId?: string) => { - const query = { projectId, ...(organizationId ? { organizationId } : {}) }; - const inspections = await Inspection.find(query).sort({ date: -1 }).lean(); - return inspections.map(i => ({ ...i, id: i._id.toString() })); + let sql = 'SELECT * FROM gpi.inspections WHERE project_id = $1'; + const params: any[] = [projectId]; + + if (organizationId) { + sql += ' AND organization_id = $2'; + params.push(organizationId); + } + + sql += ' ORDER BY created_at DESC'; + + const result = await query(sql, params); + return result.rows; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const updateInspection = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => { - const existing = await Inspection.findById(id); - if (!existing) return null; +export const updateInspection = async ( + id: string, + data: Partial, + organizationId?: string, + userId?: string, + userRole?: string, + isDeveloper: boolean = false +) => { + // First check if exists and permissions + const checkSql = 'SELECT * FROM gpi.inspections WHERE id = $1'; + const checkResult = await query(checkSql, [id]); + + if (checkResult.rows.length === 0) return null; + const existing = checkResult.rows[0]; - // Organization Check - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { - return null; - } - - // Role/Ownership check - const isPowerUser = userRole === 'admin' || isDeveloper; - if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) { - console.warn(`Permission Denied: User ${userId} tried to update inspection ${id} created by ${existing.createdBy}`); - return null; - } - - const updateData = { - ...data, - date: data.date ? new Date(data.date) : undefined - }; - - if (organizationId && !existing.organizationId) { - updateData.organizationId = organizationId; - } - - const updated = await Inspection.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } + // Organization Check + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { return null; + } + + // Role/Ownership check (using inspector_id as owner for now as created_by isn't in original schema but in model) + // Wait, let's check the schema again. There is no created_by in inspections table in schema.sql. + // There is inspector_id. + const isPowerUser = userRole === 'admin' || isDeveloper; + if (!isPowerUser && existing.inspector_id && existing.inspector_id !== userId) { + return null; + } + + const fields: string[] = []; + const params: any[] = [id]; + let paramIdx = 2; + + if (data.type) { + fields.push(`type = $${paramIdx++}`); + params.push(data.type); + } + if (data.status) { + fields.push(`status = $${paramIdx++}`); + params.push(data.status); + } + if (data.notes !== undefined) { + fields.push(`notes = $${paramIdx++}`); + params.push(data.notes); + } + if (data.inspectorId) { + fields.push(`inspector_id = $${paramIdx++}`); + params.push(data.inspectorId); + } + + if (fields.length === 0) return existing; + + const updateSql = ` + UPDATE gpi.inspections + SET ${fields.join(', ')}, updated_at = NOW() + WHERE id = $1 + RETURNING *`; + + const result = await query(updateSql, params); + return result.rows[0]; }; -export const deleteInspection = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => { - const existing = await Inspection.findById(id); - if (!existing) return false; +export const deleteInspection = async ( + id: string, + organizationId?: string, + userId?: string, + userRole?: string, + isDeveloper: boolean = false +) => { + const checkSql = 'SELECT * FROM gpi.inspections WHERE id = $1'; + const checkResult = await query(checkSql, [id]); + + if (checkResult.rows.length === 0) return false; + const existing = checkResult.rows[0]; - // Organization Check - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { - return false; - } + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { + return false; + } - // Role/Ownership check - const isPowerUser = userRole === 'admin' || isDeveloper; - if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) { - return false; - } + const isPowerUser = userRole === 'admin' || isDeveloper; + if (!isPowerUser && existing.inspector_id && existing.inspector_id !== userId) { + return false; + } - await Inspection.deleteOne({ _id: id }); - return true; + await query('DELETE FROM gpi.inspections WHERE id = $1', [id]); + return true; }; export const getAllInspections = async (organizationId?: string) => { - const query = organizationId ? { organizationId } : {}; - const inspections = await Inspection.find(query).lean(); - return inspections.map(i => ({ ...i, id: i._id.toString() })); + let sql = 'SELECT * FROM gpi.inspections'; + const params: any[] = []; + + if (organizationId) { + sql += ' WHERE organization_id = $1'; + params.push(organizationId); + } + + const result = await query(sql, params); + return result.rows; }; - - - diff --git a/src/server/services/instrumentService.ts b/src/server/services/instrumentService.ts new file mode 100644 index 0000000..fc48a6d --- /dev/null +++ b/src/server/services/instrumentService.ts @@ -0,0 +1,91 @@ +import { query } from '../config/database.js'; + +interface InstrumentData { + organizationId: string; + name: string; + type: string; + manufacturer?: string; + modelName?: string; + serialNumber: string; + calibrationDate?: Date; + calibrationExpirationDate?: Date; + certificateUrl?: string; + status?: string; + notes?: string; +} + +export const createInstrument = async (data: InstrumentData) => { + const result = await query( + `INSERT INTO gpi.instruments ( + organization_id, name, type, manufacturer, model_name, + serial_number, calibration_date, calibration_expiration_date, + certificate_url, status, notes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + data.organizationId, data.name, data.type, data.manufacturer, data.modelName, + data.serialNumber, data.calibrationDate, data.calibrationExpirationDate, + data.certificateUrl, data.status || 'active', data.notes + ] + ); + return result.rows[0]; +}; + +export const getInstruments = async (organizationId: string, status?: string) => { + let sql = 'SELECT * FROM gpi.instruments WHERE organization_id = $1'; + const params: any[] = [organizationId]; + + if (status) { + sql += ' AND status = $2'; + params.push(status); + } + + sql += ' ORDER BY name ASC'; + + const result = await query(sql, params); + return result.rows; +}; + +export const updateInstrument = async (id: string, organizationId: string, data: Partial) => { + const fields: string[] = []; + const params: any[] = [id, organizationId]; + let paramIdx = 3; + + const updatableFields = [ + 'name', 'type', 'manufacturer', 'modelName', 'serialNumber', + 'calibrationDate', 'calibrationExpirationDate', 'certificateUrl', + 'status', 'notes' + ]; + + for (const key of updatableFields) { + if ((data as any)[key] !== undefined) { + const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + fields.push(`${dbKey} = $${paramIdx++}`); + params.push((data as any)[key]); + } + } + + if (fields.length === 0) { + const res = await query('SELECT * FROM gpi.instruments WHERE id = $1 AND organization_id = $2', [id, organizationId]); + return res.rows[0]; + } + + const updateSql = ` + UPDATE gpi.instruments + SET ${fields.join(', ')}, updated_at = NOW() + WHERE id = $1 AND organization_id = $2 + RETURNING *`; + + const result = await query(updateSql, params); + return result.rows[0]; +}; + +export const deleteInstrument = async (id: string, organizationId: string) => { + const result = await query('DELETE FROM gpi.instruments WHERE id = $1 AND organization_id = $2', [id, organizationId]); + return result.rowCount > 0; +}; + +export const checkSerialNumberExists = async (organizationId: string, serialNumber: string) => { + const result = await query('SELECT id FROM gpi.instruments WHERE organization_id = $1 AND serial_number = $2', [organizationId, serialNumber]); + return result.rows.length > 0; +}; diff --git a/src/server/services/paintingSchemeService.ts b/src/server/services/paintingSchemeService.ts index 5058162..3a6f398 100644 --- a/src/server/services/paintingSchemeService.ts +++ b/src/server/services/paintingSchemeService.ts @@ -1,72 +1,94 @@ -import PaintingScheme from '../models/PaintingScheme.js'; +import { query } from '../config/database.js'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const createPaintingScheme = async (data: any & { organizationId?: string }) => { - const newScheme = new PaintingScheme({ ...data, organizationId: data.organizationId }); - const saved = await newScheme.save(); - return { ...saved.toObject(), id: saved._id.toString() }; +interface PaintingSchemeData { + organizationId?: string; + projectId?: string; + name: string; + description?: string; +} + +export const createPaintingScheme = async (data: PaintingSchemeData) => { + const result = await query( + `INSERT INTO gpi.painting_schemes (organization_id, project_id, name, description) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [data.organizationId, data.projectId, data.name, data.description] + ); + return result.rows[0]; }; export const getPaintingSchemesByProject = async (projectId: string, organizationId?: string) => { - const query = { projectId, ...(organizationId ? { organizationId } : {}) }; - const schemes = await PaintingScheme.find(query).lean(); - return schemes.map(s => ({ ...s, id: s._id.toString() })); + let sql = 'SELECT * FROM gpi.painting_schemes WHERE project_id = $1'; + const params: any[] = [projectId]; + + if (organizationId) { + sql += ' AND organization_id = $2'; + params.push(organizationId); + } + + const result = await query(sql, params); + return result.rows; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const updatePaintingScheme = async (id: string, data: any, organizationId?: string) => { - // SECURITY FIX: Allow update if: - // 1. Matches ID AND Matches Organization - // 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it! +export const updatePaintingScheme = async (id: string, data: Partial, organizationId?: string) => { + const checkResult = await query('SELECT * FROM gpi.painting_schemes WHERE id = $1', [id]); + if (checkResult.rows.length === 0) return null; + const existing = checkResult.rows[0]; - let query: any = { _id: id }; - - // First, check if the record exists and what is its state - const existing = await PaintingScheme.findById(id); - - if (!existing) return null; - - // Check ownership - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { - // Exists but belongs to ANOTHER organization -> Deny - console.warn(`Access Denied: Scheme ${id} belongs to ${existing.organizationId}, user is ${organizationId}`); - return null; // Return null effectively hides it or acts as fail - } - - // If we passed the check, we perform the update. - // Ensure we "adopt" the record if it didn't have an orgId - if (organizationId && !data.organizationId) { - data.organizationId = organizationId; - } - - const updated = await PaintingScheme.findOneAndUpdate({ _id: id }, data, { new: true }).lean(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { return null; + } + + const fields: string[] = []; + const params: any[] = [id]; + let paramIdx = 2; + + if (data.name) { + fields.push(`name = $${paramIdx++}`); + params.push(data.name); + } + if (data.description !== undefined) { + fields.push(`description = $${paramIdx++}`); + params.push(data.description); + } + if (data.projectId) { + fields.push(`project_id = $${paramIdx++}`); + params.push(data.projectId); + } + + if (fields.length === 0) return existing; + + const updateSql = ` + UPDATE gpi.painting_schemes + SET ${fields.join(', ')}, updated_at = NOW() + WHERE id = $1 + RETURNING *`; + + const result = await query(updateSql, params); + return result.rows[0]; }; export const deletePaintingScheme = async (id: string, organizationId?: string) => { - // Find first to check permissions - const existing = await PaintingScheme.findById(id); - if (!existing) return; + const checkResult = await query('SELECT * FROM gpi.painting_schemes WHERE id = $1', [id]); + if (checkResult.rows.length === 0) return; + const existing = checkResult.rows[0]; - // Permissions: - // If user has org, and item has OTHER org, deny. - if (organizationId && existing.organizationId && existing.organizationId !== organizationId) { - console.warn(`[Delete PaintingScheme] Access Denied. User Org: ${organizationId}, Scheme Org: ${existing.organizationId}`); - return; - } + if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { + return; + } - await PaintingScheme.findByIdAndDelete(id); + await query('DELETE FROM gpi.painting_schemes WHERE id = $1', [id]); }; export const getAllSchemes = async (organizationId?: string) => { - const query = organizationId ? { organizationId } : {}; - const schemes = await PaintingScheme.find(query).lean(); - return schemes.map(s => ({ ...s, id: s._id.toString() })); + let sql = 'SELECT * FROM gpi.painting_schemes'; + const params: any[] = []; + + if (organizationId) { + sql += ' WHERE organization_id = $1'; + params.push(organizationId); + } + + const result = await query(sql, params); + return result.rows; }; - - - diff --git a/src/server/services/partService.ts b/src/server/services/partService.ts index 19f394f..90f8e94 100644 --- a/src/server/services/partService.ts +++ b/src/server/services/partService.ts @@ -1,60 +1,114 @@ -import Part from '../models/Part.js'; +import { query } from '../config/database.js'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const createPart = async (data: any & { organizationId?: string }) => { - const newPart = new Part({ ...data, organizationId: data.organizationId }); - const saved = await newPart.save(); - return { ...saved.toObject(), id: saved._id.toString() }; +interface PartData { + projectId: string; + organizationId?: string; + name: string; + description?: string; + quantity?: number; + weightKg?: number; + drawingNumber?: string; +} + +export const createPart = async (data: PartData) => { + const result = await query( + `INSERT INTO gpi.parts ( + project_id, organization_id, name, description, quantity, weight_kg, drawing_number + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + data.projectId, + data.organizationId, + data.name, + data.description, + data.quantity || 1, + data.weightKg, + data.drawingNumber + ] + ); + return result.rows[0]; }; export const getPartsByProject = async (projectId: string, organizationId?: string, isGlobalAdmin: boolean = false) => { - const query = isGlobalAdmin - ? { projectId } - : { projectId, $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }; - const parts = await Part.find(query).lean(); - return parts.map(p => ({ ...p, id: p._id.toString() })); + let sql = 'SELECT * FROM gpi.parts WHERE project_id = $1'; + const params: any[] = [projectId]; + + if (!isGlobalAdmin && organizationId) { + sql += ' AND (organization_id = $2 OR organization_id IS NULL)'; + params.push(organizationId); + } + + const result = await query(sql, params); + return result.rows; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const updatePart = async (id: string, data: any, organizationId?: string, isGlobalAdmin: boolean = false) => { - const existing = await Part.findById(id); - if (!existing) return null; +export const updatePart = async (id: string, data: Partial, organizationId?: string, isGlobalAdmin: boolean = false) => { + const checkResult = await query('SELECT * FROM gpi.parts WHERE id = $1', [id]); + if (checkResult.rows.length === 0) return null; + const existing = checkResult.rows[0]; - if (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) { - console.warn(`Access Denied: Part ${id} belongs to ${existing.organizationId}, user is ${organizationId}`); - return null; - } - - if (organizationId && !existing.organizationId) { - data.organizationId = organizationId; // Adopt - } - - const updated = await Part.findOneAndUpdate({ _id: id }, data, { new: true }).lean(); - if (updated) { - return { ...updated, id: updated._id.toString() }; - } + if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) { return null; + } + + const fields: string[] = []; + const params: any[] = [id]; + let paramIdx = 2; + + if (data.name) { + fields.push(`name = $${paramIdx++}`); + params.push(data.name); + } + if (data.description !== undefined) { + fields.push(`description = $${paramIdx++}`); + params.push(data.description); + } + if (data.quantity !== undefined) { + fields.push(`quantity = $${paramIdx++}`); + params.push(data.quantity); + } + if (data.weightKg !== undefined) { + fields.push(`weight_kg = $${paramIdx++}`); + params.push(data.weightKg); + } + if (data.drawingNumber !== undefined) { + fields.push(`drawing_number = $${paramIdx++}`); + params.push(data.drawingNumber); + } + + if (fields.length === 0) return existing; + + const updateSql = ` + UPDATE gpi.parts + SET ${fields.join(', ')}, updated_at = NOW() + WHERE id = $1 + RETURNING *`; + + const result = await query(updateSql, params); + return result.rows[0]; }; export const deletePart = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => { - const part = await Part.findById(id); - if (!part) return; + const checkResult = await query('SELECT * FROM gpi.parts WHERE id = $1', [id]); + if (checkResult.rows.length === 0) return; + const existing = checkResult.rows[0]; - if (!isGlobalAdmin && organizationId && part.organizationId && part.organizationId !== organizationId) { - throw new Error('Sem permissão para excluir esta peça'); - } + if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) { + throw new Error('Sem permissão para excluir esta peça'); + } - await Part.findByIdAndDelete(id); + await query('DELETE FROM gpi.parts WHERE id = $1', [id]); }; export const getAllParts = async (organizationId?: string, isGlobalAdmin: boolean = false) => { - const query = isGlobalAdmin - ? {} - : { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }; - const parts = await Part.find(query).lean(); - return parts.map(p => ({ ...p, id: p._id.toString() })); + let sql = 'SELECT * FROM gpi.parts'; + const params: any[] = []; + + if (!isGlobalAdmin && organizationId) { + sql += ' WHERE (organization_id = $1 OR organization_id IS NULL)'; + params.push(organizationId); + } + + const result = await query(sql, params); + return result.rows; }; - - - diff --git a/src/server/services/projectService.ts b/src/server/services/projectService.ts index e66778a..544b5a4 100644 --- a/src/server/services/projectService.ts +++ b/src/server/services/projectService.ts @@ -8,6 +8,7 @@ interface ProjectData { technician?: string; environment?: string; weightKg?: number; + status?: string; } export const createProject = async (data: ProjectData & { organizationId?: string }) => { @@ -24,7 +25,7 @@ export const getDashboardProjects = async (organizationId?: string) => { (SELECT json_agg(s) FROM gpi.painting_schemes s WHERE s.project_id = p.id) as schemes, (SELECT SUM(weight_kg) FROM gpi.inspections i WHERE i.project_id = p.id) as painted_weight FROM gpi.projects p - WHERE p.organization_id = $1 OR $1 IS NULL + WHERE (p.organization_id = $1 OR $1 IS NULL) AND p.status = 'active' ORDER BY p.name ASC `; const res = await query(sql, [organizationId || null]); @@ -58,7 +59,7 @@ export const getProjectById = async (id: string, organizationId?: string, isGlob const [parts, schemes, records, inspections] = await Promise.all([ query('SELECT * FROM gpi.parts WHERE project_id = $1', [id]), query('SELECT * FROM gpi.painting_schemes WHERE project_id = $1', [id]), - query('SELECT * FROM gpi.application_records WHERE project_id = $1', [id]), // Assuming this table exists in gpi + query('SELECT * FROM gpi.application_records WHERE project_id = $1', [id]), query('SELECT * FROM gpi.inspections WHERE project_id = $1', [id]) ]); @@ -72,8 +73,29 @@ export const getProjectById = async (id: string, organizationId?: string, isGlob }; export const updateProject = async (id: string, data: Partial, organizationId?: string, isGlobalAdmin: boolean = false) => { - // Basic update logic - const res = await query('UPDATE gpi.projects SET name = COALESCE($1, name), client = COALESCE($2, client), updated_at = NOW() WHERE id = $3 RETURNING *', [data.name, data.client, id]); + const fields: string[] = []; + const params: any[] = [id]; + let paramIdx = 2; + + const updatableFields = ['name', 'client', 'startDate', 'endDate', 'technician', 'environment', 'weightKg', 'status']; + + for (const key of updatableFields) { + if ((data as any)[key] !== undefined) { + const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + fields.push(`${dbKey} = $${paramIdx++}`); + params.push((data as any)[key]); + } + } + + if (fields.length === 0) return null; + + const sql = `UPDATE gpi.projects SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1 RETURNING *`; + const res = await query(sql, params); + return res.rows[0]; +}; + +export const archiveProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => { + const res = await query("UPDATE gpi.projects SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *", [id]); return res.rows[0]; }; diff --git a/src/server/services/stockItemService.ts b/src/server/services/stockItemService.ts new file mode 100644 index 0000000..2c10a64 --- /dev/null +++ b/src/server/services/stockItemService.ts @@ -0,0 +1,163 @@ +import { query } from '../config/database.js'; + +export const createStockItem = async (data: any) => { + const result = await query( + `INSERT INTO gpi.stock_items ( + organization_id, created_by, data_sheet_id, rr_number, batch_number, + color, invoice_number, received_by, quantity, unit, min_stock, + expiration_date, notes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + data.organizationId, data.createdBy, data.dataSheetId, data.rrNumber, data.batchNumber, + data.color, data.invoiceNumber, data.receivedBy, data.quantity, data.unit, data.minStock, + data.expirationDate, data.notes + ] + ); + return result.rows[0]; +}; + +export const createStockMovement = async (data: any) => { + const result = await query( + `INSERT INTO gpi.stock_movements ( + organization_id, created_by, stock_item_id, movement_number, type, + quantity, date, responsible, reason, requester, notes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + data.organizationId, data.createdBy, data.stockItemId, data.movementNumber, data.type, + data.quantity, data.date || new Date(), data.responsible, data.reason, data.requester, data.notes + ] + ); + return result.rows[0]; +}; + +export const getStockItems = async (organizationId: string, dataSheetId?: string) => { + let sql = ` + SELECT si.*, ds.name as "dataSheetName", ds.manufacturer as "dataSheetManufacturer" + FROM gpi.stock_items si + LEFT JOIN gpi.technical_data_sheets ds ON si.data_sheet_id = ds.id + WHERE si.organization_id = $1`; + const params: any[] = [organizationId]; + + if (dataSheetId) { + sql += ' AND si.data_sheet_id = $2'; + params.push(dataSheetId); + } + + sql += ' ORDER BY si.expiration_date ASC'; + + const result = await query(sql, params); + return result.rows; +}; + +export const getStockItemById = async (id: string, organizationId: string) => { + const result = await query( + `SELECT si.*, ds.name as "dataSheetName", ds.manufacturer as "dataSheetManufacturer" + FROM gpi.stock_items si + LEFT JOIN gpi.technical_data_sheets ds ON si.data_sheet_id = ds.id + WHERE si.id = $1 AND si.organization_id = $2`, + [id, organizationId] + ); + return result.rows[0]; +}; + +export const updateStockItem = async (id: string, organizationId: string, data: any) => { + const fields: string[] = []; + const params: any[] = [id, organizationId]; + let idx = 3; + + for (const key in data) { + if (data[idx] !== undefined && key !== 'id' && key !== 'organizationId') { + const dbKey = key.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`); + fields.push(`${dbKey} = $${idx++}`); + params.push(data[key]); + } + } + + // Manual loop just in case + const updatable = ['notes', 'expirationDate', 'minStock', 'invoiceNumber', 'receivedBy', 'batchNumber', 'rrNumber', 'color']; + const finalFields = []; + const finalParams = [id, organizationId]; + let pIdx = 3; + + for (const key of updatable) { + if (data[key] !== undefined) { + const dbKey = key.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`); + finalFields.push(`${dbKey} = $${pIdx++}`); + finalParams.push(data[key]); + } + } + + if (finalFields.length === 0) return null; + + const sql = `UPDATE gpi.stock_items SET ${finalFields.join(', ')}, updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING *`; + const res = await query(sql, finalParams); + return res.rows[0]; +}; + +export const updateStockItemQuantity = async (id: string, newQuantity: number) => { + await query('UPDATE gpi.stock_items SET quantity = $1, updated_at = NOW() WHERE id = $2', [newQuantity, id]); +}; + +export const deleteStockItem = async (id: string, organizationId: string) => { + await query('DELETE FROM gpi.stock_movements WHERE stock_item_id = $1', [id]); + await query('DELETE FROM gpi.stock_audit_logs WHERE stock_item_id = $1', [id]); + const res = await query('DELETE FROM gpi.stock_items WHERE id = $1 AND organization_id = $2', [id, organizationId]); + return res.rowCount > 0; +}; + +export const getStockMovements = async (stockItemId: string, organizationId: string) => { + const result = await query( + 'SELECT * FROM gpi.stock_movements WHERE stock_item_id = $1 AND organization_id = $2 ORDER BY date DESC', + [stockItemId, organizationId] + ); + return result.rows; +}; + +export const getLatestMovementNumber = async (stockItemId: string) => { + const result = await query( + 'SELECT MAX(movement_number) as max FROM gpi.stock_movements WHERE stock_item_id = $1', + [stockItemId] + ); + return result.rows[0].max || 0; +}; + +export const getStockMovementById = async (id: string, organizationId: string) => { + const res = await query('SELECT * FROM gpi.stock_movements WHERE id = $1 AND organization_id = $2', [id, organizationId]); + return res.rows[0]; +}; + +export const updateStockMovement = async (id: string, organizationId: string, data: any) => { + const res = await query( + 'UPDATE gpi.stock_movements SET quantity = COALESCE($1, quantity), date = COALESCE($2, date), notes = COALESCE($3, notes), updated_at = NOW() WHERE id = $4 AND organization_id = $5 RETURNING *', + [data.quantity, data.date, data.notes, id, organizationId] + ); + return res.rows[0]; +}; + +export const deleteStockMovement = async (id: string, organizationId: string) => { + const res = await query('DELETE FROM gpi.stock_movements WHERE id = $1 AND organization_id = $2', [id, organizationId]); + return res.rowCount > 0; +}; + +export const createAuditLog = async (data: any) => { + await query( + `INSERT INTO gpi.stock_audit_logs ( + organization_id, stock_item_id, movement_id, movement_number, + user_id, user_name, action, details, old_values, new_values + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + data.organizationId, data.stockItemId, data.movementId, data.movementNumber, + data.userId, data.userName, data.action, data.details, data.oldValues, data.newValues + ] + ); +}; + +export const getStockAuditLogs = async (stockItemId: string, organizationId: string) => { + const res = await query( + 'SELECT * FROM gpi.stock_audit_logs WHERE stock_item_id = $1 AND organization_id = $2 ORDER BY timestamp DESC', + [stockItemId, organizationId] + ); + return res.rows; +}; diff --git a/src/server/services/userService.ts b/src/server/services/userService.ts new file mode 100644 index 0000000..b25fe48 --- /dev/null +++ b/src/server/services/userService.ts @@ -0,0 +1,145 @@ +import { query } from '../config/database.js'; + +export const syncUser = async (data: { externalId: string, email: string, name: string, organizationId?: string, clerkRole?: string }) => { + const { externalId, email, name, organizationId, clerkRole } = data; + + let result = await query( + `INSERT INTO gpi.users (clerk_id, email, name, role, updated_at) + VALUES ($1, $2, $3, 'guest', NOW()) + ON CONFLICT (clerk_id) DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name, updated_at = NOW() + RETURNING *`, + [externalId, email, name] + ); + + let user = result.rows[0]; + + if (organizationId) { + let appRole = 'guest'; + if (clerkRole === 'org:admin') appRole = 'admin'; + else if (clerkRole === 'org:member') appRole = 'user'; + + const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]); + if (orgResult.rows.length > 0) { + const orgUuid = orgResult.rows[0].id; + + await query( + `INSERT INTO gpi.user_organizations (user_id, organization_id, role, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (user_id, organization_id) DO NOTHING`, + [user.id, orgUuid, appRole] + ); + + const memberResult = await query( + 'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2', + [user.id, orgUuid] + ); + + return { + ...user, + organizationRole: memberResult.rows[0].role, + organizationBanned: memberResult.rows[0].is_banned + }; + } + } + + return user; +}; + +export const getCurrentUser = async (externalId: string, organizationId?: string) => { + const userResult = await query('SELECT * FROM gpi.users WHERE clerk_id = $1 OR logto_id = $1', [externalId]); + if (userResult.rows.length === 0) return null; + const user = userResult.rows[0]; + + if (organizationId) { + const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]); + if (orgResult.rows.length > 0) { + const orgUuid = orgResult.rows[0].id; + const memberResult = await query( + 'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2', + [user.id, orgUuid] + ); + if (memberResult.rows.length > 0) { + return { + ...user, + role: memberResult.rows[0].role, + isBanned: memberResult.rows[0].is_banned, + organizationId + }; + } + } + } + return user; +}; + +export const getAllUsersInOrg = async (organizationId: string) => { + const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]); + if (orgResult.rows.length === 0) return []; + const orgUuid = orgResult.rows[0].id; + + const results = await query( + `SELECT u.*, uo.role as organization_role, uo.is_banned as organization_banned, uo.id as member_id + FROM gpi.users u + JOIN gpi.user_organizations uo ON u.id = uo.user_id + WHERE uo.organization_id = $1 + ORDER BY uo.created_at DESC`, + [orgUuid] + ); + return results.rows; +}; + +export const updateUserRole = async (userIdOrMemberId: string, organizationId: string, role: string) => { + const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]); + if (orgResult.rows.length === 0) return null; + const orgUuid = orgResult.rows[0].id; + + // Try update by member_id or user_id + const result = await query( + 'UPDATE gpi.user_organizations SET role = $1, updated_at = NOW() WHERE (id = $2 OR user_id = $2) AND organization_id = $3 RETURNING *', + [role, userIdOrMemberId, orgUuid] + ); + return result.rows[0]; +}; + +export const toggleBanUser = async (userIdOrMemberId: string, organizationId: string, isBanned: boolean) => { + const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]); + if (orgResult.rows.length === 0) return null; + const orgUuid = orgResult.rows[0].id; + + const result = await query( + 'UPDATE gpi.user_organizations SET is_banned = $1, updated_at = NOW() WHERE (id = $2 OR user_id = $2) AND organization_id = $3 RETURNING *', + [isBanned, userIdOrMemberId, orgUuid] + ); + return result.rows[0]; +}; + +export const heartbeat = async (userId: string) => { + await query('UPDATE gpi.users SET last_seen_at = NOW() WHERE id = $1', [userId]); +}; + +export const getActiveUsers = async (organizationId: string, excludeUserId?: string) => { + const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]); + if (orgResult.rows.length === 0) return []; + const orgUuid = orgResult.rows[0].id; + + const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); + const result = await query( + `SELECT u.id, u.name, u.email, u.last_seen_at, u.clerk_id as "externalId" + FROM gpi.users u + JOIN gpi.user_organizations uo ON u.id = uo.user_id + WHERE uo.organization_id = $1 AND u.last_seen_at >= $2 AND u.id != $3`, + [orgUuid, twoMinutesAgo, excludeUserId || '00000000-0000-0000-0000-000000000000'] + ); + return result.rows; +}; + +export const deleteUserFromOrg = async (userIdOrMemberId: string, organizationId: string) => { + const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]); + if (orgResult.rows.length === 0) return false; + const orgUuid = orgResult.rows[0].id; + + const result = await query( + 'DELETE FROM gpi.user_organizations WHERE (id = $1 OR user_id = $1) AND organization_id = $2', + [userIdOrMemberId, orgUuid] + ); + return result.rowCount > 0; +};