chore: synchronize local fixes to gitea
This commit is contained in:
74
src/server/app.ts
Normal file
74
src/server/app.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import projectRoutes from './routes/projectRoutes.js';
|
||||
import partRoutes from './routes/partRoutes.js';
|
||||
import paintingSchemeRoutes from './routes/paintingSchemeRoutes.js';
|
||||
import applicationRecordRoutes from './routes/applicationRecordRoutes.js';
|
||||
import inspectionRoutes from './routes/inspectionRoutes.js';
|
||||
import analysisRoutes from './routes/analysisRoutes.js';
|
||||
import dataSheetRoutes from './routes/dataSheetRoutes.js';
|
||||
import yieldStudyRoutes from './routes/yieldStudyRoutes.js';
|
||||
import userRoutes from './routes/userRoutes.js';
|
||||
import systemSettingsRoutes from './routes/systemSettingsRoutes.js';
|
||||
import geometryTypeRoutes from './routes/geometryTypeRoutes.js';
|
||||
|
||||
import stockRoutes from './routes/stockRoutes.js';
|
||||
import notificationRoutes from './routes/notificationRoutes.js';
|
||||
import instrumentRoutes from './routes/instrumentRoutes.js';
|
||||
import messageRoutes from './routes/messageRoutes.js';
|
||||
import backupRoutes from './routes/backupRoutes.js';
|
||||
import path from 'path';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({
|
||||
origin: '*', // Be more specific in production
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'x-clerk-user-id', 'x-organization-id']
|
||||
}));
|
||||
app.use(express.json());
|
||||
import { extractUser } from './middleware/roleMiddleware.js';
|
||||
|
||||
// LOG DE DEPURAÇÃO PARA CONEXÃO
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} - ClerkID: ${req.headers['x-clerk-user-id'] || 'None'}`);
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(extractUser);
|
||||
|
||||
// Static Uploads
|
||||
import fs from 'fs';
|
||||
const uploadsPath = path.join(process.cwd(), 'uploads');
|
||||
|
||||
// Ensure uploads directory exists
|
||||
if (!fs.existsSync(uploadsPath)) {
|
||||
fs.mkdirSync(uploadsPath, { recursive: true });
|
||||
}
|
||||
|
||||
app.use('/uploads', express.static(uploadsPath));
|
||||
|
||||
// Routes
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/projects', projectRoutes);
|
||||
app.use('/api/parts', partRoutes);
|
||||
app.use('/api/painting-schemes', paintingSchemeRoutes);
|
||||
app.use('/api/application-records', applicationRecordRoutes);
|
||||
app.use('/api/inspections', inspectionRoutes);
|
||||
app.use('/api', analysisRoutes);
|
||||
app.use('/api/datasheets', dataSheetRoutes);
|
||||
app.use('/api/datasheets', dataSheetRoutes);
|
||||
app.use('/api/yield-studies', yieldStudyRoutes);
|
||||
app.use('/api/system-settings', systemSettingsRoutes);
|
||||
app.use('/api/geometry-types', geometryTypeRoutes);
|
||||
app.use('/api/stock', stockRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/instruments', instrumentRoutes);
|
||||
app.use('/api/messages', messageRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date() });
|
||||
});
|
||||
|
||||
export default app;
|
||||
46
src/server/config/database.ts
Normal file
46
src/server/config/database.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { GridFSBucket } from 'mongodb';
|
||||
|
||||
export let bucket: GridFSBucket;
|
||||
|
||||
export const connectDB = async () => {
|
||||
try {
|
||||
const uri = process.env.MONGODB_URI;
|
||||
if (!uri) {
|
||||
throw new Error('MONGODB_URI is not defined in environment variables');
|
||||
}
|
||||
|
||||
if (mongoose.connection.readyState >= 1) {
|
||||
console.log('Using existing MongoDB connection');
|
||||
if (!bucket && mongoose.connection.db) {
|
||||
bucket = new GridFSBucket(mongoose.connection.db, { bucketName: 'pdfs' });
|
||||
console.log('✅ GridFS Bucket re-initialized');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Connecting to MongoDB...');
|
||||
if (!uri) console.error('MONGODB_URI is undefined!');
|
||||
|
||||
await mongoose.connect(uri, {
|
||||
maxPoolSize: 10,
|
||||
serverSelectionTimeoutMS: 5000,
|
||||
socketTimeoutMS: 45000,
|
||||
});
|
||||
console.log('✅ MongoDB connected successfully');
|
||||
|
||||
const db = mongoose.connection.db;
|
||||
if (!db) {
|
||||
throw new Error('Database connection not established');
|
||||
}
|
||||
bucket = new GridFSBucket(db, {
|
||||
bucketName: 'pdfs'
|
||||
});
|
||||
console.log('✅ GridFS Bucket initialized');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ MongoDB connection error:', error);
|
||||
console.warn('⚠️ Server will continue running for debugging, but database features will be unavailable.');
|
||||
// process.exit(1);
|
||||
}
|
||||
};
|
||||
153
src/server/controllers/analysisController.ts
Normal file
153
src/server/controllers/analysisController.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as applicationRecordService from '../services/applicationRecordService.js';
|
||||
import * as paintingSchemeService from '../services/paintingSchemeService.js';
|
||||
import * as inspectionService from '../services/inspectionService.js';
|
||||
|
||||
interface AnalysisResult {
|
||||
pieceDescription: string;
|
||||
schemeName: string;
|
||||
schemeType: string;
|
||||
|
||||
// Yield (Rendimento)
|
||||
realYield: number;
|
||||
theoreticalYield: number;
|
||||
yieldVariance: number; // percentage
|
||||
yieldStatus: 'approved' | 'warning' | 'critical';
|
||||
|
||||
// Dilution
|
||||
diluentUsed: number;
|
||||
volumeUsed: number;
|
||||
realDilution: number;
|
||||
targetDilution: number;
|
||||
dilutionStatus: 'approved' | 'warning' | 'critical';
|
||||
|
||||
// Thickness (Espessura)
|
||||
realDFT: number; // Average of dryThicknessCalc or inspection points
|
||||
minDFT: number;
|
||||
maxDFT: number;
|
||||
dftStatus: 'approved' | 'warning' | 'critical';
|
||||
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export const getProjectAnalysis = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
|
||||
const records = await applicationRecordService.getApplicationRecordsByProject(projectId as string);
|
||||
const schemes = await paintingSchemeService.getPaintingSchemesByProject(projectId as string);
|
||||
const inspections = await inspectionService.getInspectionsByProject(projectId as string);
|
||||
|
||||
const analysis: AnalysisResult[] = [];
|
||||
|
||||
// Group records by pieceDescription to handle multiple layers/records for same piece if needed
|
||||
// For simple MVP, assuming 1 record per piece for calculation or analyzing individual records
|
||||
|
||||
for (const record of records) {
|
||||
// Find scheme used (assuming application record doesn't store schemeId directly, we might need to match by name or context?
|
||||
// Checking types.ts, ApplicationRecord doesn't have schemeId. It has 'coatStage'.
|
||||
// PaintingScheme has 'type' which might match 'coatStage' or project context.
|
||||
// Without direct link, we might have to infer or maybe the user links them by name/description effectively?
|
||||
// Actually, in many cases there's only one scheme active per project or we check if schemes exist.
|
||||
// Let's try to match if possible, or use a default if only one scheme exists.
|
||||
|
||||
// Note: In a real robust system we'd link Record -> Scheme.
|
||||
// Here we will try to find a scheme that matches the coatStage or just take the first one if length is 1.
|
||||
const scheme = schemes.find(s => s.type === record.coatStage) || schemes[0];
|
||||
|
||||
if (!scheme) continue;
|
||||
|
||||
// Calculations
|
||||
|
||||
// 1. Yield
|
||||
// Yield = Area / Volume
|
||||
const realYield = (record.areaPainted && record.volumeUsed)
|
||||
? (record.areaPainted / record.volumeUsed)
|
||||
: 0;
|
||||
|
||||
const theoreticalYield = scheme.yieldTheoretical || 0;
|
||||
|
||||
const yieldData = calculateVariance(realYield, theoreticalYield);
|
||||
|
||||
// 2. Dilution
|
||||
const diluent = record.diluentUsed || 0;
|
||||
const volume = record.volumeUsed || 1; // avoid div by zero
|
||||
const realDilution = (diluent / volume) * 100;
|
||||
const targetDilution = scheme.dilution || 0;
|
||||
|
||||
const dilutionData = calculateVariance(realDilution, targetDilution); // true for "lower is not always better/worse", requires exact match?
|
||||
// Actually for dilution, usually there's a limit. Let's assume variance check.
|
||||
|
||||
// 3. DFT (Dry Film Thickness)
|
||||
// Use inspection data if available for this piece, otherwise calculated
|
||||
const pieceInspection = inspections.find(i => i.pieceDescription === record.pieceDescription);
|
||||
|
||||
let realDFT = 0;
|
||||
if (pieceInspection) {
|
||||
const points = (pieceInspection.epsPoints || []).filter((p: number | null | undefined): p is number => p !== undefined && p !== null);
|
||||
if (points.length > 0) {
|
||||
realDFT = points.reduce((a: number, b: number) => a + b, 0) / points.length;
|
||||
}
|
||||
} else {
|
||||
realDFT = record.dryThicknessCalc || 0;
|
||||
}
|
||||
|
||||
const minDFT = scheme.epsMin || 0;
|
||||
const maxDFT = scheme.epsMax || 9999;
|
||||
|
||||
let dftStatus: 'approved' | 'warning' | 'critical' = 'approved';
|
||||
if (realDFT < minDFT) dftStatus = 'critical';
|
||||
else if (realDFT > maxDFT) dftStatus = 'warning';
|
||||
|
||||
|
||||
analysis.push({
|
||||
pieceDescription: record.pieceDescription || 'Desconhecido',
|
||||
schemeName: scheme.name,
|
||||
schemeType: scheme.type || '',
|
||||
|
||||
realYield: Number(realYield.toFixed(2)),
|
||||
theoreticalYield: Number(theoreticalYield.toFixed(2)),
|
||||
yieldVariance: Number(yieldData.variance.toFixed(2)),
|
||||
yieldStatus: yieldData.status,
|
||||
|
||||
diluentUsed: diluent,
|
||||
volumeUsed: volume,
|
||||
realDilution: Number(realDilution.toFixed(1)),
|
||||
targetDilution: Number(targetDilution.toFixed(1)),
|
||||
dilutionStatus: dilutionData.status, // We might want to warn if dilution is > target
|
||||
|
||||
realDFT: Number(realDFT.toFixed(1)),
|
||||
minDFT: minDFT,
|
||||
maxDFT: maxDFT,
|
||||
dftStatus,
|
||||
|
||||
notes: []
|
||||
});
|
||||
}
|
||||
|
||||
res.json(analysis);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
function calculateVariance(actual: number, target: number): { variance: number, status: 'approved' | 'warning' | 'critical' } {
|
||||
if (!target) return { variance: 0, status: 'approved' };
|
||||
|
||||
// Variance % = ((Actual - Target) / Target) * 100
|
||||
const variance = ((actual - target) / target) * 100;
|
||||
|
||||
// Rules (Simplified)
|
||||
// Yield: Higher is usually good (efficient), but too high might mean too thin.
|
||||
// Lower means using more paint -> expensive.
|
||||
|
||||
let status: 'approved' | 'warning' | 'critical' = 'approved';
|
||||
|
||||
// If deviation is > 20% (warning) or > 30% (critical)
|
||||
if (Math.abs(variance) > 30) status = 'critical';
|
||||
else if (Math.abs(variance) > 20) status = 'warning';
|
||||
|
||||
return { variance, status };
|
||||
}
|
||||
72
src/server/controllers/applicationRecordController.ts
Normal file
72
src/server/controllers/applicationRecordController.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as appRecordService from '../services/applicationRecordService.js';
|
||||
import '../middleware/roleMiddleware.js'; // Ensure type augmentation
|
||||
|
||||
export const createApplicationRecord = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const createdBy = req.appUser?.clerkId;
|
||||
const record = await appRecordService.createApplicationRecord({ ...req.body, organizationId, createdBy });
|
||||
res.status(201).json(record);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getApplicationRecordsByProject = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const records = await appRecordService.getApplicationRecordsByProject(projectId as string, organizationId);
|
||||
res.json(records);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateApplicationRecord = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const userId = req.appUser?.clerkId;
|
||||
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
||||
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
|
||||
const record = await appRecordService.updateApplicationRecord(
|
||||
req.params.id as string,
|
||||
req.body,
|
||||
organizationId,
|
||||
userId,
|
||||
userRole as any,
|
||||
isDeveloper
|
||||
);
|
||||
if (!record) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
|
||||
res.json(record);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteApplicationRecord = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const userId = req.appUser?.clerkId;
|
||||
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
||||
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
|
||||
const success = await appRecordService.deleteApplicationRecord(
|
||||
req.params.id as string,
|
||||
organizationId,
|
||||
userId,
|
||||
userRole as any,
|
||||
isDeveloper
|
||||
);
|
||||
if (!success) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
285
src/server/controllers/dataSheetController.ts
Normal file
285
src/server/controllers/dataSheetController.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as dataSheetService from '../services/dataSheetService.js';
|
||||
import fs from 'fs';
|
||||
import * as pdfExtractionService from '../services/pdfExtractionService.js';
|
||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||
import { notificationService } from '../services/notificationService.js';
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
appUser?: IAppUser;
|
||||
}
|
||||
|
||||
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';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const extractData = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
return res.status(400).json({ error: 'File is required' });
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const file = req.file;
|
||||
const {
|
||||
name, manufacturer, type, solidsVolume, density,
|
||||
mixingRatio, mixingRatioWeight, mixingRatioVolume,
|
||||
yieldTheoretical, dftReference, yieldFactor,
|
||||
wftMin, wftMax, dftMin, dftMax, reducer, dilution,
|
||||
notes, fileUrl,
|
||||
manufacturerCode, minStock, typicalApplication
|
||||
} = 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 finalFileUrl = fileUrl || '';
|
||||
|
||||
if (file) {
|
||||
// Read file buffer
|
||||
const buffer = fs.readFileSync(file.path);
|
||||
|
||||
// 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) {
|
||||
console.warn('Failed to delete temp file:', file.path, error);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
manufacturerCode,
|
||||
type,
|
||||
minStock: minStock ? Number(minStock) : undefined,
|
||||
typicalApplication,
|
||||
fileUrl: finalFileUrl,
|
||||
fileId: fileId,
|
||||
solidsVolume: solidsVolume ? Number(solidsVolume) : undefined,
|
||||
density: density ? Number(density) : undefined,
|
||||
mixingRatio,
|
||||
mixingRatioWeight,
|
||||
mixingRatioVolume,
|
||||
yieldTheoretical: yieldTheoretical ? Number(yieldTheoretical) : undefined,
|
||||
dftReference: dftReference ? Number(dftReference) : undefined,
|
||||
yieldFactor: yieldFactor ? Number(yieldFactor) : undefined,
|
||||
wftMin: wftMin ? Number(wftMin) : undefined,
|
||||
wftMax: wftMax ? Number(wftMax) : undefined,
|
||||
dftMin: dftMin ? Number(dftMin) : undefined,
|
||||
dftMax: dftMax ? Number(dftMax) : undefined,
|
||||
reducer,
|
||||
dilution: dilution ? Number(dilution) : undefined,
|
||||
notes,
|
||||
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' }
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(newSheet);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Error creating datasheet:', error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
} else {
|
||||
res.status(404).json({ error: 'Data sheet not found' });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateDataSheet = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const file = req.file;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const {
|
||||
name, manufacturer, type, solidsVolume, density,
|
||||
mixingRatio, mixingRatioWeight, mixingRatioVolume,
|
||||
yieldTheoretical, dftReference, yieldFactor,
|
||||
wftMin, wftMax, dftMin, dftMax, reducer, dilution,
|
||||
notes, fileUrl
|
||||
} = req.body;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const updates: Record<string, any> = {
|
||||
name,
|
||||
manufacturer,
|
||||
type,
|
||||
notes,
|
||||
solidsVolume: solidsVolume ? Number(solidsVolume) : undefined,
|
||||
density: density ? Number(density) : undefined,
|
||||
yieldTheoretical: yieldTheoretical ? Number(yieldTheoretical) : undefined,
|
||||
dftReference: dftReference ? Number(dftReference) : undefined,
|
||||
yieldFactor: yieldFactor ? Number(yieldFactor) : undefined,
|
||||
wftMin: wftMin ? Number(wftMin) : undefined,
|
||||
wftMax: wftMax ? Number(wftMax) : undefined,
|
||||
dftMin: dftMin ? Number(dftMin) : undefined,
|
||||
dftMax: dftMax ? Number(dftMax) : undefined,
|
||||
reducer,
|
||||
dilution: dilution ? Number(dilution) : undefined,
|
||||
mixingRatio,
|
||||
mixingRatioWeight,
|
||||
mixingRatioVolume
|
||||
};
|
||||
|
||||
if (file) {
|
||||
// Read file buffer
|
||||
const buffer = fs.readFileSync(file.path);
|
||||
|
||||
// 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) {
|
||||
console.warn('Failed to delete temp file:', file.path, error);
|
||||
}
|
||||
} 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);
|
||||
|
||||
if (updatedSheet) {
|
||||
res.json(updatedSheet);
|
||||
} else {
|
||||
res.status(404).json({ error: 'Data sheet not found' });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Error updating datasheet:', error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getFile = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id_or_filename = 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);
|
||||
|
||||
if (fileDoc) {
|
||||
res.set('Content-Type', fileDoc.contentType || 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${fileDoc.filename}"`);
|
||||
return res.send(fileDoc.data);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Error getting file:', error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
160
src/server/controllers/geometryTypeController.ts
Normal file
160
src/server/controllers/geometryTypeController.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Request, Response } from 'express';
|
||||
import GeometryType from '../models/GeometryType.js';
|
||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
appUser?: IAppUser;
|
||||
}
|
||||
|
||||
// Default geometry types to seed if none exist
|
||||
const DEFAULT_TYPES = [
|
||||
{ name: 'Guarda-corpo/escada', efficiencyLoss: 20 },
|
||||
{ name: 'Vigas leves', efficiencyLoss: 20 },
|
||||
{ name: 'Vigas médias', efficiencyLoss: 20 },
|
||||
{ name: 'Vigas pesadas', efficiencyLoss: 20 },
|
||||
{ name: 'Chaparia comum', efficiencyLoss: 20 },
|
||||
{ name: 'Chapas de pisos (>0,5m²)', efficiencyLoss: 20 },
|
||||
{ name: 'Calhas', efficiencyLoss: 20 },
|
||||
{ name: 'Cantoneiras', efficiencyLoss: 20 },
|
||||
{ name: 'Telhas', efficiencyLoss: 20 },
|
||||
{ name: 'Tubulações (ret/red) <100mm', efficiencyLoss: 20 },
|
||||
{ name: 'Tubulações (ret/red) >100mm', efficiencyLoss: 20 },
|
||||
{ name: 'Peças diversas (outras)', efficiencyLoss: 20 }
|
||||
];
|
||||
|
||||
export const getAllnames = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
console.log(`[GeometryType] Fetching for org: ${organizationId}, globalAdmin: ${isGlobalAdmin}`);
|
||||
|
||||
if (!organizationId && !isGlobalAdmin) {
|
||||
return res.status(400).json({ error: 'Organization ID missing' });
|
||||
}
|
||||
|
||||
// Search for org-specific types OR orphan types (legacy)
|
||||
const query = isGlobalAdmin
|
||||
? {}
|
||||
: { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
|
||||
|
||||
let types = await GeometryType.find(query).sort({ name: 1 });
|
||||
|
||||
// Auto-seed if empty AND we HAVE an organization (don't seed for global view)
|
||||
if (types.length === 0 && organizationId) {
|
||||
console.log(`[GeometryType] No types found. Seeding defaults...`);
|
||||
try {
|
||||
const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId }));
|
||||
types = await GeometryType.insertMany(seedData) as any;
|
||||
console.log(`[GeometryType] Seeded ${types.length} types successfully.`);
|
||||
} catch (seedError) {
|
||||
console.error('[GeometryType] Seeding failed:', seedError);
|
||||
return res.json([]);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(types);
|
||||
} catch (error: unknown) {
|
||||
console.error('[GeometryType] Error in getAllnames:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const restoreDefaults = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organization ID missing' });
|
||||
}
|
||||
|
||||
// Delete all existing types for this org
|
||||
await GeometryType.deleteMany({ organizationId });
|
||||
|
||||
// Insert defaults
|
||||
const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId }));
|
||||
const types = await GeometryType.insertMany(seedData);
|
||||
|
||||
res.json(types);
|
||||
} catch (error: unknown) {
|
||||
console.error('[GeometryType] Error in restoreDefaults:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const createType = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const { name, efficiencyLoss } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const newType = new GeometryType({
|
||||
name,
|
||||
efficiencyLoss: Number(efficiencyLoss) || 0,
|
||||
organizationId
|
||||
});
|
||||
|
||||
const saved = await newType.save();
|
||||
res.status(201).json(saved);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (message.includes('E11000')) {
|
||||
return res.status(409).json({ error: 'A geometry type with this name already exists' });
|
||||
}
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateType = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
const { name, efficiencyLoss } = req.body;
|
||||
|
||||
const query = isGlobalAdmin
|
||||
? { _id: id }
|
||||
: { _id: id, organizationId };
|
||||
|
||||
const updated = await GeometryType.findOneAndUpdate(
|
||||
query,
|
||||
{ name, efficiencyLoss: Number(efficiencyLoss) },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Record not found' });
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteType = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
|
||||
const query = isGlobalAdmin
|
||||
? { _id: id }
|
||||
: { _id: id, organizationId };
|
||||
|
||||
const deleted = await GeometryType.findOneAndDelete(query);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Record not found' });
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
117
src/server/controllers/inspectionController.ts
Normal file
117
src/server/controllers/inspectionController.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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
|
||||
|
||||
export const createInspection = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const createdBy = req.appUser?.clerkId;
|
||||
const inspection = await inspectionService.createInspection({
|
||||
...req.body,
|
||||
organizationId,
|
||||
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' }
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(inspection);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getInspectionsByProject = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const inspections = await inspectionService.getInspectionsByProject(projectId as string, organizationId);
|
||||
res.json(inspections);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateInspection = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const userId = req.appUser?.clerkId;
|
||||
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
||||
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
|
||||
const inspection = await inspectionService.updateInspection(
|
||||
req.params.id as string,
|
||||
req.body,
|
||||
organizationId,
|
||||
userId,
|
||||
userRole as any,
|
||||
isDeveloper
|
||||
);
|
||||
if (!inspection) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
|
||||
res.json(inspection);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteInspection = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const userId = req.appUser?.clerkId;
|
||||
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
||||
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
|
||||
const success = await inspectionService.deleteInspection(
|
||||
req.params.id as string,
|
||||
organizationId,
|
||||
userId,
|
||||
userRole as any,
|
||||
isDeveloper
|
||||
);
|
||||
if (!success) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllInspections = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const inspections = await inspectionService.getAllInspections(organizationId);
|
||||
res.json(inspections);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadPhoto = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
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}`;
|
||||
|
||||
res.json({ url: fileUrl });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
106
src/server/controllers/instrumentController.ts
Normal file
106
src/server/controllers/instrumentController.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Request, Response } from 'express';
|
||||
import Instrument from '../models/Instrument.js';
|
||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
appUser?: IAppUser;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
res.status(201).json(instrument);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
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 });
|
||||
res.json(instruments);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateInstrument = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
|
||||
// Recalcular status se data de validade mudar
|
||||
const 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 }
|
||||
);
|
||||
|
||||
if (!instrument) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
||||
res.json(instrument);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
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.' });
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
244
src/server/controllers/messageController.ts
Normal file
244
src/server/controllers/messageController.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Request, Response } from 'express';
|
||||
import Message from '../models/Message.js';
|
||||
import OrganizationMember from '../models/OrganizationMember.js';
|
||||
|
||||
// Send a message
|
||||
export const sendMessage = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { toUserId, message } = req.body;
|
||||
const fromUserId = req.appUser?.clerkId;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
if (!fromUserId) {
|
||||
return res.status(401).json({ error: 'Usuário não autenticado.' });
|
||||
}
|
||||
|
||||
if (!toUserId || !message) {
|
||||
return res.status(400).json({ error: 'Destinatário e mensagem são obrigatórios.' });
|
||||
}
|
||||
|
||||
if (message.length > 255) {
|
||||
return res.status(400).json({ error: 'Mensagem muito longa (máximo 255 caracteres).' });
|
||||
}
|
||||
|
||||
// Check if there's already a pending (unread) message from this user to that user
|
||||
const existingMessage = await Message.findOne({
|
||||
organizationId,
|
||||
fromUserId,
|
||||
toUserId,
|
||||
isRead: false,
|
||||
});
|
||||
|
||||
if (existingMessage) {
|
||||
// Update existing message instead of creating a new one
|
||||
existingMessage.message = message;
|
||||
existingMessage.updatedAt = new Date();
|
||||
await existingMessage.save();
|
||||
return res.json(existingMessage);
|
||||
}
|
||||
|
||||
// Create new message
|
||||
const newMessage = new Message({
|
||||
organizationId,
|
||||
fromUserId,
|
||||
toUserId,
|
||||
message,
|
||||
});
|
||||
|
||||
await newMessage.save();
|
||||
res.status(201).json(newMessage);
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
res.status(500).json({ error: 'Erro ao enviar mensagem.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Get unread messages for current user
|
||||
export const getUnreadMessages = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const toUserId = req.appUser?.clerkId;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
if (!toUserId) {
|
||||
return res.status(401).json({ error: 'Usuário não autenticado.' });
|
||||
}
|
||||
|
||||
const messages = await Message.find({
|
||||
organizationId,
|
||||
toUserId,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
isDeletedByRecipient: false,
|
||||
}).sort({ createdAt: -1 });
|
||||
|
||||
// Populate sender info
|
||||
const messagesWithSender = await Promise.all(
|
||||
messages.map(async (msg) => {
|
||||
const sender = await OrganizationMember.findOne({ clerkUserId: msg.fromUserId });
|
||||
return {
|
||||
...msg.toObject(),
|
||||
fromUser: sender ? { name: sender.name, email: sender.email } : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(messagesWithSender);
|
||||
} catch (error) {
|
||||
console.error('Error getting unread messages:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar mensagens.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Mark message as read
|
||||
export const markMessageAsRead = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.appUser?.clerkId;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Usuário não autenticado.' });
|
||||
}
|
||||
|
||||
const message = await Message.findOne({
|
||||
_id: id,
|
||||
organizationId,
|
||||
toUserId: userId,
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
||||
}
|
||||
|
||||
message.isRead = true;
|
||||
message.readAt = new Date();
|
||||
await message.save();
|
||||
|
||||
res.json(message);
|
||||
} catch (error) {
|
||||
console.error('Error marking message as read:', error);
|
||||
res.status(500).json({ error: 'Erro ao marcar mensagem como lida.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Get my pending (unread) sent messages
|
||||
export const getMyPendingMessages = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const fromUserId = req.appUser?.clerkId;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
if (!fromUserId) {
|
||||
return res.status(401).json({ error: 'Usuário não autenticado.' });
|
||||
}
|
||||
|
||||
const messages = await Message.find({
|
||||
organizationId,
|
||||
fromUserId,
|
||||
isRead: false,
|
||||
}).sort({ createdAt: -1 });
|
||||
|
||||
// Populate recipient info
|
||||
const messagesWithRecipient = await Promise.all(
|
||||
messages.map(async (msg) => {
|
||||
const recipient = await OrganizationMember.findOne({ clerkUserId: msg.toUserId });
|
||||
return {
|
||||
...msg.toObject(),
|
||||
toUser: recipient ? { name: recipient.name, email: recipient.email } : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(messagesWithRecipient);
|
||||
} catch (error) {
|
||||
console.error('Error getting pending messages:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar mensagens pendentes.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a message (only if unread and sender is the current user)
|
||||
export const deleteMessage = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.appUser?.clerkId;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Usuário não autenticado.' });
|
||||
}
|
||||
|
||||
const message = await Message.findOne({
|
||||
_id: id,
|
||||
organizationId,
|
||||
fromUserId: userId,
|
||||
isRead: false, // Can only delete unread messages
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
return res.status(404).json({ error: 'Mensagem não encontrada ou já foi lida.' });
|
||||
}
|
||||
|
||||
await message.deleteOne();
|
||||
res.json({ message: 'Mensagem deletada com sucesso.' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting message:', error);
|
||||
res.status(500).json({ error: 'Erro ao deletar mensagem.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Recipient deletes/archives a message
|
||||
export const archiveMessage = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.appUser?.clerkId;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
|
||||
if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
||||
|
||||
message.isArchived = true;
|
||||
message.isRead = true; // Arquivar implica ler
|
||||
await message.save();
|
||||
res.json(message);
|
||||
} catch (error) {
|
||||
console.error('Error archiving message:', error);
|
||||
res.status(500).json({ error: 'Erro ao arquivar mensagem.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const recipientDeleteMessage = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.appUser?.clerkId;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
|
||||
if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
||||
|
||||
message.isDeletedByRecipient = true;
|
||||
await message.save();
|
||||
res.json({ message: 'Mensagem excluída com sucesso.' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting message:', error);
|
||||
res.status(500).json({ error: 'Erro ao excluir mensagem.' });
|
||||
}
|
||||
};
|
||||
97
src/server/controllers/notificationController.ts
Normal file
97
src/server/controllers/notificationController.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { notificationService } from '../services/notificationService.js';
|
||||
|
||||
export const notificationController = {
|
||||
getUserNotifications: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string; // Assumindo que temos o ID do usuário (clerkId ou email)
|
||||
|
||||
// Se não tiver userId no header (ainda não implementado auth full), tentar pegar do query ou usar um fallback
|
||||
// Nota: Idealmente o middleware de auth popula req.user. Vamos assumir que passamos x-user-id no frontend por enquanto.
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organization ID is required' });
|
||||
}
|
||||
|
||||
const notifications = await notificationService.getUserNotifications(
|
||||
userId,
|
||||
organizationId,
|
||||
req.query.includeArchived === 'true'
|
||||
);
|
||||
res.json(notifications);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Error fetching notifications' });
|
||||
}
|
||||
},
|
||||
|
||||
markAsRead: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const notification = await notificationService.markAsRead(id as string);
|
||||
res.json(notification);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Error marking notification as read' });
|
||||
}
|
||||
},
|
||||
|
||||
markAllAsRead: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organization ID is required' });
|
||||
}
|
||||
|
||||
await notificationService.markAllAsRead(userId, organizationId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Error marking all as read' });
|
||||
}
|
||||
},
|
||||
|
||||
clearAll: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organization ID is required' });
|
||||
}
|
||||
|
||||
await notificationService.clearAll(userId, organizationId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Error clearing all notifications' });
|
||||
}
|
||||
},
|
||||
|
||||
archive: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const notification = await notificationService.archive(id as string, userId);
|
||||
res.json(notification);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Error archiving notification' });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
await notificationService.softDelete(id as string, userId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Error deleting notification' });
|
||||
}
|
||||
}
|
||||
};
|
||||
73
src/server/controllers/paintingSchemeController.ts
Normal file
73
src/server/controllers/paintingSchemeController.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as paintingSchemeService from '../services/paintingSchemeService.js';
|
||||
|
||||
export const createPaintingScheme = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
console.log("Creating scheme with payload:", req.body);
|
||||
const scheme = await paintingSchemeService.createPaintingScheme({ ...req.body, organizationId });
|
||||
console.log("Created scheme result:", scheme);
|
||||
res.status(201).json(scheme);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getPaintingSchemesByProject = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const schemes = await paintingSchemeService.getPaintingSchemesByProject(projectId as string, organizationId);
|
||||
res.json(schemes);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const updatePaintingScheme = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
console.log("---------------------------------------------------");
|
||||
console.log(`UPDATE REQUEST: ID=${req.params.id}`);
|
||||
console.log(`User Org ID: ${organizationId}`);
|
||||
console.log(`Payload keys: ${Object.keys(req.body)}`);
|
||||
|
||||
const scheme = await paintingSchemeService.updatePaintingScheme(req.params.id as string, req.body, organizationId);
|
||||
|
||||
console.log(`UPDATE RESULT: ${scheme ? 'SUCCESS' : 'NULL (Doc not found or not matched)'}`);
|
||||
if (scheme) {
|
||||
console.log(`Updated Doc Coat: ${scheme.coat}`);
|
||||
}
|
||||
console.log("---------------------------------------------------");
|
||||
|
||||
res.json(scheme);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deletePaintingScheme = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
await paintingSchemeService.deletePaintingScheme(req.params.id as string, organizationId);
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllPaintingSchemes = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const schemes = await paintingSchemeService.getAllSchemes(organizationId);
|
||||
res.json(schemes);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
70
src/server/controllers/partController.ts
Normal file
70
src/server/controllers/partController.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as partService from '../services/partService.js';
|
||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
appUser?: IAppUser;
|
||||
}
|
||||
|
||||
export const createPart = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
console.log('[CREATE PART] Received data:', JSON.stringify(req.body, null, 2));
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const part = await partService.createPart({ ...req.body, organizationId });
|
||||
console.log('[CREATE PART] Success:', part);
|
||||
res.status(201).json(part);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[CREATE PART] Error:', message);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getPartsByProject = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
const parts = await partService.getPartsByProject(projectId as string, organizationId, isGlobalAdmin);
|
||||
res.json(parts);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePart = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
const part = await partService.updatePart(req.params.id as string, req.body, organizationId, isGlobalAdmin);
|
||||
res.json(part);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deletePart = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
await partService.deletePart(req.params.id as string, organizationId, isGlobalAdmin);
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllParts = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
const parts = await partService.getAllParts(organizationId, isGlobalAdmin);
|
||||
res.json(parts);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
107
src/server/controllers/projectController.ts
Normal file
107
src/server/controllers/projectController.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as projectService from '../services/projectService.js';
|
||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||
import { notificationService } from '../services/notificationService.js';
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
appUser?: IAppUser;
|
||||
}
|
||||
|
||||
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';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllProjects = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
const { status } = req.query;
|
||||
const projects = await projectService.getAllProjects(organizationId, isGlobalAdmin, status as string);
|
||||
res.json(projects);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const archiveProject = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
const project = await projectService.archiveProject(req.params.id as string, organizationId, isGlobalAdmin);
|
||||
res.json(project);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getDashboardProjects = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const projects = await projectService.getDashboardProjects(organizationId);
|
||||
res.json(projects);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getProjectById = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
const project = await projectService.getProjectById(req.params.id as string, organizationId, isGlobalAdmin);
|
||||
res.json(project);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(404).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateProject = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
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' }
|
||||
});
|
||||
}
|
||||
|
||||
res.json(project);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteProject = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||
await projectService.deleteProject(req.params.id as string, organizationId, isGlobalAdmin);
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
502
src/server/controllers/stockController.ts
Normal file
502
src/server/controllers/stockController.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import { Request, Response } from 'express';
|
||||
import StockItem from '../models/StockItem.js';
|
||||
import StockMovement from '../models/StockMovement.js';
|
||||
|
||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||
import { notificationService } from '../services/notificationService.js';
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
appUser?: IAppUser;
|
||||
}
|
||||
|
||||
export const createStockItem = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
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
|
||||
} = 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({
|
||||
organizationId,
|
||||
createdBy: req.appUser?.clerkId,
|
||||
dataSheetId,
|
||||
rrNumber,
|
||||
batchNumber,
|
||||
quantity: Number(quantity),
|
||||
unit,
|
||||
minStock: finalMinStock,
|
||||
expirationDate,
|
||||
notes,
|
||||
color,
|
||||
invoiceNumber,
|
||||
receivedBy
|
||||
});
|
||||
|
||||
const savedItem = await newItem.save();
|
||||
|
||||
// Create Initial Movement (ENTRY)
|
||||
await StockMovement.create({
|
||||
organizationId,
|
||||
createdBy: req.appUser?.clerkId,
|
||||
stockItemId: savedItem._id,
|
||||
movementNumber: 1,
|
||||
type: 'ENTRY',
|
||||
quantity: Number(quantity),
|
||||
responsible: userName,
|
||||
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}).`,
|
||||
type: 'info',
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
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 }
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
export const adjustStock = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||
const { quantityDelta, reason } = req.body; // quantityDelta: +10 or -5
|
||||
|
||||
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
|
||||
if (!quantityDelta || isNaN(quantityDelta)) return res.status(400).json({ error: 'Quantidade inválida.' });
|
||||
|
||||
const item = await StockItem.findOne({ _id: id, organizationId });
|
||||
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||
|
||||
// Calculate new quantity
|
||||
const newQuantity = Number(item.quantity) + Number(quantityDelta);
|
||||
if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' });
|
||||
|
||||
item.quantity = newQuantity;
|
||||
await item.save();
|
||||
|
||||
// Calculate next movement number
|
||||
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
|
||||
const count = await StockMovement.countDocuments({ stockItemId: item._id });
|
||||
const movementNumber = (lastMov?.movementNumber || count) + 1;
|
||||
|
||||
// Register Movement
|
||||
await StockMovement.create({
|
||||
organizationId,
|
||||
createdBy: req.appUser?.clerkId,
|
||||
stockItemId: item._id,
|
||||
movementNumber,
|
||||
type: 'ADJUSTMENT',
|
||||
quantity: Number(quantityDelta),
|
||||
responsible: userName,
|
||||
reason
|
||||
});
|
||||
|
||||
// Check Low Stock
|
||||
await notificationService.checkLowStock(item._id.toString());
|
||||
|
||||
res.json(item);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const consumeStock = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||
const { quantityConsumed, requester, date } = req.body;
|
||||
|
||||
if (!requester) return res.status(400).json({ error: 'Solicitante é obrigatório.' });
|
||||
if (!quantityConsumed || Number(quantityConsumed) <= 0) return res.status(400).json({ error: 'Quantidade deve ser maior que zero.' });
|
||||
|
||||
const item = await StockItem.findOne({ _id: id, organizationId });
|
||||
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||
|
||||
if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' });
|
||||
|
||||
item.quantity -= Number(quantityConsumed);
|
||||
await item.save();
|
||||
|
||||
// Calculate next movement number
|
||||
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
|
||||
const count = await StockMovement.countDocuments({ stockItemId: item._id });
|
||||
const movementNumber = (lastMov?.movementNumber || count) + 1;
|
||||
|
||||
// Register Movement (Negative quantity for consumption)
|
||||
await StockMovement.create({
|
||||
organizationId,
|
||||
createdBy: req.appUser?.clerkId,
|
||||
stockItemId: item._id,
|
||||
movementNumber,
|
||||
type: 'CONSUMPTION',
|
||||
quantity: -Number(quantityConsumed), // Negative
|
||||
responsible: userName,
|
||||
requester,
|
||||
date: date || new Date()
|
||||
});
|
||||
|
||||
// Check Low Stock
|
||||
await notificationService.checkLowStock(item._id.toString());
|
||||
|
||||
res.json(item);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
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 });
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getStockItems = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const { dataSheetId } = req.query;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const query: any = { organizationId };
|
||||
if (dataSheetId) query.dataSheetId = dataSheetId;
|
||||
|
||||
// Sort by Expiration Date ASC (First to expire first)
|
||||
const items = await StockItem.find(query)
|
||||
.populate('dataSheetId', 'name manufacturer type')
|
||||
.sort({ expirationDate: 1 });
|
||||
|
||||
res.json(items);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getStockItemById = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
|
||||
const item = await StockItem.findOne({ _id: id, organizationId })
|
||||
.populate('dataSheetId', 'name manufacturer type');
|
||||
|
||||
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||
|
||||
res.json(item);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getStockMovements = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params; // StockItem ID
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
|
||||
const movements = await StockMovement.find({ stockItemId: id, organizationId })
|
||||
.sort({ date: -1 });
|
||||
|
||||
res.json(movements);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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 organizationId = req.appUser?.organizationId;
|
||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||
const userId = req.appUser?.clerkId || 'system';
|
||||
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
|
||||
|
||||
if (!isAdmin) {
|
||||
return res.status(403).json({ error: 'Apenas administradores podem editar movimentações.' });
|
||||
}
|
||||
|
||||
const { date, quantity, notes } = req.body;
|
||||
|
||||
const movement = await StockMovement.findOne({ _id: id, organizationId });
|
||||
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
||||
|
||||
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
|
||||
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
|
||||
|
||||
// Calculate Delta
|
||||
// If quantity changed, we need to adjust the item balance
|
||||
// Note: 'quantity' in movement is signed (+ for entry, - for consumption)
|
||||
// If the user edits a Consumption (-10) to (-15), the val passed in body might be absolute or signed?
|
||||
// Let's assume the frontend sends the SIGNED value consistent with the movement type?
|
||||
// Actually best to stick to specific logic:
|
||||
// If movement type is ENTRY/ADJUSTMENT, quantity is usually positive (unless neg adjustment).
|
||||
// If CONSUMPTION, quantity is stored negative.
|
||||
// Let's expect the frontend to send the 'raw' new value.
|
||||
// Be careful: if frontend sends positive 10 for a consumption, we must flip it?
|
||||
// Let's assume frontend sends the value exactly as it should be stored.
|
||||
|
||||
// HOWEVER, it's safer if we check type.
|
||||
const newQuantitySigned = Number(quantity);
|
||||
|
||||
// Validation: Consumption should generally be negative, Entry positive.
|
||||
// But for flexibility let's just trust the arithmetic diff for now,
|
||||
// but warn if sign flips unexpectedly?
|
||||
|
||||
const oldQuantity = Number(movement.quantity);
|
||||
const quantityDiff = newQuantitySigned - oldQuantity;
|
||||
|
||||
// Update Item
|
||||
const newStockLevel = Number(item.quantity) + quantityDiff;
|
||||
if (newStockLevel < 0) {
|
||||
return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
|
||||
}
|
||||
|
||||
item.quantity = newStockLevel;
|
||||
await item.save();
|
||||
|
||||
// Audit Log
|
||||
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
|
||||
const typeLabel = typeMap[movement.type] || movement.type;
|
||||
|
||||
await StockAuditLog.create({
|
||||
organizationId,
|
||||
stockItemId: item._id,
|
||||
movementId: movement._id,
|
||||
movementNumber: movement.movementNumber,
|
||||
userId,
|
||||
userName,
|
||||
action: 'UPDATE',
|
||||
details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`,
|
||||
oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes },
|
||||
newValues: { date, quantity: newQuantitySigned, notes }
|
||||
});
|
||||
|
||||
// Update Movement
|
||||
movement.quantity = newQuantitySigned;
|
||||
if (date) movement.date = date;
|
||||
if (notes !== undefined) movement.notes = notes;
|
||||
await movement.save();
|
||||
|
||||
res.json(movement);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Error updating movement:', error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteStockMovement = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||
const userId = req.appUser?.clerkId || 'system';
|
||||
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
|
||||
|
||||
if (!isAdmin) {
|
||||
return res.status(403).json({ error: 'Apenas administradores podem excluir movimentações.' });
|
||||
}
|
||||
|
||||
const movement = await StockMovement.findOne({ _id: id, organizationId });
|
||||
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
||||
|
||||
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
|
||||
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
|
||||
|
||||
// Reverse the effect
|
||||
// If we delete an Entry (+10), we MUST subtract 10 from Item.
|
||||
// If we delete a Consumption (-10), we MUST add 10 (subtract -10) to Item.
|
||||
// So: Item.quantity -= movement.quantity
|
||||
|
||||
const reverseQty = Number(movement.quantity);
|
||||
const newStockLevel = Number(item.quantity) - reverseQty;
|
||||
|
||||
if (newStockLevel < 0) {
|
||||
return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
|
||||
}
|
||||
|
||||
item.quantity = newStockLevel;
|
||||
await item.save();
|
||||
|
||||
// Audit Log
|
||||
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
|
||||
const typeLabel = typeMap[movement.type] || movement.type;
|
||||
|
||||
await StockAuditLog.create({
|
||||
organizationId,
|
||||
stockItemId: item._id,
|
||||
movementId: movement._id,
|
||||
movementNumber: movement.movementNumber,
|
||||
userId,
|
||||
userName,
|
||||
action: 'DELETE',
|
||||
details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`,
|
||||
oldValues: movement.toObject()
|
||||
});
|
||||
|
||||
await StockMovement.deleteOne({ _id: id });
|
||||
|
||||
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 organizationId = req.appUser?.organizationId;
|
||||
|
||||
const logs = await StockAuditLog.find({ stockItemId: id, organizationId })
|
||||
.sort({ timestamp: -1 });
|
||||
|
||||
res.json(logs);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
179
src/server/controllers/systemSettingsController.ts
Normal file
179
src/server/controllers/systemSettingsController.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Request, Response } from 'express';
|
||||
import SystemSettings from '../models/SystemSettings.js';
|
||||
import User from '../models/User.js';
|
||||
import OrganizationMember from '../models/OrganizationMember.js';
|
||||
import Organization from '../models/Organization.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
||||
export const getSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
let settings = await SystemSettings.findOne({ settingsId: 'global' });
|
||||
|
||||
if (!settings) {
|
||||
// Create default if not exists
|
||||
settings = await SystemSettings.create({
|
||||
settingsId: 'global',
|
||||
appName: 'GPI',
|
||||
appSubtitle: 'Gestão de Pintura Industrial'
|
||||
});
|
||||
}
|
||||
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Error fetching system settings:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar configurações do sistema' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { appName, appSubtitle, appLogoUrl } = req.body;
|
||||
|
||||
const settings = await SystemSettings.findOneAndUpdate(
|
||||
{ settingsId: 'global' },
|
||||
{
|
||||
appName,
|
||||
appSubtitle,
|
||||
appLogoUrl,
|
||||
updatedBy: req.appUser?.email
|
||||
},
|
||||
{ new: true, upsert: true } // Create if not exists
|
||||
);
|
||||
|
||||
console.log(`⚙️ System Settings updated by ${req.appUser?.email}`);
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Error updating system settings:', error);
|
||||
res.status(500).json({ error: 'Erro ao atualizar configurações do sistema' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const serveLogo = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filename } = req.params as { filename: string };
|
||||
|
||||
// Check tmp dir first (Serverless/Netlify uploads)
|
||||
const tmpPath = path.join(os.tmpdir(), 'uploads', filename);
|
||||
// Check local dir (Development)
|
||||
const localPath = path.join(process.cwd(), 'uploads', filename);
|
||||
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
res.sendFile(tmpPath);
|
||||
} else if (fs.existsSync(localPath)) {
|
||||
res.sendFile(localPath);
|
||||
} else {
|
||||
console.error(`Logo file not found in tmp or local: ${filename}`);
|
||||
res.status(404).json({ error: 'Imagem não encontrada' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error serving logo:', error);
|
||||
res.status(500).json({ error: 'Erro ao processar imagem' });
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadLogo = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
|
||||
}
|
||||
|
||||
// Return the API URL instead of static path
|
||||
// This ensures requests go through /api proxy and we control serving
|
||||
const fileUrl = `/api/system-settings/logo-image/${req.file.filename}`;
|
||||
|
||||
res.json({ url: fileUrl });
|
||||
} catch (error) {
|
||||
console.error('Error uploading logo:', error);
|
||||
res.status(500).json({ error: 'Erro ao fazer upload do logo.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Global Admin Functions
|
||||
export const getGlobalUsers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const users = await User.find({}).sort({ createdAt: -1 });
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
console.error('Error getting global users:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar usuários globais.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getGlobalOrganizations = async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Aggregate members to group by org and get full member lists
|
||||
const organizations = await OrganizationMember.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$organizationId',
|
||||
members: {
|
||||
$push: {
|
||||
name: '$name',
|
||||
email: '$email',
|
||||
role: '$role',
|
||||
clerkUserId: '$clerkUserId',
|
||||
isBanned: '$isBanned'
|
||||
}
|
||||
},
|
||||
lastActive: { $max: '$updatedAt' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'organizations', // Ensure this matches the collection name of Organization model
|
||||
localField: '_id',
|
||||
foreignField: 'clerkId',
|
||||
as: 'orgDetails'
|
||||
}
|
||||
},
|
||||
{
|
||||
$unwind: {
|
||||
path: '$orgDetails',
|
||||
preserveNullAndEmptyArrays: true
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
lastActive: 1,
|
||||
members: 1,
|
||||
memberCount: { $size: '$members' },
|
||||
isBanned: { $ifNull: ['$orgDetails.isBanned', false] },
|
||||
name: { $ifNull: ['$orgDetails.name', ''] }
|
||||
}
|
||||
},
|
||||
{ $sort: { memberCount: -1 } }
|
||||
]);
|
||||
|
||||
res.json(organizations);
|
||||
} catch (error) {
|
||||
console.error('Error getting global organizations:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar organizações globais.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleOrganizationBan = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { organizationId, isBanned } = req.body;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'ID da organização é obrigatório.' });
|
||||
}
|
||||
|
||||
// Upsert the Organization record
|
||||
const org = await Organization.findOneAndUpdate(
|
||||
{ clerkId: organizationId },
|
||||
{ isBanned: isBanned },
|
||||
{ new: true, upsert: true }
|
||||
);
|
||||
|
||||
console.log(`Organization ${organizationId} ban status set to ${isBanned} by ${req.appUser?.email}`);
|
||||
res.json(org);
|
||||
} catch (error) {
|
||||
console.error('Error toggling organization ban:', error);
|
||||
res.status(500).json({ error: 'Erro ao atualizar status da organização.' });
|
||||
}
|
||||
};
|
||||
319
src/server/controllers/userController.ts
Normal file
319
src/server/controllers/userController.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
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;
|
||||
}
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
appUser?: IAppUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync user from Clerk 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 { clerkId, email, name, organizationId, clerkRole } = req.body;
|
||||
|
||||
if (!clerkId || !email || !name) {
|
||||
return res.status(400).json({ error: 'clerkId, email e name são obrigatórios.' });
|
||||
}
|
||||
|
||||
// 1. Upsert the global User record
|
||||
let user = await User.findOne({ clerkId });
|
||||
|
||||
if (user) {
|
||||
user.email = email;
|
||||
user.name = name;
|
||||
await user.save();
|
||||
} else {
|
||||
user = await User.create({
|
||||
clerkId,
|
||||
email,
|
||||
name,
|
||||
role: 'guest', // Default global role
|
||||
isBanned: false
|
||||
});
|
||||
}
|
||||
|
||||
if (organizationId) {
|
||||
|
||||
// Map Clerk 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(
|
||||
{ clerkUserId: clerkId, 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 Clerk.
|
||||
// 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
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
return res.status(404).json({ error: 'Usuário não encontrado.' });
|
||||
}
|
||||
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (organizationId) {
|
||||
const member = await OrganizationMember.findOne({
|
||||
clerkUserId: req.appUser.clerkId,
|
||||
organizationId
|
||||
});
|
||||
|
||||
if (member) {
|
||||
return res.json({
|
||||
...req.appUser.toObject(),
|
||||
role: member.role,
|
||||
isBanned: member.isBanned,
|
||||
organizationId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(req.appUser);
|
||||
} catch (error) {
|
||||
console.error('Error getting current user:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar usuário.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all users for the current organization (admin only)
|
||||
*/
|
||||
export const getAllUsers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
console.log('getAllUsers called with organizationId:', organizationId);
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
const members = await OrganizationMember.find({ organizationId }).sort({ createdAt: -1 });
|
||||
console.log(`Found ${members.length} members for org ${organizationId}:`, members.map(m => ({ name: m.name, email: m.email, clerkId: m.clerkUserId })));
|
||||
res.json(members);
|
||||
} catch (error) {
|
||||
console.error('Error getting users:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar usuários.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user role within organization (admin only)
|
||||
*/
|
||||
export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { role } = req.body;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
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) {
|
||||
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.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ban or unban user within organization (admin only)
|
||||
*/
|
||||
export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { isBanned } = req.body;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
const member = await OrganizationMember.findById(id);
|
||||
if (!member || member.organizationId !== organizationId) {
|
||||
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
|
||||
}
|
||||
|
||||
// Prevent banning yourself
|
||||
if (req.appUser && member.clerkUserId === req.appUser.clerkId) {
|
||||
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);
|
||||
res.status(500).json({ error: 'Erro ao alterar status de banimento.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update current user's lastSeenAt timestamp
|
||||
*/
|
||||
export const heartbeat = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.appUser) {
|
||||
return res.status(401).json({ error: 'Não autenticado.' });
|
||||
}
|
||||
|
||||
// Update User model
|
||||
await User.findByIdAndUpdate(req.appUser._id, { lastSeenAt: new Date() });
|
||||
|
||||
// Also update Organization Member for tighter query
|
||||
// But for now User model is enough if we join correctly, or just use User model for presence.
|
||||
// Actually, since we want to show users per organization, we should filter by Org.
|
||||
// Our 'User.ts' has organizationId, but it might be just the 'default' one.
|
||||
// Let's rely on OrganizationMember for the list, but we need to update lastSeenAt there too?
|
||||
// Strategy: Update User (global), and when querying active users, join or filter.
|
||||
// Better: Update OrganizationMember too if we want org-specific presence?
|
||||
// Simpler: Just update User. When fetching active users, we fetch OrganizationMembers and populate User details, filtering by User.lastSeenAt.
|
||||
|
||||
res.status(200).send();
|
||||
} catch (error) {
|
||||
// Silent fail for heartbeat
|
||||
console.error('Heartbeat error:', error);
|
||||
res.status(500).send();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get active users in the same organization (seen in last 2 mins)
|
||||
*/
|
||||
export const getActiveUsers = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
const currentUserId = req.appUser?._id;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json([]);
|
||||
}
|
||||
|
||||
// Find members of this org
|
||||
const members = await OrganizationMember.find({ organizationId });
|
||||
|
||||
// Get their Clerk IDs
|
||||
const clerkIds = members.map(m => m.clerkUserId);
|
||||
|
||||
// Find Users who were seen recently (2 minutes)
|
||||
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
|
||||
|
||||
const activeUsers = await User.find({
|
||||
clerkId: { $in: clerkIds },
|
||||
lastSeenAt: { $gte: twoMinutesAgo },
|
||||
_id: { $ne: currentUserId } // Optional: exclude self
|
||||
}).select('name email lastSeenAt clerkId'); // Only needed fields
|
||||
|
||||
res.json(activeUsers);
|
||||
} catch (error) {
|
||||
console.error('Error getting active users:', error);
|
||||
res.status(500).json([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete organization member
|
||||
export const deleteUser = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||
}
|
||||
|
||||
console.log(`Deleting member ${id} from organization ${organizationId}`);
|
||||
|
||||
// Delete from OrganizationMember collection
|
||||
const result = await OrganizationMember.findByIdAndDelete(id);
|
||||
|
||||
if (!result) {
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
res.status(500).json({ error: 'Erro ao remover membro.' });
|
||||
}
|
||||
};
|
||||
57
src/server/controllers/yieldStudyController.ts
Normal file
57
src/server/controllers/yieldStudyController.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import * as yieldStudyService from '../services/yieldStudyService.js';
|
||||
|
||||
export const getAllStudies = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const studies = await yieldStudyService.getAllStudies(organizationId);
|
||||
res.json(studies);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const createStudy = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const study = await yieldStudyService.createStudy({ ...req.body, organizationId });
|
||||
res.status(201).json(study);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateStudy = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const study = await yieldStudyService.updateStudy(id, req.body, organizationId);
|
||||
if (study) {
|
||||
res.json(study);
|
||||
} else {
|
||||
res.status(404).json({ error: 'Study not found' });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteStudy = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const success = await yieldStudyService.deleteStudy(id, organizationId);
|
||||
if (success) {
|
||||
res.status(204).send();
|
||||
} else {
|
||||
res.status(404).json({ error: 'Study not found' });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
41
src/server/index.ts
Normal file
41
src/server/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import app from './app.js';
|
||||
import dotenv from 'dotenv';
|
||||
import { migrateFilesToGridFS } from './services/dataSheetService.js';
|
||||
import { connectDB } from './config/database.js';
|
||||
import mongoose from 'mongoose';
|
||||
import { notificationService } from './services/notificationService.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const startServer = async () => {
|
||||
try {
|
||||
await connectDB();
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(Number(PORT), '0.0.0.0', async () => {
|
||||
console.log(`🚀 Server running on port ${PORT} (0.0.0.0)`);
|
||||
if (mongoose.connection.readyState === 1) {
|
||||
await migrateFilesToGridFS().catch(err => console.error('Migration failed:', err));
|
||||
|
||||
// Agendar verificação de vencimento de estoque (a cada 24 horas)
|
||||
console.log('📅 Scheduling stock expiration check...');
|
||||
setInterval(() => {
|
||||
notificationService.checkStockExpirations();
|
||||
}, 24 * 60 * 60 * 1000);
|
||||
|
||||
// Executar uma vez no início para garantir (opcional, bom para dev)
|
||||
notificationService.checkStockExpirations();
|
||||
|
||||
} else {
|
||||
console.warn('⚠️ MongoDB is not connected. Skipping migrations.');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
startServer();
|
||||
|
||||
// Force keep-alive to debug why it exits
|
||||
setInterval(() => { }, 1000);
|
||||
174
src/server/middleware/roleMiddleware.ts
Normal file
174
src/server/middleware/roleMiddleware.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import User, { IUser } from '../models/User.js';
|
||||
import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js';
|
||||
import Organization from '../models/Organization.js';
|
||||
|
||||
// Extended user info with organization context
|
||||
export interface IAppUser extends IUser {
|
||||
organizationId?: string;
|
||||
organizationRole?: OrgRole;
|
||||
organizationBanned?: boolean;
|
||||
}
|
||||
|
||||
// Module augmentation for Express Request
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
appUser?: IAppUser;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to extract and verify user from Clerk ID header
|
||||
* Also loads organization-specific role if organization context is provided
|
||||
*/
|
||||
export const extractUser = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const clerkId = req.headers['x-clerk-user-id'] as string;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (!clerkId) {
|
||||
return next(); // No user, continue without
|
||||
}
|
||||
|
||||
const user = await User.findOne({ clerkId });
|
||||
|
||||
if (user) {
|
||||
if (user.isBanned) {
|
||||
return res.status(403).json({ error: 'Conta bloqueada. Entre em contato com o administrador.' });
|
||||
}
|
||||
|
||||
// Create extended user object
|
||||
const appUser: IAppUser = user.toObject() as IAppUser;
|
||||
appUser.organizationId = organizationId;
|
||||
|
||||
// If organization context, get org-specific role
|
||||
if (organizationId) {
|
||||
// Check if Organization is globally banned (subscription specific, etc.)
|
||||
const orgStatus = await Organization.findOne({ clerkId: organizationId });
|
||||
const orgName = req.headers['x-organization-name'] ? decodeURIComponent(req.headers['x-organization-name'] as string) : undefined;
|
||||
|
||||
if (orgStatus) {
|
||||
// Update name if different and present
|
||||
if (orgName && orgStatus.name !== orgName) {
|
||||
try {
|
||||
await Organization.updateOne(
|
||||
{ clerkId: organizationId },
|
||||
{ name: orgName }
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('Failed to update organization name', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (orgStatus.isBanned) {
|
||||
return res.status(403).json({
|
||||
error: 'Acesso bloqueado: Esta organização está suspensa. Entre em contato com o suporte.'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create new org with name if present
|
||||
try {
|
||||
await Organization.create({
|
||||
clerkId: organizationId,
|
||||
name: orgName
|
||||
});
|
||||
} catch (_e) {
|
||||
console.warn('Organization auto-create race condition', _e);
|
||||
}
|
||||
}
|
||||
|
||||
const member = await OrganizationMember.findOne({ clerkUserId: clerkId, organizationId });
|
||||
if (member) {
|
||||
if (member.isBanned) {
|
||||
return res.status(403).json({ error: 'Acesso bloqueado nesta organização.' });
|
||||
}
|
||||
appUser.organizationRole = member.role;
|
||||
appUser.role = member.role; // Override global role with org role
|
||||
} else {
|
||||
// User exists but is not a member of this org yet
|
||||
appUser.organizationRole = 'guest';
|
||||
appUser.role = 'guest';
|
||||
}
|
||||
}
|
||||
|
||||
req.appUser = appUser;
|
||||
// console.log(`✅ Request authenticated as: ${appUser.name} (${appUser.role})`);
|
||||
} else {
|
||||
console.warn(`⚠️ User with Clerk ID ${clerkId} not found in MongoDB. Sync required.`);
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error extracting user:', error);
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require specific roles for a route
|
||||
* @param allowedRoles Array of roles that can access the route
|
||||
*/
|
||||
export const requireRole = (allowedRoles: OrgRole[]) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.appUser) {
|
||||
return res.status(401).json({ error: 'Autenticação necessária.' });
|
||||
}
|
||||
|
||||
// DEV Bypass: Developer has full power
|
||||
if (req.appUser.email === 'admtracksteel@gmail.com') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
|
||||
|
||||
if (!allowedRoles.includes(effectiveRole as OrgRole)) {
|
||||
return res.status(403).json({ error: 'Acesso negado. Permissões insuficientes.' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require admin role
|
||||
*/
|
||||
export const requireAdmin = requireRole(['admin']);
|
||||
|
||||
/**
|
||||
* Middleware to require at least user role (user or admin)
|
||||
*/
|
||||
export const requireUser = requireRole(['user', 'admin']);
|
||||
|
||||
/**
|
||||
* Middleware to check if user can edit (user or admin, not guest)
|
||||
*/
|
||||
export const canEdit = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.appUser) {
|
||||
return res.status(401).json({ error: 'Autenticação necessária.' });
|
||||
}
|
||||
|
||||
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
|
||||
|
||||
if (effectiveRole === 'guest') {
|
||||
return res.status(403).json({ error: 'Convidados não podem editar. Solicite acesso ao administrador.' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require Developer (Super Admin) access
|
||||
* Hardcoded to specific email for security
|
||||
*/
|
||||
export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.appUser) {
|
||||
return res.status(401).json({ error: 'Autenticação necessária.' });
|
||||
}
|
||||
|
||||
if (req.appUser.email !== 'admtracksteel@gmail.com') {
|
||||
console.warn(`⛔ Attempted unauthorized developer access by: ${req.appUser.email}`);
|
||||
return res.status(403).json({ error: 'Acesso restrito ao desenvolvedor.' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
34
src/server/middleware/uploadMiddleware.ts
Normal file
34
src/server/middleware/uploadMiddleware.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import os from 'os';
|
||||
|
||||
// Ensure uploads directory exists in tmp (writable in serverless)
|
||||
const uploadsDir = path.join(os.tmpdir(), 'uploads');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadsDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${uuidv4()}`;
|
||||
cb(null, `logo-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
export const uploadLogoDetails = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Apenas arquivos de imagem são permitidos.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
47
src/server/models/ApplicationRecord.ts
Normal file
47
src/server/models/ApplicationRecord.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IApplicationRecord extends Document {
|
||||
organizationId?: string;
|
||||
createdBy?: string;
|
||||
projectId: mongoose.Types.ObjectId;
|
||||
coatStage: string;
|
||||
pieceDescription?: string | null;
|
||||
date?: Date | null;
|
||||
operator?: string | null;
|
||||
realWeight?: number | null;
|
||||
volumeUsed?: number | null;
|
||||
areaPainted?: number | null;
|
||||
wetThicknessAvg?: number | null;
|
||||
dryThicknessCalc?: number | null;
|
||||
method?: string | null;
|
||||
diluentUsed?: number | null;
|
||||
notes?: string | null;
|
||||
items?: {
|
||||
partId: mongoose.Types.ObjectId;
|
||||
quantity: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
const ApplicationRecordSchema: Schema = new Schema({
|
||||
organizationId: { type: String, index: true },
|
||||
createdBy: { type: String, index: true },
|
||||
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
|
||||
coatStage: { type: String, required: true },
|
||||
pieceDescription: { type: String }, // Can be auto-generated or manual name for the Batch
|
||||
date: { type: Date },
|
||||
operator: { type: String },
|
||||
realWeight: { type: Number },
|
||||
volumeUsed: { type: Number },
|
||||
areaPainted: { type: Number },
|
||||
wetThicknessAvg: { type: Number },
|
||||
dryThicknessCalc: { type: Number },
|
||||
method: { type: String },
|
||||
diluentUsed: { type: Number },
|
||||
notes: { type: String },
|
||||
items: [{
|
||||
partId: { type: Schema.Types.ObjectId, ref: 'Part' },
|
||||
quantity: { type: Number, required: true }
|
||||
}]
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.ApplicationRecord || mongoose.model<IApplicationRecord>('ApplicationRecord', ApplicationRecordSchema);
|
||||
22
src/server/models/GeometryType.ts
Normal file
22
src/server/models/GeometryType.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface IGeometryType extends Document {
|
||||
name: string;
|
||||
efficiencyLoss: number; // Percentage, e.g., 10 for 10%
|
||||
organizationId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const GeometryTypeSchema: Schema = new Schema({
|
||||
name: { type: String, required: true },
|
||||
efficiencyLoss: { type: Number, required: true, default: 0 },
|
||||
organizationId: { type: String, required: true, index: true },
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Compound index to ensure unique names per organization
|
||||
GeometryTypeSchema.index({ organizationId: 1, name: 1 }, { unique: true });
|
||||
|
||||
export default mongoose.model<IGeometryType>('GeometryType', GeometryTypeSchema);
|
||||
73
src/server/models/Inspection.ts
Normal file
73
src/server/models/Inspection.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IInspection extends Document {
|
||||
organizationId?: string;
|
||||
createdBy?: string; // Clerk User ID
|
||||
projectId: mongoose.Types.ObjectId;
|
||||
type: 'painting' | 'surface_treatment';
|
||||
|
||||
// Common
|
||||
date?: Date | null;
|
||||
inspector?: string | null;
|
||||
appearance?: 'approved' | 'rejected' | 'notes' | null; // Unified status
|
||||
defects?: string | null; // Observations
|
||||
photos?: string[]; // URLs
|
||||
partTemperature?: number | null;
|
||||
weightKg?: number | null;
|
||||
|
||||
// Painting Specific
|
||||
pieceDescription?: string | null;
|
||||
epsPoints?: (number | null)[];
|
||||
adhesionTest?: string | null;
|
||||
|
||||
// Surface Treatment Specific
|
||||
batch?: string | null; // Lote
|
||||
treatmentExecutor?: string | null;
|
||||
treatmentType?: string | null; // Jateamento, Mecânica...
|
||||
cleaningDegree?: string | null; // Sa 2.5, St 3...
|
||||
roughnessReadings?: (number | null)[]; // 5 measurements
|
||||
flashRust?: string | null;
|
||||
temperature?: number | null;
|
||||
relativeHumidity?: number | null;
|
||||
period?: 'morning' | 'afternoon' | 'night' | null;
|
||||
applicationRecordId?: mongoose.Types.ObjectId; // Link to specific painting batch
|
||||
stockItemId?: mongoose.Types.ObjectId; // Link to Stock Item (Paint used)
|
||||
instrumentId?: mongoose.Types.ObjectId; // Link to Instrument used
|
||||
}
|
||||
|
||||
const InspectionSchema: Schema = new Schema({
|
||||
organizationId: { type: String, index: true },
|
||||
createdBy: { type: String, index: true },
|
||||
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
|
||||
applicationRecordId: { type: Schema.Types.ObjectId, ref: 'ApplicationRecord' },
|
||||
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem' },
|
||||
instrumentId: { type: Schema.Types.ObjectId, ref: 'Instrument' },
|
||||
type: { type: String, enum: ['painting', 'surface_treatment'], default: 'painting', index: true },
|
||||
|
||||
// Common
|
||||
date: { type: Date },
|
||||
inspector: { type: String },
|
||||
appearance: { type: String }, // approved, rejected, notes
|
||||
defects: { type: String },
|
||||
photos: [{ type: String }],
|
||||
partTemperature: { type: Number },
|
||||
weightKg: { type: Number },
|
||||
|
||||
// Painting
|
||||
pieceDescription: { type: String },
|
||||
epsPoints: [{ type: Number }],
|
||||
adhesionTest: { type: String },
|
||||
|
||||
// Surface Treatment
|
||||
batch: { type: String },
|
||||
treatmentExecutor: { type: String },
|
||||
treatmentType: { type: String },
|
||||
cleaningDegree: { type: String },
|
||||
roughnessReadings: [{ type: Number }],
|
||||
flashRust: { type: String },
|
||||
temperature: { type: Number },
|
||||
relativeHumidity: { type: Number },
|
||||
period: { type: String },
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.Inspection || mongoose.model<IInspection>('Inspection', InspectionSchema);
|
||||
40
src/server/models/Instrument.ts
Normal file
40
src/server/models/Instrument.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IInstrument extends Document {
|
||||
organizationId: string;
|
||||
name: string;
|
||||
type: string; // Ex: Medidor de Camada, Termo-higrômetro
|
||||
manufacturer?: string;
|
||||
modelName?: string;
|
||||
serialNumber: string;
|
||||
calibrationDate?: Date;
|
||||
calibrationExpirationDate?: Date;
|
||||
certificateUrl?: string; // URL do PDF
|
||||
status: 'active' | 'inactive' | 'maintenance' | 'expired';
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const InstrumentSchema: Schema = new Schema({
|
||||
organizationId: { type: String, required: true, index: true },
|
||||
name: { type: String, required: true },
|
||||
type: { type: String, required: true },
|
||||
manufacturer: { type: String },
|
||||
modelName: { type: String },
|
||||
serialNumber: { type: String, required: true },
|
||||
calibrationDate: { type: Date },
|
||||
calibrationExpirationDate: { type: Date },
|
||||
certificateUrl: { type: String },
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive', 'maintenance', 'expired'],
|
||||
default: 'active'
|
||||
},
|
||||
notes: { type: String }
|
||||
}, { timestamps: true });
|
||||
|
||||
// Index para evitar duplicidade de número de série dentro da mesma organização
|
||||
InstrumentSchema.index({ organizationId: 1, serialNumber: 1 }, { unique: true });
|
||||
|
||||
export default mongoose.models.Instrument || mongoose.model<IInstrument>('Instrument', InstrumentSchema);
|
||||
63
src/server/models/Message.ts
Normal file
63
src/server/models/Message.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IMessage extends Document {
|
||||
organizationId: string;
|
||||
fromUserId: string; // clerkId do remetente
|
||||
toUserId: string; // clerkId do destinatário
|
||||
message: string;
|
||||
isRead: boolean;
|
||||
readAt?: Date;
|
||||
isArchived: boolean;
|
||||
isDeletedByRecipient: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const MessageSchema: Schema = new Schema(
|
||||
{
|
||||
organizationId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
fromUserId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
toUserId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 255,
|
||||
},
|
||||
isRead: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readAt: {
|
||||
type: Date,
|
||||
},
|
||||
isArchived: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDeletedByRecipient: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Compound index for efficient queries
|
||||
MessageSchema.index({ toUserId: 1, isRead: 1 });
|
||||
MessageSchema.index({ fromUserId: 1, toUserId: 1 });
|
||||
|
||||
export default mongoose.model<IMessage>('Message', MessageSchema);
|
||||
32
src/server/models/Notification.ts
Normal file
32
src/server/models/Notification.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export type NotificationType = 'info' | 'warning' | 'error' | 'success';
|
||||
|
||||
export interface INotification extends Document {
|
||||
organizationId: string;
|
||||
recipientId?: string; // Se null, é para todos da organização
|
||||
title: string;
|
||||
message: string;
|
||||
type: NotificationType;
|
||||
isRead: boolean;
|
||||
isArchived: boolean;
|
||||
archivedBy: string[]; // IDs dos usuários que arquivaram (para notificações globais)
|
||||
deletedBy: string[]; // IDs dos usuários que deletaram (para notificações globais)
|
||||
metadata?: any; // Para guardar IDs de projetos, itens, etc.
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const NotificationSchema: Schema = new Schema({
|
||||
organizationId: { type: String, required: true, index: true },
|
||||
recipientId: { type: String, index: true }, // Opcional
|
||||
title: { type: String, required: true },
|
||||
message: { type: String, required: true },
|
||||
type: { type: String, enum: ['info', 'warning', 'error', 'success'], default: 'info' },
|
||||
isRead: { type: Boolean, default: false },
|
||||
isArchived: { type: Boolean, default: false },
|
||||
archivedBy: [{ type: String }],
|
||||
deletedBy: [{ type: String }],
|
||||
metadata: { type: Schema.Types.Mixed },
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.Notification || mongoose.model<INotification>('Notification', NotificationSchema);
|
||||
17
src/server/models/Organization.ts
Normal file
17
src/server/models/Organization.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IOrganization extends Document {
|
||||
clerkId: string;
|
||||
name?: string;
|
||||
isBanned: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const OrganizationSchema: Schema = new Schema({
|
||||
clerkId: { type: String, required: true, unique: true, index: true },
|
||||
name: { type: String },
|
||||
isBanned: { type: Boolean, default: false },
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.Organization || mongoose.model<IOrganization>('Organization', OrganizationSchema);
|
||||
52
src/server/models/OrganizationMember.ts
Normal file
52
src/server/models/OrganizationMember.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export type OrgRole = 'guest' | 'user' | 'admin';
|
||||
|
||||
export interface IOrganizationMember extends Document {
|
||||
clerkUserId: string;
|
||||
organizationId: string;
|
||||
role: OrgRole;
|
||||
isBanned: boolean;
|
||||
// Denormalized user info for quick access
|
||||
email: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const OrganizationMemberSchema: Schema = new Schema({
|
||||
clerkUserId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
organizationId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['guest', 'user', 'admin'],
|
||||
default: 'guest'
|
||||
},
|
||||
isBanned: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Compound index for unique user per organization
|
||||
OrganizationMemberSchema.index({ clerkUserId: 1, organizationId: 1 }, { unique: true });
|
||||
|
||||
export default mongoose.models.OrganizationMember || mongoose.model<IOrganizationMember>('OrganizationMember', OrganizationMemberSchema);
|
||||
54
src/server/models/PaintingScheme.ts
Normal file
54
src/server/models/PaintingScheme.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IPaintingScheme extends Document {
|
||||
projectId: mongoose.Types.ObjectId;
|
||||
name: string;
|
||||
type?: string | null;
|
||||
coat?: string | null;
|
||||
solidsVolume?: number | null;
|
||||
yieldTheoretical?: number | null;
|
||||
epsMin?: number | null;
|
||||
epsMax?: number | null;
|
||||
dilution?: number | null;
|
||||
manufacturer?: string | null;
|
||||
color?: string | null;
|
||||
notes?: string | null;
|
||||
organizationId?: string;
|
||||
// Consumption Planning
|
||||
paintConsumption?: number | null;
|
||||
thinnerConsumption?: number | null;
|
||||
paintId?: mongoose.Types.ObjectId | null; // Ref to TechnicalDataSheet
|
||||
thinnerId?: mongoose.Types.ObjectId | null; // Ref to TechnicalDataSheet
|
||||
preferredStockItemId?: mongoose.Types.ObjectId | null; // Ref to StockItem (Suggested Batch)
|
||||
}
|
||||
|
||||
const PaintingSchemeSchema: Schema = new Schema({
|
||||
organizationId: { type: String, index: true },
|
||||
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
|
||||
name: { type: String, required: true },
|
||||
type: { type: String },
|
||||
coat: { type: String },
|
||||
solidsVolume: { type: Number },
|
||||
yieldTheoretical: { type: Number },
|
||||
epsMin: { type: Number },
|
||||
epsMax: { type: Number },
|
||||
dilution: { type: Number },
|
||||
manufacturer: { type: String },
|
||||
color: { type: String },
|
||||
notes: { type: String },
|
||||
// Consumption Planning
|
||||
paintConsumption: { type: Number },
|
||||
thinnerConsumption: { type: Number },
|
||||
paintId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet' },
|
||||
thinnerId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet' },
|
||||
preferredStockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem' }
|
||||
}, { strict: false });
|
||||
|
||||
console.log("✅✅✅ PAINTING SCHEME MODEL (WITH CONSUMPTION) LOADED ✅✅✅");
|
||||
|
||||
// Force model recompilation to ensure schema updates are applied
|
||||
if (mongoose.models.PaintingScheme) {
|
||||
delete mongoose.models.PaintingScheme;
|
||||
}
|
||||
|
||||
export default mongoose.model<IPaintingScheme>('PaintingScheme', PaintingSchemeSchema);
|
||||
29
src/server/models/Part.ts
Normal file
29
src/server/models/Part.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IPart extends Document {
|
||||
projectId?: mongoose.Types.ObjectId;
|
||||
description: string;
|
||||
dimensions?: string | null;
|
||||
weight?: number | null;
|
||||
type?: string | null;
|
||||
area?: number | null;
|
||||
complexity?: number | null;
|
||||
quantity: number;
|
||||
notes?: string | null;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
const PartSchema: Schema = new Schema({
|
||||
organizationId: { type: String, index: true },
|
||||
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: false },
|
||||
description: { type: String, required: true },
|
||||
dimensions: { type: String },
|
||||
weight: { type: Number },
|
||||
type: { type: String },
|
||||
area: { type: Number },
|
||||
complexity: { type: Number },
|
||||
quantity: { type: Number, required: true, default: 1 },
|
||||
notes: { type: String },
|
||||
});
|
||||
|
||||
export default mongoose.models.Part || mongoose.model<IPart>('Part', PartSchema);
|
||||
29
src/server/models/Project.ts
Normal file
29
src/server/models/Project.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IProject extends Document {
|
||||
name: string;
|
||||
client: string;
|
||||
startDate?: Date | null;
|
||||
endDate?: Date | null;
|
||||
technician?: string | null;
|
||||
environment?: string | null;
|
||||
organizationId?: string;
|
||||
weightKg?: number | null;
|
||||
status: 'active' | 'archived';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const ProjectSchema: Schema = new Schema({
|
||||
name: { type: String, required: true },
|
||||
client: { type: String, required: true },
|
||||
organizationId: { type: String, index: true },
|
||||
startDate: { type: Date },
|
||||
endDate: { type: Date },
|
||||
technician: { type: String },
|
||||
environment: { type: String },
|
||||
weightKg: { type: Number },
|
||||
status: { type: String, enum: ['active', 'archived'], default: 'active', index: true },
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.Project || mongoose.model<IProject>('Project', ProjectSchema);
|
||||
31
src/server/models/StockAuditLog.ts
Normal file
31
src/server/models/StockAuditLog.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IStockAuditLog extends Document {
|
||||
organizationId?: string;
|
||||
stockItemId: mongoose.Types.ObjectId;
|
||||
movementId?: mongoose.Types.ObjectId; // Optional, might be deleted
|
||||
movementNumber?: number;
|
||||
userId: string;
|
||||
userName: string;
|
||||
action: 'CREATE' | 'UPDATE' | 'DELETE';
|
||||
details: string; // Human readable summary
|
||||
oldValues?: Record<string, any>;
|
||||
newValues?: Record<string, any>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
const StockAuditLogSchema: Schema = new Schema({
|
||||
organizationId: { type: String, index: true },
|
||||
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem', required: true },
|
||||
movementId: { type: Schema.Types.ObjectId, ref: 'StockMovement' },
|
||||
movementNumber: { type: Number },
|
||||
userId: { type: String, required: true },
|
||||
userName: { type: String, required: true },
|
||||
action: { type: String, required: true, enum: ['CREATE', 'UPDATE', 'DELETE'] },
|
||||
details: { type: String, required: true },
|
||||
oldValues: { type: Object },
|
||||
newValues: { type: Object },
|
||||
timestamp: { type: Date, default: Date.now }
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.StockAuditLog || mongoose.model<IStockAuditLog>('StockAuditLog', StockAuditLogSchema);
|
||||
43
src/server/models/StockItem.ts
Normal file
43
src/server/models/StockItem.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IStockItem extends Document {
|
||||
organizationId?: string;
|
||||
createdBy?: string;
|
||||
dataSheetId: mongoose.Types.ObjectId;
|
||||
rrNumber: string; // Registro de Rastreabilidade
|
||||
batchNumber: string; // Lote
|
||||
color?: string;
|
||||
invoiceNumber?: string; // Nota Fiscal
|
||||
receivedBy?: string; // Quem recebeu
|
||||
quantity: number;
|
||||
unit: string;
|
||||
minStock?: number; // Estoque mínimo estipulado
|
||||
expirationDate?: Date;
|
||||
entryDate: Date;
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const StockItemSchema: Schema = new Schema({
|
||||
organizationId: { type: String, index: true },
|
||||
createdBy: { type: String, index: true },
|
||||
dataSheetId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet', required: true },
|
||||
rrNumber: { type: String, required: true },
|
||||
batchNumber: { type: String, required: true },
|
||||
color: { type: String },
|
||||
invoiceNumber: { type: String },
|
||||
receivedBy: { type: String },
|
||||
quantity: { type: Number, required: true, default: 0 },
|
||||
unit: { type: String, required: true },
|
||||
minStock: { type: Number, default: 0 },
|
||||
expirationDate: { type: Date },
|
||||
entryDate: { type: Date, default: Date.now },
|
||||
notes: { type: String }
|
||||
}, { timestamps: true });
|
||||
|
||||
// Compound index to prevent duplicate RR within an organization, if desirable.
|
||||
// For now, indexing RR for fast lookup.
|
||||
StockItemSchema.index({ organizationId: 1, rrNumber: 1 });
|
||||
|
||||
export default mongoose.models.StockItem || mongoose.model<IStockItem>('StockItem', StockItemSchema);
|
||||
34
src/server/models/StockMovement.ts
Normal file
34
src/server/models/StockMovement.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export type MovementType = 'ENTRY' | 'ADJUSTMENT' | 'CONSUMPTION';
|
||||
|
||||
export interface IStockMovement extends Document {
|
||||
organizationId?: string;
|
||||
createdBy?: string;
|
||||
stockItemId: mongoose.Types.ObjectId;
|
||||
movementNumber?: number;
|
||||
type: MovementType;
|
||||
quantity: number; // Positive for entry, negative for exit
|
||||
date: Date;
|
||||
responsible: string; // User who performed the action
|
||||
reason?: string; // For ADJUSTMENT
|
||||
requester?: string; // For CONSUMPTION
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const StockMovementSchema: Schema = new Schema({
|
||||
organizationId: { type: String, index: true },
|
||||
createdBy: { type: String, index: true },
|
||||
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem', required: true },
|
||||
movementNumber: { type: Number },
|
||||
type: { type: String, enum: ['ENTRY', 'ADJUSTMENT', 'CONSUMPTION'], required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
date: { type: Date, default: Date.now },
|
||||
responsible: { type: String, required: true },
|
||||
reason: { type: String },
|
||||
requester: { type: String },
|
||||
notes: { type: String }
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.StockMovement || mongoose.model<IStockMovement>('StockMovement', StockMovementSchema);
|
||||
19
src/server/models/StoredFile.ts
Normal file
19
src/server/models/StoredFile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IStoredFile extends Document {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
data: Buffer;
|
||||
size: number;
|
||||
uploadDate: Date;
|
||||
}
|
||||
|
||||
const StoredFileSchema: Schema = new Schema({
|
||||
filename: { type: String, required: true },
|
||||
contentType: { type: String, required: true },
|
||||
data: { type: Buffer, required: true },
|
||||
size: { type: Number, required: true },
|
||||
uploadDate: { type: Date, default: Date.now }
|
||||
});
|
||||
|
||||
export default mongoose.models.StoredFile || mongoose.model<IStoredFile>('StoredFile', StoredFileSchema);
|
||||
19
src/server/models/SystemSettings.ts
Normal file
19
src/server/models/SystemSettings.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface ISystemSettings extends Document {
|
||||
settingsId: string;
|
||||
appName: string;
|
||||
appSubtitle: string;
|
||||
appLogoUrl?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
const SystemSettingsSchema: Schema = new Schema({
|
||||
settingsId: { type: String, required: true, unique: true, default: 'global' },
|
||||
appName: { type: String, required: true, default: 'GPI' },
|
||||
appSubtitle: { type: String, required: true, default: 'Gestão de Pintura Industrial' },
|
||||
appLogoUrl: { type: String },
|
||||
updatedBy: { type: String } // Email of the dev who updated it
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.SystemSettings || mongoose.model<ISystemSettings>('SystemSettings', SystemSettingsSchema);
|
||||
59
src/server/models/TechnicalDataSheet.ts
Normal file
59
src/server/models/TechnicalDataSheet.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface ITechnicalDataSheet extends Document {
|
||||
name: string;
|
||||
manufacturer?: string;
|
||||
type?: string;
|
||||
fileId?: mongoose.Types.ObjectId;
|
||||
fileUrl: string;
|
||||
uploadDate: Date;
|
||||
solidsVolume?: number;
|
||||
density?: number;
|
||||
mixingRatio?: string;
|
||||
mixingRatioWeight?: string;
|
||||
mixingRatioVolume?: string;
|
||||
wftMin?: number;
|
||||
wftMax?: number;
|
||||
dftMin?: number;
|
||||
dftMax?: number;
|
||||
reducer?: string;
|
||||
yieldTheoretical?: number;
|
||||
dftReference?: number;
|
||||
yieldFactor?: number;
|
||||
dilution?: number;
|
||||
notes?: string;
|
||||
organizationId?: string;
|
||||
manufacturerCode?: string;
|
||||
minStock?: number;
|
||||
typicalApplication?: string;
|
||||
}
|
||||
|
||||
const TechnicalDataSheetSchema: Schema = new Schema({
|
||||
organizationId: { type: String, index: true },
|
||||
name: { type: String, required: true },
|
||||
manufacturer: { type: String },
|
||||
manufacturerCode: { type: String },
|
||||
type: { type: String },
|
||||
minStock: { type: Number },
|
||||
typicalApplication: { type: String },
|
||||
fileId: { type: Schema.Types.ObjectId, ref: 'StoredFile' },
|
||||
fileUrl: { type: String },
|
||||
uploadDate: { type: Date, default: Date.now },
|
||||
solidsVolume: { type: Number },
|
||||
density: { type: Number },
|
||||
mixingRatio: { type: String },
|
||||
mixingRatioWeight: { type: String },
|
||||
mixingRatioVolume: { type: String },
|
||||
wftMin: { type: Number },
|
||||
wftMax: { type: Number },
|
||||
dftMin: { type: Number },
|
||||
dftMax: { type: Number },
|
||||
reducer: { type: String },
|
||||
yieldTheoretical: { type: Number },
|
||||
dftReference: { type: Number },
|
||||
yieldFactor: { type: Number },
|
||||
dilution: { type: Number },
|
||||
notes: { type: String },
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.TechnicalDataSheet || mongoose.model<ITechnicalDataSheet>('TechnicalDataSheet', TechnicalDataSheetSchema);
|
||||
53
src/server/models/User.ts
Normal file
53
src/server/models/User.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export type UserRole = 'guest' | 'user' | 'admin';
|
||||
|
||||
export interface IUser extends Document {
|
||||
clerkId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
isBanned: boolean;
|
||||
organizationId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastSeenAt?: Date;
|
||||
}
|
||||
|
||||
const UserSchema: Schema = new Schema({
|
||||
clerkId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
organizationId: {
|
||||
type: String,
|
||||
index: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['guest', 'user', 'admin'],
|
||||
default: 'guest'
|
||||
},
|
||||
isBanned: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
lastSeenAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default mongoose.models.User || mongoose.model<IUser>('User', UserSchema);
|
||||
53
src/server/models/YieldStudy.ts
Normal file
53
src/server/models/YieldStudy.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface IPieceCategory {
|
||||
id: string; // Keep as string for internal mapping if needed, or convert to Sub-document
|
||||
name: string;
|
||||
organizationId?: string;
|
||||
weight: number;
|
||||
area?: number; // Área em m² para cálculo alternativo
|
||||
historicalYield: number;
|
||||
historicalDft: number;
|
||||
efficiency: number;
|
||||
}
|
||||
|
||||
const PieceCategorySchema: Schema = new Schema({
|
||||
name: { type: String, required: true },
|
||||
weight: { type: Number, required: true },
|
||||
area: { type: Number }, // Área em m² (opcional)
|
||||
historicalYield: { type: Number, required: true },
|
||||
historicalDft: { type: Number, required: true },
|
||||
efficiency: { type: Number, required: true },
|
||||
});
|
||||
|
||||
export interface IYieldStudy extends Document {
|
||||
name: string;
|
||||
organizationId?: string;
|
||||
dataSheetId: mongoose.Types.ObjectId;
|
||||
targetDft: number;
|
||||
dilutionPercent: number;
|
||||
categories: IPieceCategory[];
|
||||
totalWeight: number;
|
||||
estimatedPaintVolume: number;
|
||||
estimatedReducerVolume: number;
|
||||
estimatedPaintVolumeByArea?: number; // Cálculo por área (m²)
|
||||
estimatedReducerVolumeByArea?: number; // Cálculo por área (m²)
|
||||
averageComplexity: number;
|
||||
}
|
||||
|
||||
const YieldStudySchema: Schema = new Schema({
|
||||
name: { type: String, required: true },
|
||||
organizationId: { type: String, index: true },
|
||||
dataSheetId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet', required: true },
|
||||
targetDft: { type: Number, required: true },
|
||||
dilutionPercent: { type: Number, default: 0 },
|
||||
categories: [PieceCategorySchema],
|
||||
totalWeight: { type: Number },
|
||||
estimatedPaintVolume: { type: Number },
|
||||
estimatedReducerVolume: { type: Number },
|
||||
estimatedPaintVolumeByArea: { type: Number }, // Cálculo por área
|
||||
estimatedReducerVolumeByArea: { type: Number }, // Cálculo por área
|
||||
averageComplexity: { type: Number },
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.YieldStudy || mongoose.model<IYieldStudy>('YieldStudy', YieldStudySchema);
|
||||
8
src/server/routes/analysisRoutes.ts
Normal file
8
src/server/routes/analysisRoutes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import * as analysisController from '../controllers/analysisController.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/projects/:projectId/analysis', analysisController.getProjectAnalysis);
|
||||
|
||||
export default router;
|
||||
15
src/server/routes/applicationRecordRoutes.ts
Normal file
15
src/server/routes/applicationRecordRoutes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import * as appRecordController from '../controllers/applicationRecordController.js';
|
||||
import { extractUser, requireUser } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Public routes (read-only)
|
||||
router.get('/project/:projectId', extractUser, appRecordController.getApplicationRecordsByProject);
|
||||
|
||||
// Protected routes (require user permission)
|
||||
router.post('/', extractUser, requireUser, appRecordController.createApplicationRecord);
|
||||
router.put('/:id', extractUser, requireUser, appRecordController.updateApplicationRecord);
|
||||
router.delete('/:id', extractUser, requireUser, appRecordController.deleteApplicationRecord);
|
||||
|
||||
export default router;
|
||||
117
src/server/routes/backupRoutes.ts
Normal file
117
src/server/routes/backupRoutes.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { backupService } from '../services/backupService.js';
|
||||
import { requireRole } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/backup/export
|
||||
* Exporta todos os dados da organização como JSON para download
|
||||
*/
|
||||
router.get('/export', requireRole(['admin']), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ message: 'Organization ID não fornecido' });
|
||||
}
|
||||
|
||||
const backupData = await backupService.exportData(organizationId);
|
||||
|
||||
// Gera nome do arquivo com timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||
const filename = `backup_${timestamp}.json`;
|
||||
|
||||
// Define headers para download
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
res.json(backupData);
|
||||
} catch (error) {
|
||||
console.error('Erro ao exportar backup:', error);
|
||||
res.status(500).json({ message: 'Erro ao gerar backup' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/backup/import
|
||||
* Importa dados de um arquivo de backup JSON
|
||||
*/
|
||||
router.post('/import', requireRole(['admin']), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
const backupData = req.body;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ message: 'Organization ID não fornecido' });
|
||||
}
|
||||
|
||||
if (!backupData || !backupData.version || !backupData.data) {
|
||||
return res.status(400).json({ message: 'Formato de backup inválido' });
|
||||
}
|
||||
|
||||
// Validação adicional: verifica se o backup é da mesma organização
|
||||
if (backupData.organizationId !== organizationId) {
|
||||
return res.status(403).json({
|
||||
message: 'Este backup pertence a outra organização e não pode ser restaurado aqui'
|
||||
});
|
||||
}
|
||||
|
||||
await backupService.importData(backupData, organizationId);
|
||||
|
||||
res.json({
|
||||
message: 'Backup restaurado com sucesso',
|
||||
stats: backupService.getBackupStats(backupData)
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao importar backup:', error);
|
||||
res.status(500).json({
|
||||
message: error.message || 'Erro ao restaurar backup'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/backup/validate
|
||||
* Valida um arquivo de backup sem importá-lo
|
||||
*/
|
||||
router.post('/validate', requireRole(['admin']), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
const backupData = req.body;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(400).json({ message: 'Organization ID não fornecido' });
|
||||
}
|
||||
|
||||
if (!backupData || !backupData.version || !backupData.data) {
|
||||
return res.status(400).json({
|
||||
valid: false,
|
||||
message: 'Formato de backup inválido'
|
||||
});
|
||||
}
|
||||
|
||||
const isValidOrganization = backupData.organizationId === organizationId;
|
||||
const stats = backupService.getBackupStats(backupData);
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
isValidOrganization,
|
||||
version: backupData.version,
|
||||
timestamp: backupData.timestamp,
|
||||
organizationId: backupData.organizationId,
|
||||
stats,
|
||||
message: isValidOrganization
|
||||
? 'Backup válido e compatível com esta organização'
|
||||
: 'Backup válido, mas pertence a outra organização'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao validar backup:', error);
|
||||
res.status(400).json({
|
||||
valid: false,
|
||||
message: 'Erro ao validar arquivo de backup'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
44
src/server/routes/dataSheetRoutes.ts
Normal file
44
src/server/routes/dataSheetRoutes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as dataSheetController from '../controllers/dataSheetController.js';
|
||||
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
|
||||
import os from 'os';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Configure Multer
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, os.tmpdir());
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${uuidv4()}`;
|
||||
cb(null, `${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'application/pdf' || file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only PDF and image files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Public routes (read-only)
|
||||
router.get('/', dataSheetController.getAllDataSheets);
|
||||
router.get('/file/:id', dataSheetController.getFile);
|
||||
|
||||
// Protected routes (require edit permission)
|
||||
router.post('/', extractUser, requireAdmin, upload.single('file'), dataSheetController.createDataSheet);
|
||||
router.post('/extract', extractUser, requireAdmin, upload.single('file'), dataSheetController.extractData);
|
||||
router.put('/:id', extractUser, requireAdmin, upload.single('file'), dataSheetController.updateDataSheet);
|
||||
router.delete('/:id', extractUser, requireAdmin, dataSheetController.deleteDataSheet);
|
||||
|
||||
export default router;
|
||||
16
src/server/routes/geometryTypeRoutes.ts
Normal file
16
src/server/routes/geometryTypeRoutes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import * as geometryTypeController from '../controllers/geometryTypeController.js';
|
||||
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Retrieve all types (public read for authenticated users, auto-seeds)
|
||||
router.get('/', extractUser, geometryTypeController.getAllnames);
|
||||
|
||||
// Protected Routes (Edit Only)
|
||||
router.post('/restore', extractUser, requireAdmin, geometryTypeController.restoreDefaults);
|
||||
router.post('/', extractUser, requireAdmin, geometryTypeController.createType);
|
||||
router.put('/:id', extractUser, requireAdmin, geometryTypeController.updateType);
|
||||
router.delete('/:id', extractUser, requireAdmin, geometryTypeController.deleteType);
|
||||
|
||||
export default router;
|
||||
56
src/server/routes/inspectionRoutes.ts
Normal file
56
src/server/routes/inspectionRoutes.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Router } from 'express';
|
||||
import * as inspectionController from '../controllers/inspectionController.js';
|
||||
import { extractUser, requireUser } from '../middleware/roleMiddleware.js';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import os from 'os';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Configure Multer for uploads
|
||||
// In serverless environments (Netlify), we must use /tmp or memory
|
||||
// We use /tmp but be aware files are ephemeral in serverless
|
||||
const uploadsDir = path.join(os.tmpdir(), 'uploads');
|
||||
|
||||
// Ensure uploads directory exists (safe in /tmp)
|
||||
import fs from 'fs';
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadsDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${uuidv4()}`;
|
||||
cb(null, `${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 500 * 1024 }, // 500KB limit
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'image/jpeg' || file.mimetype === 'image/jpg') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPG/JPEG images are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Upload route
|
||||
router.post('/upload', extractUser, requireUser, upload.single('photo'), inspectionController.uploadPhoto);
|
||||
|
||||
// Public routes (read-only)
|
||||
router.get('/', extractUser, inspectionController.getAllInspections);
|
||||
router.get('/project/:projectId', extractUser, inspectionController.getInspectionsByProject);
|
||||
|
||||
// Protected routes (require user permission)
|
||||
router.post('/', extractUser, requireUser, inspectionController.createInspection);
|
||||
router.put('/:id', extractUser, requireUser, inspectionController.updateInspection);
|
||||
router.delete('/:id', extractUser, requireUser, inspectionController.deleteInspection);
|
||||
|
||||
export default router;
|
||||
12
src/server/routes/instrumentRoutes.ts
Normal file
12
src/server/routes/instrumentRoutes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import * as instrumentController from '../controllers/instrumentController.js';
|
||||
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/', extractUser, requireAdmin, instrumentController.createInstrument);
|
||||
router.get('/', extractUser, instrumentController.getInstruments);
|
||||
router.put('/:id', extractUser, requireAdmin, instrumentController.updateInstrument);
|
||||
router.delete('/:id', extractUser, requireAdmin, instrumentController.deleteInstrument);
|
||||
|
||||
export default router;
|
||||
16
src/server/routes/messageRoutes.ts
Normal file
16
src/server/routes/messageRoutes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import express from 'express';
|
||||
import { sendMessage, getUnreadMessages, markMessageAsRead, getMyPendingMessages, deleteMessage, archiveMessage, recipientDeleteMessage } from '../controllers/messageController.js';
|
||||
import { extractUser } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.post('/', extractUser, sendMessage);
|
||||
router.get('/unread', extractUser, getUnreadMessages);
|
||||
router.get('/pending', extractUser, getMyPendingMessages);
|
||||
router.patch('/:id/read', extractUser, markMessageAsRead);
|
||||
router.patch('/:id/archive', extractUser, archiveMessage);
|
||||
router.delete('/:id', extractUser, deleteMessage); // Sender side (if unread)
|
||||
router.delete('/:id/recipient', extractUser, recipientDeleteMessage); // Recipient side
|
||||
|
||||
export default router;
|
||||
13
src/server/routes/notificationRoutes.ts
Normal file
13
src/server/routes/notificationRoutes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { notificationController } from '../controllers/notificationController.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', notificationController.getUserNotifications);
|
||||
router.put('/:id/read', notificationController.markAsRead);
|
||||
router.put('/read-all', notificationController.markAllAsRead);
|
||||
router.delete('/clear-all', notificationController.clearAll);
|
||||
router.patch('/:id/archive', notificationController.archive);
|
||||
router.delete('/:id', notificationController.delete);
|
||||
|
||||
export default router;
|
||||
16
src/server/routes/paintingSchemeRoutes.ts
Normal file
16
src/server/routes/paintingSchemeRoutes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import * as paintingSchemeController from '../controllers/paintingSchemeController.js';
|
||||
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Public routes (read-only)
|
||||
router.get('/', paintingSchemeController.getAllPaintingSchemes);
|
||||
router.get('/project/:projectId', paintingSchemeController.getPaintingSchemesByProject);
|
||||
|
||||
// Protected routes (require admin permission)
|
||||
router.post('/', extractUser, requireAdmin, paintingSchemeController.createPaintingScheme);
|
||||
router.put('/:id', extractUser, requireAdmin, paintingSchemeController.updatePaintingScheme);
|
||||
router.delete('/:id', extractUser, requireAdmin, paintingSchemeController.deletePaintingScheme);
|
||||
|
||||
export default router;
|
||||
16
src/server/routes/partRoutes.ts
Normal file
16
src/server/routes/partRoutes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import * as partController from '../controllers/partController.js';
|
||||
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Public routes (read-only)
|
||||
router.get('/', extractUser, partController.getAllParts);
|
||||
router.get('/project/:projectId', extractUser, partController.getPartsByProject);
|
||||
|
||||
// Protected routes (require admin permission)
|
||||
router.post('/', extractUser, requireAdmin, partController.createPart);
|
||||
router.put('/:id', extractUser, requireAdmin, partController.updatePart);
|
||||
router.delete('/:id', extractUser, requireAdmin, partController.deletePart);
|
||||
|
||||
export default router;
|
||||
18
src/server/routes/projectRoutes.ts
Normal file
18
src/server/routes/projectRoutes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import * as projectController from '../controllers/projectController.js';
|
||||
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Rotas com contexto de usuário (leitura)
|
||||
router.get('/', extractUser, projectController.getAllProjects);
|
||||
router.get('/dashboard', extractUser, projectController.getDashboardProjects);
|
||||
router.get('/:id', extractUser, projectController.getProjectById);
|
||||
|
||||
// Protected routes (require admin permission)
|
||||
router.post('/', extractUser, requireAdmin, projectController.createProject);
|
||||
router.put('/:id', extractUser, requireAdmin, projectController.updateProject);
|
||||
router.delete('/:id', extractUser, requireAdmin, projectController.deleteProject);
|
||||
router.patch('/:id/archive', extractUser, requireAdmin, projectController.archiveProject);
|
||||
|
||||
export default router;
|
||||
40
src/server/routes/stockRoutes.ts
Normal file
40
src/server/routes/stockRoutes.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Router } from 'express';
|
||||
import * as stockController from '../controllers/stockController.js';
|
||||
import { extractUser, requireAdmin, requireUser } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Retrieve all items
|
||||
router.get('/', extractUser, stockController.getStockItems);
|
||||
|
||||
// Retrieve Item Details
|
||||
router.get('/:id', extractUser, stockController.getStockItemById);
|
||||
|
||||
// Retrieve movements for a specific item
|
||||
router.get('/:id/movements', extractUser, stockController.getStockMovements);
|
||||
|
||||
// Retrieve logs for a specific item
|
||||
router.get('/:id/logs', extractUser, stockController.getStockAuditLogs);
|
||||
|
||||
// Create (Entry)
|
||||
router.post('/', extractUser, requireUser, stockController.createStockItem);
|
||||
|
||||
// Update Details (No quantity)
|
||||
router.put('/:id', extractUser, requireAdmin, stockController.updateStockItem);
|
||||
|
||||
// Technical Adjustment (Baixa Técnica / Correção)
|
||||
router.post('/:id/adjust', extractUser, requireAdmin, stockController.adjustStock);
|
||||
|
||||
// Consumption (Baixa por Obra)
|
||||
router.post('/:id/consume', extractUser, requireUser, stockController.consumeStock);
|
||||
|
||||
// Delete Stock Item (and its movements)
|
||||
router.delete('/:id', extractUser, requireAdmin, stockController.deleteStockItem);
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Movement CRUD
|
||||
// -----------------------------------------------------------
|
||||
router.put('/movements/:id', extractUser, requireAdmin, stockController.updateStockMovement);
|
||||
router.delete('/movements/:id', extractUser, requireAdmin, stockController.deleteStockMovement);
|
||||
|
||||
export default router;
|
||||
30
src/server/routes/systemSettingsRoutes.ts
Normal file
30
src/server/routes/systemSettingsRoutes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import express from 'express';
|
||||
import { getSettings, updateSettings, uploadLogo, serveLogo } from '../controllers/systemSettingsController.js';
|
||||
import { extractUser, requireDeveloper } from '../middleware/roleMiddleware.js';
|
||||
import { uploadLogoDetails } from '../middleware/uploadMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Public/Auth access to read settings
|
||||
router.get('/', getSettings);
|
||||
|
||||
// Protected update
|
||||
router.put('/', extractUser, requireDeveloper, updateSettings);
|
||||
|
||||
// Protected upload
|
||||
router.post('/logo', extractUser, requireDeveloper, uploadLogoDetails.single('logo'), uploadLogo);
|
||||
|
||||
// Public access to view logo (noauth needed for login screen etc)
|
||||
router.get('/logo-image/:filename', (req, res) => {
|
||||
// Use static import to ensure bundler compatibility
|
||||
serveLogo(req, res);
|
||||
});
|
||||
|
||||
// Global Admin Routes
|
||||
import { getGlobalUsers, getGlobalOrganizations, toggleOrganizationBan } from '../controllers/systemSettingsController.js';
|
||||
|
||||
router.get('/users', extractUser, requireDeveloper, getGlobalUsers);
|
||||
router.get('/organizations', extractUser, requireDeveloper, getGlobalOrganizations);
|
||||
router.post('/organizations/ban', extractUser, requireDeveloper, toggleOrganizationBan);
|
||||
|
||||
export default router;
|
||||
27
src/server/routes/uploadsRoutes.ts
Normal file
27
src/server/routes/uploadsRoutes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/:filename', (req, res) => {
|
||||
const { filename } = req.params;
|
||||
// Sanitize filename to prevent directory traversal
|
||||
const safeFilename = path.basename(filename);
|
||||
|
||||
// Path in tmp dir
|
||||
const filePath = path.join(os.tmpdir(), 'uploads', safeFilename);
|
||||
const localPath = path.join(process.cwd(), 'uploads', safeFilename);
|
||||
|
||||
// Try serving from tmp first (serverless), then local (dev)
|
||||
if (fs.existsSync(filePath)) {
|
||||
res.sendFile(filePath);
|
||||
} else if (fs.existsSync(localPath)) {
|
||||
res.sendFile(localPath);
|
||||
} else {
|
||||
res.status(404).send('File not found');
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
23
src/server/routes/userRoutes.ts
Normal file
23
src/server/routes/userRoutes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import express from 'express';
|
||||
import { syncUser, getCurrentUser, getAllUsers, updateUserRole, toggleBanUser, heartbeat, getActiveUsers, deleteUser } from '../controllers/userController.js';
|
||||
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Sync user from Clerk (public - called on login)
|
||||
router.post('/sync', syncUser);
|
||||
|
||||
// Get current user (requires extractUser middleware)
|
||||
router.get('/me', extractUser, getCurrentUser);
|
||||
|
||||
// Heartbeat & Presence
|
||||
router.post('/heartbeat', extractUser, heartbeat);
|
||||
router.get('/active', extractUser, getActiveUsers);
|
||||
|
||||
// Admin-only routes
|
||||
router.get('/', extractUser, requireAdmin, getAllUsers);
|
||||
router.patch('/:id/role', extractUser, requireAdmin, updateUserRole);
|
||||
router.patch('/:id/ban', extractUser, requireAdmin, toggleBanUser);
|
||||
router.delete('/:id', extractUser, requireAdmin, deleteUser);
|
||||
|
||||
export default router;
|
||||
15
src/server/routes/yieldStudyRoutes.ts
Normal file
15
src/server/routes/yieldStudyRoutes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import * as yieldStudyController from '../controllers/yieldStudyController.js';
|
||||
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Public routes (read-only)
|
||||
router.get('/', extractUser, yieldStudyController.getAllStudies);
|
||||
|
||||
// Protected routes (require admin permission)
|
||||
router.post('/', extractUser, requireAdmin, yieldStudyController.createStudy);
|
||||
router.put('/:id', extractUser, requireAdmin, yieldStudyController.updateStudy);
|
||||
router.delete('/:id', extractUser, requireAdmin, yieldStudyController.deleteStudy);
|
||||
|
||||
export default router;
|
||||
121
src/server/scripts/migrate.ts
Normal file
121
src/server/scripts/migrate.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import mongoose from 'mongoose';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Import Models
|
||||
import Project from '../models/Project.js';
|
||||
import Part from '../models/Part.js';
|
||||
import PaintingScheme from '../models/PaintingScheme.js';
|
||||
import ApplicationRecord from '../models/ApplicationRecord.js';
|
||||
import Inspection from '../models/Inspection.js';
|
||||
import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
|
||||
import YieldStudy from '../models/YieldStudy.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
|
||||
const readJson = (filename: string) => {
|
||||
const filePath = path.join(DATA_DIR, filename);
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
};
|
||||
|
||||
const migrate = async () => {
|
||||
try {
|
||||
const uri = process.env.MONGODB_URI;
|
||||
if (!uri) throw new Error('MONGODB_URI not defined');
|
||||
await mongoose.connect(uri);
|
||||
console.log('Connected to MongoDB for migration...');
|
||||
|
||||
const idMap = new Map<string, mongoose.Types.ObjectId>();
|
||||
|
||||
// 1. TechnicalDataSheets
|
||||
console.log('Migrating TechnicalDataSheets...');
|
||||
const datasheets = readJson('datasheets.json');
|
||||
for (const ds of datasheets) {
|
||||
const newDs = new TechnicalDataSheet({
|
||||
...ds,
|
||||
_id: new mongoose.Types.ObjectId()
|
||||
});
|
||||
await newDs.save();
|
||||
idMap.set(ds.id, newDs._id as mongoose.Types.ObjectId);
|
||||
}
|
||||
|
||||
// 2. Projects
|
||||
console.log('Migrating Projects...');
|
||||
const projects = readJson('projects.json');
|
||||
for (const p of projects) {
|
||||
const newP = new Project({
|
||||
...p,
|
||||
_id: new mongoose.Types.ObjectId()
|
||||
});
|
||||
await newP.save();
|
||||
idMap.set(p.id, newP._id as mongoose.Types.ObjectId);
|
||||
}
|
||||
|
||||
// 3. Parts
|
||||
console.log('Migrating Parts...');
|
||||
const parts = readJson('parts.json');
|
||||
for (const part of parts) {
|
||||
const projectId = idMap.get(part.projectId);
|
||||
if (projectId) {
|
||||
const newPart = new Part({ ...part, projectId });
|
||||
await newPart.save();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. PaintingSchemes
|
||||
console.log('Migrating PaintingSchemes...');
|
||||
const schemes = readJson('paintingSchemes.json');
|
||||
for (const s of schemes) {
|
||||
const projectId = idMap.get(s.projectId);
|
||||
if (projectId) {
|
||||
const newS = new PaintingScheme({ ...s, projectId });
|
||||
await newS.save();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. ApplicationRecords
|
||||
console.log('Migrating ApplicationRecords...');
|
||||
const records = readJson('applicationRecords.json');
|
||||
for (const r of records) {
|
||||
const projectId = idMap.get(r.projectId);
|
||||
if (projectId) {
|
||||
const newR = new ApplicationRecord({ ...r, projectId });
|
||||
await newR.save();
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Inspections
|
||||
console.log('Migrating Inspections...');
|
||||
const inspections = readJson('inspections.json');
|
||||
for (const i of inspections) {
|
||||
const projectId = idMap.get(i.projectId);
|
||||
if (projectId) {
|
||||
const newI = new Inspection({ ...i, projectId });
|
||||
await newI.save();
|
||||
}
|
||||
}
|
||||
|
||||
// 7. YieldStudies
|
||||
console.log('Migrating YieldStudies...');
|
||||
const studies = readJson('yield_studies.json');
|
||||
for (const s of studies) {
|
||||
const dataSheetId = idMap.get(s.dataSheetId);
|
||||
if (dataSheetId) {
|
||||
const newS = new YieldStudy({ ...s, dataSheetId });
|
||||
await newS.save();
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Migration completed successfully!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
migrate();
|
||||
48
src/server/scripts/setAdmin.ts
Normal file
48
src/server/scripts/setAdmin.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const MONGODB_URI = 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0';
|
||||
|
||||
const UserSchema = new mongoose.Schema({
|
||||
clerkId: String,
|
||||
email: String,
|
||||
name: String,
|
||||
role: { type: String, enum: ['guest', 'user', 'admin'], default: 'guest' },
|
||||
isBanned: { type: Boolean, default: false }
|
||||
}, { timestamps: true });
|
||||
|
||||
async function fixAdmin() {
|
||||
await mongoose.connect(MONGODB_URI);
|
||||
console.log('✅ Conectado ao MongoDB');
|
||||
|
||||
const User = mongoose.model('User', UserSchema);
|
||||
|
||||
// Resetar m.reifonas para guest
|
||||
await User.updateOne(
|
||||
{ email: 'm.reifonas@gmail.com' },
|
||||
{ $set: { role: 'guest' } }
|
||||
);
|
||||
console.log('✅ m.reifonas@gmail.com resetado para guest');
|
||||
|
||||
// Atualizar admtracksteel para admin (o com clerkId real)
|
||||
const result = await User.updateOne(
|
||||
{ email: 'admtracksteel@gmail.com', clerkId: { $ne: 'pending' } },
|
||||
{ $set: { role: 'admin' } }
|
||||
);
|
||||
console.log('✅ admtracksteel@gmail.com atualizado para admin', result.modifiedCount > 0 ? '(sucesso)' : '(não encontrado)');
|
||||
|
||||
// Listar todos os usuários
|
||||
const users = await User.find({});
|
||||
console.log('\n📋 Usuários atualizados:');
|
||||
users.forEach((u, i) => {
|
||||
console.log(` ${i + 1}. ${u.email} | role: ${u.role}`);
|
||||
});
|
||||
|
||||
await mongoose.disconnect();
|
||||
console.log('\n✅ Desconectado do MongoDB');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fixAdmin().catch(err => {
|
||||
console.error('❌ Erro:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
85
src/server/services/FileStorageService.ts
Normal file
85
src/server/services/FileStorageService.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
|
||||
// Ensure data directory exists
|
||||
const initDataDir = async () => {
|
||||
try {
|
||||
await fs.access(DATA_DIR);
|
||||
} catch {
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
initDataDir();
|
||||
|
||||
export class FileStorageService<T extends { id: string }> {
|
||||
private filePath: string;
|
||||
|
||||
constructor(filename: string) {
|
||||
this.filePath = path.join(DATA_DIR, filename);
|
||||
}
|
||||
|
||||
private async readFile(): Promise<T[]> {
|
||||
try {
|
||||
await initDataDir();
|
||||
const data = await fs.readFile(this.filePath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'ENOENT') {
|
||||
return []; // File doesn't exist yet, return empty array
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeFile(data: T[]): Promise<void> {
|
||||
await initDataDir();
|
||||
await fs.writeFile(this.filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async getAll(): Promise<T[]> {
|
||||
return this.readFile();
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<T | null> {
|
||||
const items = await this.readFile();
|
||||
return items.find(item => item.id === id) || null;
|
||||
}
|
||||
|
||||
async create(data: Omit<T, 'id'>): Promise<T> {
|
||||
const items = await this.readFile();
|
||||
const newItem = { ...data, id: uuidv4() } as T;
|
||||
items.push(newItem);
|
||||
await this.writeFile(items);
|
||||
return newItem;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<T>): Promise<T | null> {
|
||||
const items = await this.readFile();
|
||||
const index = items.findIndex(item => item.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
items[index] = { ...items[index], ...data };
|
||||
await this.writeFile(items);
|
||||
return items[index];
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const items = await this.readFile();
|
||||
const filtered = items.filter(item => item.id !== id);
|
||||
if (filtered.length === items.length) return false;
|
||||
|
||||
await this.writeFile(filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper for filtering by foreign keys (e.g., get parts by projectId)
|
||||
async getByField(field: keyof T, value: T[keyof T]): Promise<T[]> {
|
||||
const items = await this.readFile();
|
||||
return items.filter(item => item[field] === value);
|
||||
}
|
||||
}
|
||||
72
src/server/services/applicationRecordService.ts
Normal file
72
src/server/services/applicationRecordService.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import ApplicationRecord from '../models/ApplicationRecord.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() };
|
||||
};
|
||||
|
||||
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() }));
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// 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() };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
// Organization Check
|
||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Role/Ownership check
|
||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await ApplicationRecord.deleteOne({ _id: id });
|
||||
return true;
|
||||
};
|
||||
162
src/server/services/backupService.ts
Normal file
162
src/server/services/backupService.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import Project from '../models/Project.js';
|
||||
import Inspection from '../models/Inspection.js';
|
||||
import ApplicationRecord from '../models/ApplicationRecord.js';
|
||||
import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
|
||||
import PaintingScheme from '../models/PaintingScheme.js';
|
||||
import Part from '../models/Part.js';
|
||||
import Instrument from '../models/Instrument.js';
|
||||
import YieldStudy from '../models/YieldStudy.js';
|
||||
import GeometryType from '../models/GeometryType.js';
|
||||
import StockItem from '../models/StockItem.js';
|
||||
import StockMovement from '../models/StockMovement.js';
|
||||
|
||||
interface BackupData {
|
||||
version: string;
|
||||
timestamp: string;
|
||||
organizationId: string;
|
||||
data: {
|
||||
projects: any[];
|
||||
inspections: any[];
|
||||
applicationRecords: any[];
|
||||
technicalDataSheets: any[];
|
||||
paintingSchemes: any[];
|
||||
parts: any[];
|
||||
instruments: any[];
|
||||
yieldStudies: any[];
|
||||
geometryTypes: any[];
|
||||
stockItems: any[];
|
||||
stockMovements: any[];
|
||||
};
|
||||
}
|
||||
|
||||
export const backupService = {
|
||||
/**
|
||||
* Exporta todos os dados da organização para um objeto JSON
|
||||
*/
|
||||
async exportData(organizationId: string): Promise<BackupData> {
|
||||
try {
|
||||
const [
|
||||
projects,
|
||||
inspections,
|
||||
applicationRecords,
|
||||
technicalDataSheets,
|
||||
paintingSchemes,
|
||||
parts,
|
||||
instruments,
|
||||
yieldStudies,
|
||||
geometryTypes,
|
||||
stockItems,
|
||||
stockMovements
|
||||
] = await Promise.all([
|
||||
Project.find({ organizationId }).lean(),
|
||||
Inspection.find({ organizationId }).lean(),
|
||||
ApplicationRecord.find({ organizationId }).lean(),
|
||||
TechnicalDataSheet.find({ organizationId }).lean(),
|
||||
PaintingScheme.find({ organizationId }).lean(),
|
||||
Part.find({ organizationId }).lean(),
|
||||
Instrument.find({ organizationId }).lean(),
|
||||
YieldStudy.find({ organizationId }).lean(),
|
||||
GeometryType.find({ organizationId }).lean(),
|
||||
StockItem.find({ organizationId }).lean(),
|
||||
StockMovement.find({ organizationId }).lean()
|
||||
]);
|
||||
|
||||
const backup: BackupData = {
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
organizationId,
|
||||
data: {
|
||||
projects,
|
||||
inspections,
|
||||
applicationRecords,
|
||||
technicalDataSheets,
|
||||
paintingSchemes,
|
||||
parts,
|
||||
instruments,
|
||||
yieldStudies,
|
||||
geometryTypes,
|
||||
stockItems,
|
||||
stockMovements
|
||||
}
|
||||
};
|
||||
|
||||
return backup;
|
||||
} catch (error) {
|
||||
console.error('Erro ao exportar dados:', error);
|
||||
throw new Error('Falha ao criar backup dos dados');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Importa dados de um backup JSON e sobrescreve os dados atuais da organização
|
||||
*/
|
||||
async importData(backupData: BackupData, organizationId: string): Promise<void> {
|
||||
try {
|
||||
// Validação básica
|
||||
if (!backupData.version || !backupData.data) {
|
||||
throw new Error('Formato de backup inválido');
|
||||
}
|
||||
|
||||
// Verifica se o backup é da mesma organização (segurança adicional)
|
||||
if (backupData.organizationId !== organizationId) {
|
||||
throw new Error('Este backup pertence a outra organização');
|
||||
}
|
||||
|
||||
// Remove todos os dados atuais da organização
|
||||
await Promise.all([
|
||||
Project.deleteMany({ organizationId }),
|
||||
Inspection.deleteMany({ organizationId }),
|
||||
ApplicationRecord.deleteMany({ organizationId }),
|
||||
TechnicalDataSheet.deleteMany({ organizationId }),
|
||||
PaintingScheme.deleteMany({ organizationId }),
|
||||
Part.deleteMany({ organizationId }),
|
||||
Instrument.deleteMany({ organizationId }),
|
||||
YieldStudy.deleteMany({ organizationId }),
|
||||
GeometryType.deleteMany({ organizationId }),
|
||||
StockItem.deleteMany({ organizationId }),
|
||||
StockMovement.deleteMany({ organizationId })
|
||||
]);
|
||||
|
||||
// Insere os dados do backup
|
||||
const { data } = backupData;
|
||||
|
||||
await Promise.all([
|
||||
data.projects.length > 0 ? Project.insertMany(data.projects) : Promise.resolve(),
|
||||
data.inspections.length > 0 ? Inspection.insertMany(data.inspections) : Promise.resolve(),
|
||||
data.applicationRecords.length > 0 ? ApplicationRecord.insertMany(data.applicationRecords) : Promise.resolve(),
|
||||
data.technicalDataSheets.length > 0 ? TechnicalDataSheet.insertMany(data.technicalDataSheets) : Promise.resolve(),
|
||||
data.paintingSchemes.length > 0 ? PaintingScheme.insertMany(data.paintingSchemes) : Promise.resolve(),
|
||||
data.parts.length > 0 ? Part.insertMany(data.parts) : Promise.resolve(),
|
||||
data.instruments.length > 0 ? Instrument.insertMany(data.instruments) : Promise.resolve(),
|
||||
data.yieldStudies.length > 0 ? YieldStudy.insertMany(data.yieldStudies) : Promise.resolve(),
|
||||
data.geometryTypes.length > 0 ? GeometryType.insertMany(data.geometryTypes) : Promise.resolve(),
|
||||
data.stockItems.length > 0 ? StockItem.insertMany(data.stockItems) : Promise.resolve(),
|
||||
data.stockMovements.length > 0 ? StockMovement.insertMany(data.stockMovements) : Promise.resolve()
|
||||
]);
|
||||
|
||||
console.log(`Backup restaurado com sucesso para organização ${organizationId}`);
|
||||
} catch (error) {
|
||||
console.error('Erro ao importar dados:', error);
|
||||
throw new Error('Falha ao restaurar backup');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retorna estatísticas do backup para exibição
|
||||
*/
|
||||
getBackupStats(backupData: BackupData): Record<string, number> {
|
||||
return {
|
||||
projects: backupData.data.projects.length,
|
||||
inspections: backupData.data.inspections.length,
|
||||
applicationRecords: backupData.data.applicationRecords.length,
|
||||
technicalDataSheets: backupData.data.technicalDataSheets.length,
|
||||
paintingSchemes: backupData.data.paintingSchemes.length,
|
||||
parts: backupData.data.parts.length,
|
||||
instruments: backupData.data.instruments.length,
|
||||
yieldStudies: backupData.data.yieldStudies.length,
|
||||
geometryTypes: backupData.data.geometryTypes.length,
|
||||
stockItems: backupData.data.stockItems.length,
|
||||
stockMovements: backupData.data.stockMovements.length
|
||||
};
|
||||
}
|
||||
};
|
||||
174
src/server/services/dataSheetService.ts
Normal file
174
src/server/services/dataSheetService.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { bucket } from '../config/database.js';
|
||||
import { ObjectId } from 'mongodb';
|
||||
|
||||
export const saveFileToGridFS = (localPath: string, filename: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const uploadStream = bucket.openUploadStream(filename);
|
||||
const readStream = fs.createReadStream(localPath);
|
||||
|
||||
readStream.pipe(uploadStream)
|
||||
.on('error', reject)
|
||||
.on('finish', () => {
|
||||
// Remove local file after upload
|
||||
fs.unlink(localPath, (err) => {
|
||||
if (err) console.error('Failed to delete local temp file:', err);
|
||||
});
|
||||
resolve(uploadStream.id.toString());
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const 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));
|
||||
};
|
||||
|
||||
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() }));
|
||||
};
|
||||
|
||||
export const matchSheets = async (query: string, organizationId?: string) => {
|
||||
const orgFilter = organizationId
|
||||
? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }
|
||||
: {};
|
||||
|
||||
const filter = {
|
||||
...orgFilter,
|
||||
$or: [
|
||||
{ name: { $regex: query, $options: 'i' } },
|
||||
{ manufacturer: { $regex: query, $options: 'i' } },
|
||||
{ type: { $regex: query, $options: 'i' } }
|
||||
]
|
||||
};
|
||||
const sheets = await TechnicalDataSheet.find(filter).lean();
|
||||
return sheets.map(s => ({ ...s, id: s._id.toString() }));
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const createDataSheet = async (data: any & { organizationId?: string }) => {
|
||||
let fileId = data.fileUrl;
|
||||
|
||||
// If fileUrl is a local path (exists on disk), move to GridFS
|
||||
if (data.fileUrl && fs.existsSync(data.fileUrl)) {
|
||||
fileId = await saveFileToGridFS(data.fileUrl, data.name + '.pdf');
|
||||
}
|
||||
|
||||
const newSheet = new TechnicalDataSheet({
|
||||
...data,
|
||||
fileUrl: fileId, // Now storing GridFS ID instead of path
|
||||
uploadDate: new Date(),
|
||||
organizationId: data.organizationId
|
||||
});
|
||||
|
||||
const saved = await newSheet.save();
|
||||
return { ...saved.toObject(), id: saved._id.toString() };
|
||||
};
|
||||
|
||||
export const deleteDataSheet = async (id: string, organizationId?: string) => {
|
||||
// Find first to check permissions
|
||||
const sheet = await TechnicalDataSheet.findById(id);
|
||||
if (!sheet) return false;
|
||||
|
||||
// Permission Check:
|
||||
// If current user is in an Org, and Sheet is in a DIFFERENT Org, deny.
|
||||
// Explicitly allow if Sheet has NO Org (Legacy/Global).
|
||||
if (organizationId && sheet.organizationId && sheet.organizationId !== organizationId) {
|
||||
console.warn(`[Delete DataSheet] Access Denied. User Org: ${organizationId}, Sheet Org: ${sheet.organizationId}`);
|
||||
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;
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
81
src/server/services/inspectionService.ts
Normal file
81
src/server/services/inspectionService.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import Inspection from '../models/Inspection.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() };
|
||||
};
|
||||
|
||||
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() }));
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// 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() };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
// Organization Check
|
||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Role/Ownership check
|
||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await Inspection.deleteOne({ _id: 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() }));
|
||||
};
|
||||
|
||||
|
||||
|
||||
397
src/server/services/notificationService.ts
Normal file
397
src/server/services/notificationService.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import Notification, { INotification } from '../models/Notification.js';
|
||||
import StockItem from '../models/StockItem.js';
|
||||
import Instrument from '../models/Instrument.js';
|
||||
import { addMonths, isBefore } from 'date-fns';
|
||||
|
||||
export const notificationService = {
|
||||
// Criar uma notificação
|
||||
async create(data: Partial<INotification>) {
|
||||
try {
|
||||
const notification = new Notification(data);
|
||||
await notification.save();
|
||||
return notification;
|
||||
} catch (error) {
|
||||
console.error('Error creating notification:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Verificar se já existe uma notificação recente para evitar spam
|
||||
async isAlreadyNotified(orgId: string, metadata: Record<string, string>, graceDays: number = 30) {
|
||||
try {
|
||||
const graceDate = new Date();
|
||||
graceDate.setDate(graceDate.getDate() - graceDays);
|
||||
|
||||
const query: Record<string, unknown> = {
|
||||
organizationId: orgId
|
||||
};
|
||||
|
||||
// Adicionar campos de metadata à query
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
query[`metadata.${key}`] = value;
|
||||
}
|
||||
|
||||
// Verificar se existe alguma notificação com essa metadata nos últimos graceDays
|
||||
// Independente de estar lida ou não, para evitar duplicidade.
|
||||
query.createdAt = { $gte: graceDate };
|
||||
|
||||
const existing = await Notification.findOne(query);
|
||||
return !!existing;
|
||||
} catch (error) {
|
||||
console.error('Error checking notification existence:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Obter notificações de um usuário (ou globais da organização)
|
||||
async getUserNotifications(userId: string, organizationId: string, includeArchived: boolean = false) {
|
||||
try {
|
||||
const query: Record<string, unknown> = {
|
||||
organizationId,
|
||||
$or: [
|
||||
{ recipientId: userId },
|
||||
{ recipientId: null } // Notificações globais
|
||||
],
|
||||
deletedBy: { $ne: userId } // Não mostrar as deletadas pelo usuário
|
||||
};
|
||||
|
||||
if (!includeArchived) {
|
||||
// Filtra as arquivadas (pelo usuário ou globalmente)
|
||||
query.isArchived = false;
|
||||
query.archivedBy = { $ne: userId };
|
||||
}
|
||||
|
||||
return await Notification.find(query).sort({ createdAt: -1 }).limit(50);
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Marcar como lida
|
||||
async markAsRead(id: string) {
|
||||
try {
|
||||
return await Notification.findByIdAndUpdate(id, { isRead: true }, { new: true });
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Marcar todas como lidas para um usuário
|
||||
async markAllAsRead(userId: string, organizationId: string) {
|
||||
try {
|
||||
return await Notification.updateMany(
|
||||
{
|
||||
organizationId,
|
||||
$or: [
|
||||
{ recipientId: userId },
|
||||
{ recipientId: null }
|
||||
],
|
||||
isRead: false
|
||||
},
|
||||
{ isRead: true }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error marking all notifications as read:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Arquivar uma notificação para um usuário
|
||||
async archive(id: string, userId: string) {
|
||||
try {
|
||||
const notification = await Notification.findById(id);
|
||||
if (!notification) return null;
|
||||
|
||||
if (notification.recipientId) {
|
||||
// Notificação pessoal
|
||||
notification.isArchived = true;
|
||||
notification.isRead = true;
|
||||
} else {
|
||||
// Notificação global
|
||||
if (!notification.archivedBy.includes(userId)) {
|
||||
notification.archivedBy.push(userId);
|
||||
}
|
||||
// Marcar como lida também? Opcional
|
||||
if (!notification.readBy?.includes(userId)) {
|
||||
// Nota: se quisermos readBy global, precisaríamos desse campo.
|
||||
// Para simplificar, vamos assumir que arquivar esconde da lista ativa.
|
||||
}
|
||||
}
|
||||
return await notification.save();
|
||||
} catch (error) {
|
||||
console.error('Error archiving notification:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Deletar (esconder) uma notificação para um usuário
|
||||
async softDelete(id: string, userId: string) {
|
||||
try {
|
||||
const notification = await Notification.findById(id);
|
||||
if (!notification) return null;
|
||||
|
||||
if (notification.recipientId && notification.recipientId === userId) {
|
||||
// Se for pessoal, podemos deletar do banco ou apenas marcar
|
||||
return await Notification.findByIdAndDelete(id);
|
||||
} else {
|
||||
// Se for global, apenas adicionar ao deletedBy
|
||||
if (!notification.deletedBy.includes(userId)) {
|
||||
notification.deletedBy.push(userId);
|
||||
}
|
||||
return await notification.save();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error soft deleting notification:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Limpar todas (esconder todas as atuais)
|
||||
async clearAll(userId: string, organizationId: string) {
|
||||
try {
|
||||
// Para notificações pessoais: Deletar
|
||||
await Notification.deleteMany({
|
||||
organizationId,
|
||||
recipientId: userId
|
||||
});
|
||||
|
||||
// Para notificações globais: Marcar como deletadas por esse usuário
|
||||
const globalNotifications = await Notification.find({
|
||||
organizationId,
|
||||
recipientId: null,
|
||||
deletedBy: { $ne: userId }
|
||||
});
|
||||
|
||||
for (const notif of globalNotifications) {
|
||||
notif.deletedBy.push(userId);
|
||||
await notif.save();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error clearing all notifications:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Verificar vencimentos de estoque e gerar notificações
|
||||
async checkStockExpirations() {
|
||||
console.log('Running stock expiration checkJob...');
|
||||
try {
|
||||
// Buscar todos os itens de estoque com data de validade que ainda não venceram ou venceram recentemente
|
||||
// Otimização: Em um sistema real, faríamos isso por query direta, mas aqui vamos iterar para aplicar a lógica de 2 meses, 1 mês, vencido.
|
||||
const stockItems = await StockItem.find({ expirationDate: { $exists: true, $ne: null }, quantity: { $gt: 0 } });
|
||||
|
||||
const now = new Date();
|
||||
const twoMonthsFromNow = addMonths(now, 2);
|
||||
const oneMonthFromNow = addMonths(now, 1);
|
||||
|
||||
for (const item of stockItems) {
|
||||
if (!item.expirationDate) continue;
|
||||
|
||||
const expirationDate = new Date(item.expirationDate);
|
||||
const itemId = item._id.toString();
|
||||
const orgId = item.organizationId;
|
||||
|
||||
if (!orgId) continue;
|
||||
|
||||
let message = '';
|
||||
let title = '';
|
||||
let type: 'warning' | 'error' = 'warning';
|
||||
|
||||
// Lógica de notificação
|
||||
// 1. Vencido
|
||||
if (isBefore(expirationDate, now)) {
|
||||
title = 'Item Vencido';
|
||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} venceu em ${expirationDate.toLocaleDateString()}.`;
|
||||
type = 'error';
|
||||
|
||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
||||
stockItemId: itemId,
|
||||
triggerType: 'expired'
|
||||
});
|
||||
|
||||
if (!notified) {
|
||||
await this.create({
|
||||
organizationId: orgId,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
metadata: { stockItemId: itemId, triggerType: 'expired' }
|
||||
});
|
||||
}
|
||||
}
|
||||
// 2. Vence em 1 mês (aprox)
|
||||
else if (isBefore(expirationDate, oneMonthFromNow)) {
|
||||
title = 'Vencimento Próximo (1 mês)';
|
||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em menos de 1 mês (${expirationDate.toLocaleDateString()}).`;
|
||||
|
||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
||||
stockItemId: itemId,
|
||||
triggerType: 'expire_1_month'
|
||||
});
|
||||
|
||||
if (!notified) {
|
||||
await this.create({
|
||||
organizationId: orgId,
|
||||
title,
|
||||
message,
|
||||
type: 'warning',
|
||||
metadata: { stockItemId: itemId, triggerType: 'expire_1_month' }
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
// 3. Vence em 2 meses (aprox)
|
||||
else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
||||
title = 'Vencimento em 2 meses';
|
||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em 2 meses (${expirationDate.toLocaleDateString()}).`;
|
||||
|
||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
||||
stockItemId: itemId,
|
||||
triggerType: 'expire_2_months'
|
||||
});
|
||||
|
||||
if (!notified) {
|
||||
await this.create({
|
||||
organizationId: orgId,
|
||||
title,
|
||||
message,
|
||||
type: 'info',
|
||||
metadata: { stockItemId: itemId, triggerType: 'expire_2_months' }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in checkStockExpirations:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Verificar calibração de instrumentos
|
||||
async checkInstrumentCalibrations() {
|
||||
console.log('Running instrument calibration checkJob...');
|
||||
try {
|
||||
const instruments = await Instrument.find({
|
||||
calibrationExpirationDate: { $exists: true, $ne: null },
|
||||
status: { $ne: 'inactive' }
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const twoMonthsFromNow = addMonths(now, 2);
|
||||
const oneMonthFromNow = addMonths(now, 1);
|
||||
|
||||
for (const instrument of instruments) {
|
||||
if (!instrument.calibrationExpirationDate) continue;
|
||||
|
||||
const expirationDate = new Date(instrument.calibrationExpirationDate);
|
||||
const instrumentId = instrument._id.toString();
|
||||
const orgId = instrument.organizationId;
|
||||
|
||||
if (!orgId) continue;
|
||||
|
||||
let title = '';
|
||||
let message = '';
|
||||
let type: 'info' | 'warning' | 'error' = 'info';
|
||||
let triggerType = '';
|
||||
|
||||
// 1. Vencido
|
||||
if (isBefore(expirationDate, now)) {
|
||||
title = 'Calibração Vencida';
|
||||
message = `O instrumento ${instrument.name} (${instrument.serialNumber}) está com a calibração vencida desde ${expirationDate.toLocaleDateString()}.`;
|
||||
type = 'error';
|
||||
triggerType = 'calibration_expired';
|
||||
|
||||
// Atualizar status para expired se não estiver
|
||||
if (instrument.status !== 'expired') {
|
||||
instrument.status = 'expired';
|
||||
await instrument.save();
|
||||
}
|
||||
|
||||
}
|
||||
// 2. Vence em 1 mês
|
||||
else if (isBefore(expirationDate, oneMonthFromNow)) {
|
||||
title = 'Calibração vence em 1 mês';
|
||||
message = `A calibração do instrumento ${instrument.name} (${instrument.serialNumber}) vence em ${expirationDate.toLocaleDateString()}.`;
|
||||
type = 'warning';
|
||||
triggerType = 'calibration_1_month';
|
||||
}
|
||||
// 3. Vence em 2 meses
|
||||
else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
||||
title = 'Calibração vence em 2 meses';
|
||||
message = `A calibração do instrumento ${instrument.name} (${instrument.serialNumber}) vence em ${expirationDate.toLocaleDateString()}.`;
|
||||
type = 'info';
|
||||
triggerType = 'calibration_2_months';
|
||||
} else {
|
||||
continue; // Não precisa notificar
|
||||
}
|
||||
|
||||
// Evitar spam
|
||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
||||
instrumentId,
|
||||
triggerType
|
||||
});
|
||||
|
||||
if (!notified) {
|
||||
await this.create({
|
||||
organizationId: orgId,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
metadata: { instrumentId, triggerType }
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in checkInstrumentCalibrations:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Verificar se o estoque está abaixo do mínimo (Aggregated by Product + Color)
|
||||
async checkLowStock(stockItemId: string) {
|
||||
try {
|
||||
const item = await StockItem.findById(stockItemId).populate('dataSheetId', 'name manufacturer');
|
||||
if (!item || !item.minStock || item.minStock <= 0) return;
|
||||
|
||||
const orgId = item.organizationId;
|
||||
if (!orgId) return;
|
||||
|
||||
// Aggregate total quantity for this Product + Color
|
||||
const siblings = await StockItem.find({
|
||||
organizationId: orgId,
|
||||
dataSheetId: item.dataSheetId,
|
||||
color: item.color
|
||||
});
|
||||
|
||||
const totalQuantity = siblings.reduce((sum, s) => sum + s.quantity, 0);
|
||||
|
||||
if (totalQuantity < item.minStock) {
|
||||
// Check throttling
|
||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
||||
stockItemId: stockItemId, // Keep using specific item ID as reference or maybe composite key?
|
||||
// Let's use a composite key for the trigger to avoid spamming for every batch in the group
|
||||
productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`,
|
||||
triggerType: 'low_stock_aggregated'
|
||||
}, 3);
|
||||
|
||||
if (!notified) {
|
||||
await this.create({
|
||||
organizationId: orgId,
|
||||
title: 'Estoque Baixo (Total)',
|
||||
message: `O produto ${item.dataSheetId?.name} (Cor: ${item.color || 'N/A'}) atingiu o nível crítico. Total: ${totalQuantity.toFixed(1)}${item.unit}. (Mínimo: ${item.minStock}${item.unit})`,
|
||||
type: 'error',
|
||||
metadata: {
|
||||
stockItemId,
|
||||
productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`,
|
||||
triggerType: 'low_stock_aggregated'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking low stock:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
72
src/server/services/paintingSchemeService.ts
Normal file
72
src/server/services/paintingSchemeService.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import PaintingScheme from '../models/PaintingScheme.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() };
|
||||
};
|
||||
|
||||
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() }));
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const updatePaintingScheme = async (id: string, data: any, organizationId?: string) => {
|
||||
// SECURITY FIX: Allow update if:
|
||||
// 1. Matches ID AND Matches Organization
|
||||
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
|
||||
|
||||
let query: any = { _id: id };
|
||||
|
||||
// First, check if the record exists and what is its state
|
||||
const existing = await PaintingScheme.findById(id);
|
||||
|
||||
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() };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const deletePaintingScheme = async (id: string, organizationId?: string) => {
|
||||
// Find first to check permissions
|
||||
const existing = await PaintingScheme.findById(id);
|
||||
if (!existing) return;
|
||||
|
||||
// Permissions:
|
||||
// If user has org, and item has OTHER org, deny.
|
||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
||||
console.warn(`[Delete PaintingScheme] Access Denied. User Org: ${organizationId}, Scheme Org: ${existing.organizationId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await PaintingScheme.findByIdAndDelete(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() }));
|
||||
};
|
||||
|
||||
|
||||
|
||||
60
src/server/services/partService.ts
Normal file
60
src/server/services/partService.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import Part from '../models/Part.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() };
|
||||
};
|
||||
|
||||
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() }));
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
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() };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const deletePart = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||
const part = await Part.findById(id);
|
||||
if (!part) return;
|
||||
|
||||
if (!isGlobalAdmin && organizationId && part.organizationId && part.organizationId !== organizationId) {
|
||||
throw new Error('Sem permissão para excluir esta peça');
|
||||
}
|
||||
|
||||
await Part.findByIdAndDelete(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() }));
|
||||
};
|
||||
|
||||
|
||||
|
||||
209
src/server/services/pdfExtractionService.ts
Normal file
209
src/server/services/pdfExtractionService.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let pdfParse: any;
|
||||
|
||||
try {
|
||||
pdfParse = require('pdf-parse');
|
||||
} catch (e) {
|
||||
console.error('CRITICAL ERROR: Could not load "pdf-parse". Is it installed?', e);
|
||||
}
|
||||
|
||||
export interface ExtractedData {
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
type?: string;
|
||||
solidsVolume?: number;
|
||||
density?: number;
|
||||
yieldTheoretical?: number;
|
||||
dftReference?: number;
|
||||
yieldFactor?: number;
|
||||
wftMin?: number;
|
||||
wftMax?: number;
|
||||
dftMin?: number;
|
||||
dftMax?: number;
|
||||
reducer?: string;
|
||||
mixingRatioWeight?: string;
|
||||
mixingRatioVolume?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const COMMON_MANUFACTURERS = [
|
||||
'WEG', 'SHERWIN WILLIAMS', 'SHERWIN-WILLIAMS', 'INTERNATIONAL', 'AKZONOBEL', 'HEMPEL', 'JOTUN', 'RENNER', 'RENNER HERRMANN', 'PPG', 'SUMARÉ', 'SUMARE'
|
||||
];
|
||||
|
||||
const PAINT_TYPES = [
|
||||
{ key: 'Epóxi', keywords: ['epóxi', 'epoxi', 'epoxy'] },
|
||||
{ key: 'Poliuretano (PU)', keywords: ['poliuretano', 'polyurethane', 'pu'] },
|
||||
{ key: 'Rico em Zinco', keywords: ['zinco', 'zinc'] },
|
||||
{ key: 'Alquídica', keywords: ['alquídica', 'alkyd'] },
|
||||
{ key: 'Acrílica', keywords: ['acrílica', 'acrylic'] },
|
||||
{ key: 'Fenólica', keywords: ['fenólica', 'phenolic'] },
|
||||
{ key: 'Silicone', keywords: ['silicone'] }
|
||||
];
|
||||
|
||||
export async function extractDataFromPdf(buffer: Buffer): Promise<ExtractedData> {
|
||||
if (!pdfParse) {
|
||||
console.error('pdf-parse library is not loaded. Cannot extract.');
|
||||
return {};
|
||||
}
|
||||
|
||||
let text = '';
|
||||
|
||||
try {
|
||||
console.log('Using pdf-parse v1.1.1 logic...');
|
||||
// pdf-parse v1.1.1 returns a Promise resolving to data object { text: string, ... }
|
||||
const data = await pdfParse(buffer);
|
||||
text = data.text;
|
||||
|
||||
// Log first few chars to debug
|
||||
console.log('--- START EXTRACTED TEXT (First 200 chars) ---');
|
||||
console.log(text.substring(0, 200));
|
||||
console.log('--- END EXTRACTED TEXT ---');
|
||||
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('pdf-parse extraction failed:', err.message, err);
|
||||
return {};
|
||||
}
|
||||
|
||||
// Attempt 2: OCR logic check
|
||||
if (!text || text.trim().length < 20) {
|
||||
console.warn('Text extracted is empty or too short. This might be an image PDF.');
|
||||
// OCR placeholder - we need image conversion first which is hard without binaries
|
||||
}
|
||||
|
||||
if (text) {
|
||||
return parseText(text);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function extractDataWithOCR(_buffer: Buffer): Promise<ExtractedData> {
|
||||
// Placeholder - still requires image conversion logic not present here
|
||||
console.warn('OCR requested but not implemented (requires PDF to Image conversion)');
|
||||
return {};
|
||||
}
|
||||
|
||||
export function parseText(text: string): ExtractedData {
|
||||
const result: ExtractedData = {};
|
||||
const cleanText = text.replace(/\s+/g, ' ').trim();
|
||||
// Keep case for some checks, lower for others
|
||||
const lowerText = cleanText.toLowerCase();
|
||||
|
||||
// 1. Solids Volume
|
||||
// "Sólidos por Volume", "Vol. Sólidos", "Volume Solids", "Solids by Volume"
|
||||
// Handle variants like "72 ± 2" or "72 +/- 2" or just "72"
|
||||
const solidsMatch = cleanText.match(/(?:Sólidos\s*(?:por|em)?\s*Vol\.?|Volume\s*(?:de)?\s*Sólidos|Solids\s*(?:by)?\s*Vol\.?|Sólidos\s*Volumétricos)[^0-9\n]*?(\d+(?:[.,]\d+)?)/i);
|
||||
if (solidsMatch && solidsMatch[1]) {
|
||||
result.solidsVolume = parseFloat(solidsMatch[1].replace(',', '.'));
|
||||
}
|
||||
|
||||
// 2. Yield (Rendimento)
|
||||
// "Rendimento Teórico", "Rendimento", "Theoretical Spreading Rate"
|
||||
// Look for unit m2/L, m²/L, m2/l
|
||||
const yieldMatch = cleanText.match(/(?:Rendimento(?:\s*Teórico)?|Theoretical\s*Spreading\s*Rate)[^0-9\n]*?(\d+(?:[.,]\d+)?)\s*(?:m[²2]\/L|m2\/l)/i);
|
||||
if (yieldMatch && yieldMatch[1]) {
|
||||
result.yieldTheoretical = parseFloat(yieldMatch[1].replace(',', '.'));
|
||||
}
|
||||
|
||||
// 2.1 Dry Film Thickness (DFT) Reference (Espessura)
|
||||
// "Espessura Seca", "DFT", "EPS"
|
||||
// Look for unit µm, microns
|
||||
const dftMatch = cleanText.match(/(?:Espessura\s*(?:de)?\s*(?:Camada|Filme)?\s*Sec[oa]|Dry\s*Film\s*Thickness|DFT|EPS)[^0-9\n]*?(\d+(?:[.,]\d+)?)\s*(?:µm|microns|micra|micrometros|micrômetros)/i);
|
||||
if (dftMatch && dftMatch[1]) {
|
||||
result.dftReference = parseFloat(dftMatch[1].replace(',', '.'));
|
||||
}
|
||||
|
||||
// 2.2 Yield Factor Calculation
|
||||
if (result.solidsVolume) {
|
||||
result.yieldFactor = parseFloat((result.solidsVolume * 10).toFixed(2));
|
||||
} else if (result.yieldTheoretical && result.dftReference) {
|
||||
result.yieldFactor = parseFloat((result.yieldTheoretical * result.dftReference).toFixed(2));
|
||||
}
|
||||
|
||||
// 3. Density
|
||||
const densityMatch = cleanText.match(/(?:Densidade|Massa\s*Específica|Specific\s*Gravity|Peso\s*Específico)[^0-9\n]*?(\d+(?:[.,]\d+)?)/i);
|
||||
if (densityMatch && densityMatch[1]) {
|
||||
// Sanity check: Density usually between 0.8 and 2.5 g/cm3 for paints
|
||||
const val = parseFloat(densityMatch[1].replace(',', '.'));
|
||||
if (val > 0.5 && val < 3.0) {
|
||||
result.density = val;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. WFT and DFT Ranges
|
||||
// Range formats: "100 a 200", "100 - 200", "100 to 200"
|
||||
|
||||
// WFT
|
||||
const wftRangeMatch = cleanText.match(/(?:Espessura\s*Úmida|Wet\s*Film\s*Thickness|WFT)[^0-9\n]*?(\d+(?:[.,]\d+)?)\s*(?:a|-|to)\s*(\d+(?:[.,]\d+)?)/i);
|
||||
if (wftRangeMatch) {
|
||||
result.wftMin = parseFloat(wftRangeMatch[1].replace(',', '.'));
|
||||
result.wftMax = parseFloat(wftRangeMatch[2].replace(',', '.'));
|
||||
}
|
||||
|
||||
// DFT Range (if not single ref)
|
||||
const dftRangeMatch = cleanText.match(/(?:Espessura\s*Seca|Dry\s*Film\s*Thickness|DFT|EPS)[^0-9\n]*?(\d+(?:[.,]\d+)?)\s*(?:a|-|to)\s*(\d+(?:[.,]\d+)?)/i);
|
||||
if (dftRangeMatch) {
|
||||
const min = parseFloat(dftRangeMatch[1].replace(',', '.'));
|
||||
const max = parseFloat(dftRangeMatch[2].replace(',', '.'));
|
||||
// If we found a reference DFT earlier, verify if it fits here or if this is better
|
||||
result.dftMin = min;
|
||||
result.dftMax = max;
|
||||
if (!result.dftReference) {
|
||||
result.dftReference = (min + max) / 2; // Average as ref if missing
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Reducer
|
||||
// "Diluente:", "Redutor:", "Thinner"
|
||||
const reducerMatch = cleanText.match(/(?:Diluente|Redutor|Thinner)[\s.:]*([A-Za-z0-9.\-\s]+?)(?:(?=\s[A-Z])|$)/i);
|
||||
if (reducerMatch && reducerMatch[1] && reducerMatch[1].length > 3) {
|
||||
result.reducer = reducerMatch[1].trim();
|
||||
}
|
||||
|
||||
// 6. Mixing Ratio
|
||||
// Search for "A : B" patterns or "Component A ... Component B"
|
||||
const ratioMatch = cleanText.match(/(?:Relação\s*de\s*Mistura|Mixing\s*Ratio)[^0-9]*?(\d+)\s*[:/]\s*(\d+)/i);
|
||||
if (ratioMatch) {
|
||||
result.mixingRatioVolume = `${ratioMatch[1]} : ${ratioMatch[2]}`;
|
||||
} else {
|
||||
// Fallback for separated components listing
|
||||
const compA = cleanText.match(/Comp(?:onente)?\.?\s*A\s*[^0-9]*?(\d+(?:[.,]\d+)?)/i);
|
||||
const compB = cleanText.match(/Comp(?:onente)?\.?\s*B\s*[^0-9]*?(\d+(?:[.,]\d+)?)/i);
|
||||
if (compA && compB) {
|
||||
result.mixingRatioVolume = `${compA[1].replace(/[.,]0+$/, '')} : ${compB[1].replace(/[.,]0+$/, '')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Manufacturer
|
||||
for (const manufacturer of COMMON_MANUFACTURERS) {
|
||||
if (cleanText.toUpperCase().includes(manufacturer)) {
|
||||
result.manufacturer = manufacturer;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Type
|
||||
for (const type of PAINT_TYPES) {
|
||||
if (type.keywords.some(k => lowerText.includes(k))) {
|
||||
result.type = type.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Name
|
||||
// Heuristic: Often in the first 200 chars, defined by "PRODUTO:" or just uppercase line
|
||||
const nameMatch = cleanText.match(/(?:PRODUTO|NOME|NAME)\s*[:.]?\s*([A-Za-z0-9\-\s]+?)(?:\s+(?:REFERÊNCIA|DESCRIÇÃO|CÓDIGO)|$)/i);
|
||||
if (nameMatch && nameMatch[1]) {
|
||||
result.name = nameMatch[1].trim();
|
||||
} else {
|
||||
// Use the first non-trivial line if logic fails, usually filename is passed by frontend anyway
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
221
src/server/services/projectService.ts
Normal file
221
src/server/services/projectService.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import Project from '../models/Project.js';
|
||||
import Part from '../models/Part.js';
|
||||
import PaintingScheme from '../models/PaintingScheme.js';
|
||||
import ApplicationRecord from '../models/ApplicationRecord.js';
|
||||
import Inspection from '../models/Inspection.js';
|
||||
|
||||
interface ProjectData {
|
||||
name: string;
|
||||
client: string;
|
||||
startDate?: string | Date;
|
||||
endDate?: string | Date;
|
||||
technician?: string;
|
||||
environment?: string;
|
||||
weightKg?: number;
|
||||
}
|
||||
|
||||
export const createProject = async (data: ProjectData & { organizationId?: string }) => {
|
||||
const newProject = new Project({
|
||||
name: data.name,
|
||||
client: data.client,
|
||||
startDate: data.startDate ? new Date(data.startDate) : null,
|
||||
endDate: data.endDate ? new Date(data.endDate) : null,
|
||||
technician: data.technician,
|
||||
environment: data.environment,
|
||||
organizationId: data.organizationId,
|
||||
weightKg: data.weightKg
|
||||
});
|
||||
return await newProject.save();
|
||||
};
|
||||
|
||||
export const getDashboardProjects = async (organizationId?: string) => {
|
||||
const matchStage = organizationId ? { organizationId } : {};
|
||||
|
||||
const projects = await Project.aggregate([
|
||||
{ $match: matchStage },
|
||||
{ $sort: { name: 1 } },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'paintingschemes',
|
||||
localField: '_id',
|
||||
foreignField: 'projectId',
|
||||
as: 'paintingSchemes'
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'inspections',
|
||||
localField: '_id',
|
||||
foreignField: 'projectId',
|
||||
as: 'inspections'
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
client: 1,
|
||||
technician: 1,
|
||||
weightKg: 1,
|
||||
createdAt: 1,
|
||||
schemes: {
|
||||
$map: {
|
||||
input: "$paintingSchemes",
|
||||
as: "scheme",
|
||||
in: {
|
||||
id: { $toString: "$$scheme._id" },
|
||||
name: "$$scheme.name",
|
||||
type: "$$scheme.type",
|
||||
coat: "$$scheme.coat",
|
||||
color: "$$scheme.color",
|
||||
colorHex: "$$scheme.colorHex",
|
||||
thinnerSymbol: "$$scheme.thinnerSymbol",
|
||||
epsMin: "$$scheme.epsMin",
|
||||
epsMax: "$$scheme.epsMax"
|
||||
}
|
||||
}
|
||||
},
|
||||
paintedWeight: { $sum: "$inspections.weightKg" }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
return projects.map(p => ({ ...p, id: p._id.toString() }));
|
||||
};
|
||||
|
||||
export const getAllProjects = async (organizationId?: string, isGlobalAdmin: boolean = false, status: string = 'active') => {
|
||||
const statusQuery = status === 'active'
|
||||
? { status: { $ne: 'archived' } }
|
||||
: { status: 'archived' };
|
||||
|
||||
const matchQuery: Record<string, unknown> = isGlobalAdmin
|
||||
? { ...statusQuery }
|
||||
: {
|
||||
...statusQuery,
|
||||
$or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }]
|
||||
};
|
||||
|
||||
const projects = await Project.aggregate([
|
||||
{ $match: matchQuery },
|
||||
{ $sort: { name: 1 } },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'paintingschemes',
|
||||
localField: '_id',
|
||||
foreignField: 'projectId',
|
||||
as: 'paintingSchemes'
|
||||
}
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
id: { $toString: "$_id" }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
return projects;
|
||||
};
|
||||
|
||||
export const archiveProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||
const project = await Project.findById(id);
|
||||
if (!project) throw new Error('Projeto não encontrado');
|
||||
|
||||
// Check ownership
|
||||
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
|
||||
throw new Error('Sem permissão para arquivar este projeto');
|
||||
}
|
||||
|
||||
const newStatus = project.status === 'active' ? 'archived' : 'active';
|
||||
const updated = await Project.findByIdAndUpdate(id, { status: newStatus }, { new: true }).lean();
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const getProjectById = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||
const project = await Project.findById(id).lean();
|
||||
|
||||
if (!project) throw new Error('Projeto não encontrado');
|
||||
|
||||
// Security check: Allow if global admin OR matches organization OR project has no organization
|
||||
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
|
||||
throw new Error('Acesso negado a este projeto');
|
||||
}
|
||||
|
||||
const [parts, schemes, records, inspections] = await Promise.all([
|
||||
Part.find({ projectId: id }).lean(),
|
||||
PaintingScheme.find({ projectId: id }).populate('paintId thinnerId').lean(),
|
||||
ApplicationRecord.find({ projectId: id }).lean(),
|
||||
Inspection.find({ projectId: id })
|
||||
.populate({
|
||||
path: 'stockItemId',
|
||||
select: 'batchNumber dataSheetId',
|
||||
populate: { path: 'dataSheetId', select: 'name' }
|
||||
})
|
||||
.lean()
|
||||
]);
|
||||
|
||||
return {
|
||||
...project,
|
||||
id: project._id.toString(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parts: parts.map((p: any) => ({ ...p, id: p._id.toString() })),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
paintingSchemes: schemes.map((s: any) => ({ ...s, id: s._id.toString() })),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
applicationRecords: records.map((r: any) => ({ ...r, id: r._id.toString() })),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
inspections: inspections.map((i: any) => ({ ...i, id: i._id.toString() }))
|
||||
};
|
||||
};
|
||||
|
||||
export const updateProject = async (id: string, data: Partial<ProjectData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||
const existing = await Project.findById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
// Check ownership
|
||||
if (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
||||
console.warn(`Access Denied: Project ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateData: Partial<ProjectData> & { updatedAt: Date, organizationId?: string } = {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
startDate: data.startDate ? new Date(data.startDate) : undefined,
|
||||
endDate: data.endDate ? new Date(data.endDate) : undefined,
|
||||
weightKg: data.weightKg,
|
||||
};
|
||||
|
||||
// Adopt if needed
|
||||
if (organizationId && !existing.organizationId) {
|
||||
updateData.organizationId = organizationId;
|
||||
}
|
||||
|
||||
const updated = await Project.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
|
||||
if (updated) {
|
||||
return { ...updated, id: updated._id.toString() };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const deleteProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||
const project = await Project.findById(id);
|
||||
if (!project) return;
|
||||
|
||||
// Check ownership
|
||||
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
|
||||
throw new Error('Sem permissão para excluir este projeto');
|
||||
}
|
||||
|
||||
await Project.findByIdAndDelete(id);
|
||||
|
||||
// Also cleanup related data
|
||||
await Promise.all([
|
||||
Part.deleteMany({ projectId: id }),
|
||||
PaintingScheme.deleteMany({ projectId: id }),
|
||||
ApplicationRecord.deleteMany({ projectId: id }),
|
||||
Inspection.deleteMany({ projectId: id })
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
|
||||
56
src/server/services/yieldStudyService.ts
Normal file
56
src/server/services/yieldStudyService.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import YieldStudy from '../models/YieldStudy.js';
|
||||
|
||||
export const getAllStudies = async (organizationId?: string) => {
|
||||
const query = organizationId ? { organizationId } : {};
|
||||
const studies = await YieldStudy.find(query).populate('dataSheetId').sort({ createdAt: -1 }).lean();
|
||||
return studies.map(s => ({ ...s, id: s._id.toString() }));
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const createStudy = async (data: any & { organizationId?: string }) => {
|
||||
const newStudy = new YieldStudy({ ...data, organizationId: data.organizationId });
|
||||
const saved = await newStudy.save();
|
||||
return { ...saved.toObject(), id: saved._id.toString() };
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const updateStudy = async (id: string, updates: any, organizationId?: string) => {
|
||||
// SECURITY FIX: Allow update if:
|
||||
// 1. Matches ID AND Matches Organization
|
||||
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
|
||||
|
||||
const existing = await YieldStudy.findById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
||||
console.warn(`Access Denied: Study ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (organizationId && !existing.organizationId) {
|
||||
updates.organizationId = organizationId;
|
||||
}
|
||||
|
||||
const updated = await YieldStudy.findOneAndUpdate({ _id: id }, updates, { new: true }).lean();
|
||||
if (updated) {
|
||||
return { ...updated, id: updated._id.toString() };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const deleteStudy = async (id: string, organizationId?: string) => {
|
||||
// SECURITY FIX: Same logic as update - allow delete if owned OR if orphan
|
||||
const existing = await YieldStudy.findById(id);
|
||||
if (!existing) return false;
|
||||
|
||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
||||
console.warn(`Access Denied: Delete Study ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await YieldStudy.findByIdAndDelete(id);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
10
src/server/types/express.d.ts
vendored
Normal file
10
src/server/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IUser } from '../models/User.js';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
appUser?: IUser;
|
||||
clerkUserId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/server/types/models.ts
Normal file
125
src/server/types/models.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
client: string;
|
||||
startDate?: Date | string | null;
|
||||
endDate?: Date | string | null;
|
||||
technician?: string | null;
|
||||
environment?: string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
parts?: Part[];
|
||||
paintingSchemes?: PaintingScheme[];
|
||||
applicationRecords?: ApplicationRecord[];
|
||||
inspections?: Inspection[];
|
||||
}
|
||||
|
||||
export interface Part {
|
||||
id: string;
|
||||
projectId: string;
|
||||
description: string;
|
||||
dimensions?: string | null;
|
||||
weight?: number | null;
|
||||
type?: string | null;
|
||||
area?: number | null;
|
||||
complexity?: number | null;
|
||||
quantity: number;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface PaintingScheme {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
type?: string | null;
|
||||
solidsVolume?: number | null;
|
||||
yieldTheoretical?: number | null;
|
||||
epsMin?: number | null;
|
||||
epsMax?: number | null;
|
||||
dilution?: number | null;
|
||||
manufacturer?: string | null;
|
||||
color?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface ApplicationRecord {
|
||||
id: string;
|
||||
projectId: string;
|
||||
coatStage: string;
|
||||
pieceDescription?: string | null;
|
||||
date?: Date | string | null;
|
||||
operator?: string | null;
|
||||
realWeight?: number | null; // Peso Real (kg)
|
||||
volumeUsed?: number | null; // Volume Utilizado (L)
|
||||
areaPainted?: number | null; // Área Pintada (m²)
|
||||
wetThicknessAvg?: number | null; // Espessura Úmida Média (µm)
|
||||
dryThicknessCalc?: number | null; // Espessura Seca Calculada (µm)
|
||||
method?: string | null;
|
||||
realYield?: number | null; // Rendimento Real (m²/L)
|
||||
diluentUsed?: number | null; // Consumo estimado de Diluente (L)
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface Inspection {
|
||||
id: string;
|
||||
projectId: string;
|
||||
date?: Date | string | null;
|
||||
inspector?: string | null;
|
||||
pieceDescription?: string | null;
|
||||
epsPoints?: (number | null)[];
|
||||
adhesionTest?: string | null; // Teste de Aderência
|
||||
appearance?: string | null; // Aspecto Visual
|
||||
defects?: string | null;
|
||||
}
|
||||
|
||||
export interface TechnicalDataSheet {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer?: string;
|
||||
type?: string; // 'epoxi', 'pu', 'diluent', etc
|
||||
fileUrl: string; // Path or URL to PDF
|
||||
uploadDate: Date | string;
|
||||
|
||||
// Technical Properties for Analysis
|
||||
solidsVolume?: number; // %
|
||||
density?: number; // g/cm3
|
||||
mixingRatio?: string;
|
||||
mixingRatioWeight?: string;
|
||||
mixingRatioVolume?: string;
|
||||
wftMin?: number;
|
||||
wftMax?: number;
|
||||
dftMin?: number;
|
||||
dftMax?: number;
|
||||
reducer?: string;
|
||||
yieldTheoretical?: number; // m2/L
|
||||
dftReference?: number; // µm (Espessura de camada seca de referência para o rendimento teórico)
|
||||
yieldFactor?: number; // Razão m²/L por µm (Calculado como rendimento * espessura / 1 ou simplesmente sólidos * 10)
|
||||
dilution?: number; // % sugerida
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface PieceCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
weight: number; // Total weight in Kg
|
||||
historicalYield: number; // L/Kg (Historical consumption)
|
||||
historicalDft: number; // µm (Reference DFT for the historical yield)
|
||||
efficiency: number; // % (Expected application efficiency)
|
||||
}
|
||||
|
||||
export interface YieldStudy {
|
||||
id: string;
|
||||
name: string;
|
||||
dataSheetId: string; // Ficha Técnica selecionada
|
||||
targetDft: number; // µm (Desired dry thickness)
|
||||
dilutionPercent: number; // % (Expected dilution)
|
||||
categories: PieceCategory[];
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
|
||||
// Results (Calculated)
|
||||
totalWeight: number;
|
||||
estimatedPaintVolume: number; // Liters
|
||||
estimatedReducerVolume: number; // Liters
|
||||
averageComplexity: number;
|
||||
}
|
||||
Reference in New Issue
Block a user