Initialize fresh project without Clerk
This commit is contained in:
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?.externalId;
|
||||
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?.externalId;
|
||||
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?.externalId;
|
||||
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 });
|
||||
}
|
||||
};
|
||||
99
src/server/controllers/authController.ts
Normal file
99
src/server/controllers/authController.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import User from '../models/User.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod';
|
||||
|
||||
export const register = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, email, password } = req.body;
|
||||
|
||||
if (!name || !email || !password) {
|
||||
res.status(400).json({ error: 'Todos os campos são obrigatórios' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUser = await User.findOne({ email });
|
||||
if (existingUser) {
|
||||
res.status(400).json({ error: 'Email já cadastrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const passwordHash = await bcrypt.hash(password, salt);
|
||||
|
||||
// Gere um externalId falso apenas para manter retrocompatibilidade no banco
|
||||
const fakeAuthId = `user_${uuidv4().replace(/-/g, '')}`;
|
||||
|
||||
const newUser = new User({
|
||||
name,
|
||||
email,
|
||||
passwordHash,
|
||||
externalId: fakeAuthId,
|
||||
role: 'member',
|
||||
isBanned: false
|
||||
});
|
||||
|
||||
await newUser.save();
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: newUser._id.toString(), externalId: newUser.externalId, role: newUser.role, organizationId: newUser.organizationId },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Usuário criado com sucesso',
|
||||
token,
|
||||
user: { id: newUser._id, name: newUser.name, email: newUser.email, role: newUser.role, externalId: newUser.externalId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Register Error:', error);
|
||||
res.status(500).json({ error: 'Erro no servidor' });
|
||||
}
|
||||
};
|
||||
|
||||
export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'Email e senha são obrigatórios' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email });
|
||||
if (!user) {
|
||||
res.status(400).json({ error: 'Usuário não encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
res.status(400).json({ error: 'Usuário do sistema antigo. Por favor, solicite a redefinição de senha ou recrie sua conta se possível.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isMatch) {
|
||||
res.status(400).json({ error: 'Credenciais inválidas' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user._id.toString(), externalId: user.externalId, role: user.role, organizationId: user.organizationId },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Login realizado com sucesso',
|
||||
token,
|
||||
user: { id: user._id, name: user.name, email: user.email, role: user.role, externalId: user.externalId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login Error:', error);
|
||||
res.status(500).json({ error: 'Erro no servidor' });
|
||||
}
|
||||
};
|
||||
287
src/server/controllers/dataSheetController.ts
Normal file
287
src/server/controllers/dataSheetController.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
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}"`);
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.set('Cache-Control', 'public, max-age=3600');
|
||||
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?.externalId;
|
||||
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?.externalId;
|
||||
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?.externalId;
|
||||
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?.externalId;
|
||||
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?.externalId;
|
||||
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({ userId: 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?.externalId;
|
||||
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?.externalId;
|
||||
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({ userId: 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?.externalId;
|
||||
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?.externalId;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
|
||||
if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
||||
|
||||
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?.externalId;
|
||||
const organizationId = req.headers['x-organization-id'] as string;
|
||||
|
||||
const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
|
||||
if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
||||
|
||||
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 (externalId 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?.externalId,
|
||||
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?.externalId,
|
||||
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?.externalId,
|
||||
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?.externalId,
|
||||
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?.externalId || 'system';
|
||||
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
|
||||
|
||||
if (!isAdmin) {
|
||||
return res.status(403).json({ error: 'Apenas administradores podem editar movimentações.' });
|
||||
}
|
||||
|
||||
const { date, quantity, notes } = req.body;
|
||||
|
||||
const movement = await StockMovement.findOne({ _id: id, organizationId });
|
||||
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
||||
|
||||
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
|
||||
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
|
||||
|
||||
// Calculate Delta
|
||||
// If quantity changed, we need to adjust the item balance
|
||||
// Note: 'quantity' in movement is signed (+ for entry, - for consumption)
|
||||
// If the user edits a Consumption (-10) to (-15), the val passed in body might be absolute or signed?
|
||||
// Let's assume the frontend sends the SIGNED value consistent with the movement type?
|
||||
// Actually best to stick to specific logic:
|
||||
// If movement type is ENTRY/ADJUSTMENT, quantity is usually positive (unless neg adjustment).
|
||||
// If CONSUMPTION, quantity is stored negative.
|
||||
// Let's expect the frontend to send the 'raw' new value.
|
||||
// Be careful: if frontend sends positive 10 for a consumption, we must flip it?
|
||||
// Let's assume frontend sends the value exactly as it should be stored.
|
||||
|
||||
// HOWEVER, it's safer if we check type.
|
||||
const newQuantitySigned = Number(quantity);
|
||||
|
||||
// Validation: Consumption should generally be negative, Entry positive.
|
||||
// But for flexibility let's just trust the arithmetic diff for now,
|
||||
// but warn if sign flips unexpectedly?
|
||||
|
||||
const oldQuantity = Number(movement.quantity);
|
||||
const quantityDiff = newQuantitySigned - oldQuantity;
|
||||
|
||||
// Update Item
|
||||
const newStockLevel = Number(item.quantity) + quantityDiff;
|
||||
if (newStockLevel < 0) {
|
||||
return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
|
||||
}
|
||||
|
||||
item.quantity = newStockLevel;
|
||||
await item.save();
|
||||
|
||||
// Audit Log
|
||||
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
|
||||
const typeLabel = typeMap[movement.type] || movement.type;
|
||||
|
||||
await StockAuditLog.create({
|
||||
organizationId,
|
||||
stockItemId: item._id,
|
||||
movementId: movement._id,
|
||||
movementNumber: movement.movementNumber,
|
||||
userId,
|
||||
userName,
|
||||
action: 'UPDATE',
|
||||
details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`,
|
||||
oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes },
|
||||
newValues: { date, quantity: newQuantitySigned, notes }
|
||||
});
|
||||
|
||||
// Update Movement
|
||||
movement.quantity = newQuantitySigned;
|
||||
if (date) movement.date = date;
|
||||
if (notes !== undefined) movement.notes = notes;
|
||||
await movement.save();
|
||||
|
||||
res.json(movement);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Error updating movement:', error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteStockMovement = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const organizationId = req.appUser?.organizationId;
|
||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||
const userId = req.appUser?.externalId || 'system';
|
||||
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
|
||||
|
||||
if (!isAdmin) {
|
||||
return res.status(403).json({ error: 'Apenas administradores podem excluir movimentações.' });
|
||||
}
|
||||
|
||||
const movement = await StockMovement.findOne({ _id: id, organizationId });
|
||||
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
||||
|
||||
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
|
||||
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
|
||||
|
||||
// Reverse the effect
|
||||
// If we delete an Entry (+10), we MUST subtract 10 from Item.
|
||||
// If we delete a Consumption (-10), we MUST add 10 (subtract -10) to Item.
|
||||
// So: Item.quantity -= movement.quantity
|
||||
|
||||
const reverseQty = Number(movement.quantity);
|
||||
const newStockLevel = Number(item.quantity) - reverseQty;
|
||||
|
||||
if (newStockLevel < 0) {
|
||||
return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
|
||||
}
|
||||
|
||||
item.quantity = newStockLevel;
|
||||
await item.save();
|
||||
|
||||
// Audit Log
|
||||
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
|
||||
const typeLabel = typeMap[movement.type] || movement.type;
|
||||
|
||||
await StockAuditLog.create({
|
||||
organizationId,
|
||||
stockItemId: item._id,
|
||||
movementId: movement._id,
|
||||
movementNumber: movement.movementNumber,
|
||||
userId,
|
||||
userName,
|
||||
action: 'DELETE',
|
||||
details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`,
|
||||
oldValues: movement.toObject()
|
||||
});
|
||||
|
||||
await StockMovement.deleteOne({ _id: id });
|
||||
|
||||
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',
|
||||
userId: '$userId',
|
||||
isBanned: '$isBanned'
|
||||
}
|
||||
},
|
||||
lastActive: { $max: '$updatedAt' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'organizations', // Ensure this matches the collection name of Organization model
|
||||
localField: '_id',
|
||||
foreignField: 'externalId',
|
||||
as: 'orgDetails'
|
||||
}
|
||||
},
|
||||
{
|
||||
$unwind: {
|
||||
path: '$orgDetails',
|
||||
preserveNullAndEmptyArrays: true
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
lastActive: 1,
|
||||
members: 1,
|
||||
memberCount: { $size: '$members' },
|
||||
isBanned: { $ifNull: ['$orgDetails.isBanned', false] },
|
||||
name: { $ifNull: ['$orgDetails.name', ''] }
|
||||
}
|
||||
},
|
||||
{ $sort: { memberCount: -1 } }
|
||||
]);
|
||||
|
||||
res.json(organizations);
|
||||
} 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(
|
||||
{ externalId: 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 Auth to MongoDB
|
||||
* Creates user if doesn't exist, updates if exists
|
||||
* Also creates/updates OrganizationMember for the current organization
|
||||
*/
|
||||
export const syncUser = async (req: Request, res: Response) => {
|
||||
console.log('--- syncUser called ---', req.body);
|
||||
try {
|
||||
const { externalId, email, name, organizationId, clerkRole } = req.body;
|
||||
|
||||
if (!externalId || !email || !name) {
|
||||
return res.status(400).json({ error: 'externalId, email e name são obrigatórios.' });
|
||||
}
|
||||
|
||||
// 1. Upsert the global User record
|
||||
let user = await User.findOne({ externalId });
|
||||
|
||||
if (user) {
|
||||
user.email = email;
|
||||
user.name = name;
|
||||
await user.save();
|
||||
} else {
|
||||
user = await User.create({
|
||||
externalId,
|
||||
email,
|
||||
name,
|
||||
role: 'guest', // Default global role
|
||||
isBanned: false
|
||||
});
|
||||
}
|
||||
|
||||
if (organizationId) {
|
||||
|
||||
// Map Auth role to our app role
|
||||
let appRole: OrgRole = 'guest';
|
||||
if (clerkRole === 'org:admin') {
|
||||
appRole = 'admin';
|
||||
} else if (clerkRole === 'org:member') {
|
||||
appRole = 'user';
|
||||
}
|
||||
|
||||
// Use findOneAndUpdate with upsert to handle race conditions atomically
|
||||
// This avoids the need for try/catch on create and handles existing members too
|
||||
const member = await OrganizationMember.findOneAndUpdate(
|
||||
{ userId: externalId, organizationId },
|
||||
{
|
||||
$set: {
|
||||
name,
|
||||
email,
|
||||
// Only update role if it's the first time (creation)
|
||||
// Or we can optionally update it if needed.
|
||||
// For now, let's NOT overwrite role on update to preserve local changes,
|
||||
// UNLESS we want to force sync with Auth.
|
||||
// Let's use $setOnInsert for fields we only want to set on creation.
|
||||
},
|
||||
$setOnInsert: {
|
||||
role: appRole,
|
||||
isBanned: false
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||
);
|
||||
|
||||
// Return combined info
|
||||
return res.json({
|
||||
...user.toObject(),
|
||||
organizationRole: member.role,
|
||||
organizationBanned: member.isBanned
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
userId: req.appUser.externalId,
|
||||
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, externalId: m.userId })));
|
||||
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.userId === req.appUser.externalId) {
|
||||
return res.status(400).json({ error: 'Você não pode banir a si mesmo.' });
|
||||
}
|
||||
|
||||
// Prevent banning another admin
|
||||
if (member.role === 'admin') {
|
||||
return res.status(400).json({ error: 'Não é possível banir um administrador.' });
|
||||
}
|
||||
|
||||
member.isBanned = isBanned;
|
||||
await member.save();
|
||||
|
||||
res.json(member);
|
||||
} catch (error) {
|
||||
console.error('Error toggling ban:', error);
|
||||
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 Auth IDs
|
||||
const externalIds = members.map(m => m.userId);
|
||||
|
||||
// Find Users who were seen recently (2 minutes)
|
||||
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
|
||||
|
||||
const activeUsers = await User.find({
|
||||
externalId: { $in: externalIds },
|
||||
lastSeenAt: { $gte: twoMinutesAgo },
|
||||
_id: { $ne: currentUserId } // Optional: exclude self
|
||||
}).select('name email lastSeenAt externalId'); // Only needed fields
|
||||
|
||||
res.json(activeUsers);
|
||||
} 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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user