Migrate major controllers and services to PostgreSQL (gpi schema), fix build errors, add file and audit log support

This commit is contained in:
2026-03-16 08:16:14 -03:00
parent 8c247d8afd
commit e88d145df7
18 changed files with 1212 additions and 998 deletions

View File

@@ -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<string, any> = {
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);

View File

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

View File

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

View File

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

View File

@@ -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<string, string> = { 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<string, string> = { 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';

View File

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