Migrate major controllers and services to PostgreSQL (gpi schema), fix build errors, add file and audit log support
This commit is contained in:
49
api/app.ts
49
api/app.ts
@@ -46,11 +46,14 @@ app.get('/api/admin/migrate-to-gpi', async (req, res) => {
|
|||||||
"user_organizations",
|
"user_organizations",
|
||||||
"projects",
|
"projects",
|
||||||
"parts",
|
"parts",
|
||||||
|
"technical_data_sheets",
|
||||||
"painting_schemes",
|
"painting_schemes",
|
||||||
"inspections",
|
"inspections",
|
||||||
"instruments",
|
"instruments",
|
||||||
"stock_items",
|
"stock_items",
|
||||||
"stock_movements"
|
"stock_movements",
|
||||||
|
"application_records",
|
||||||
|
"application_record_items"
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -60,14 +63,52 @@ app.get('/api/admin/migrate-to-gpi', async (req, res) => {
|
|||||||
const results = [];
|
const results = [];
|
||||||
for (const table of TABLES) {
|
for (const table of TABLES) {
|
||||||
try {
|
try {
|
||||||
|
// Try to move from public to gpi
|
||||||
await client.query(`ALTER TABLE public.${table} SET SCHEMA gpi;`);
|
await client.query(`ALTER TABLE public.${table} SET SCHEMA gpi;`);
|
||||||
results.push({ table, status: 'success' });
|
results.push({ table, action: 'moved', status: 'success' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
results.push({ table, status: 'failed', error: err.message });
|
// If it fails, maybe it's already in gpi?
|
||||||
|
try {
|
||||||
|
await client.query(`SELECT 1 FROM gpi.${table} LIMIT 1`);
|
||||||
|
results.push({ table, action: 'check', status: 'already_in_gpi' });
|
||||||
|
} catch (checkErr: any) {
|
||||||
|
results.push({ table, action: 'move', status: 'failed', error: err.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure new tables exist
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
data BYTEA NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
results.push({ table: 'files', action: 'create', status: 'success_or_exists' });
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID,
|
||||||
|
stock_item_id UUID,
|
||||||
|
movement_id UUID,
|
||||||
|
movement_number INTEGER,
|
||||||
|
user_id TEXT,
|
||||||
|
user_name TEXT,
|
||||||
|
action TEXT,
|
||||||
|
details TEXT,
|
||||||
|
old_values JSONB,
|
||||||
|
new_values JSONB,
|
||||||
|
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
results.push({ table: 'stock_audit_logs', action: 'create', status: 'success_or_exists' });
|
||||||
client.release();
|
client.release();
|
||||||
res.json({ message: "Migration completed", results });
|
res.json({ message: "Migration check completed", results });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
19
create_tables.ts
Normal file
19
create_tables.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { query } from './src/server/config/database.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log("Creating gpi.files...");
|
||||||
|
await query("CREATE TABLE IF NOT EXISTS gpi.files (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), filename TEXT NOT NULL, content_type TEXT NOT NULL, data BYTEA NOT NULL, size INTEGER NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());");
|
||||||
|
|
||||||
|
console.log("Creating gpi.stock_audit_logs...");
|
||||||
|
await query("CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID, stock_item_id UUID, movement_id UUID, movement_number INTEGER, user_id TEXT, user_name TEXT, action TEXT, details TEXT, old_values JSONB, new_values JSONB, timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW());");
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -4,6 +4,7 @@ import fs from 'fs';
|
|||||||
import * as pdfExtractionService from '../services/pdfExtractionService.js';
|
import * as pdfExtractionService from '../services/pdfExtractionService.js';
|
||||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||||
import { notificationService } from '../services/notificationService.js';
|
import { notificationService } from '../services/notificationService.js';
|
||||||
|
import * as fileService from '../services/fileService.js';
|
||||||
|
|
||||||
interface AuthRequest extends Request {
|
interface AuthRequest extends Request {
|
||||||
appUser?: IAppUser;
|
appUser?: IAppUser;
|
||||||
@@ -12,9 +13,7 @@ interface AuthRequest extends Request {
|
|||||||
export const getAllDataSheets = async (req: AuthRequest, res: Response) => {
|
export const getAllDataSheets = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
console.log('Backend: Fetching datasheets for org:', organizationId);
|
|
||||||
const sheets = await dataSheetService.getAllDataSheets(organizationId);
|
const sheets = await dataSheetService.getAllDataSheets(organizationId);
|
||||||
console.log(`Backend: Found ${sheets.length} sheets`);
|
|
||||||
res.json(sheets);
|
res.json(sheets);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -32,7 +31,6 @@ export const extractData = async (req: AuthRequest, res: Response) => {
|
|||||||
const fileBuffer = fs.readFileSync(file.path);
|
const fileBuffer = fs.readFileSync(file.path);
|
||||||
const data = await pdfExtractionService.extractDataFromPdf(fileBuffer);
|
const data = await pdfExtractionService.extractDataFromPdf(fileBuffer);
|
||||||
|
|
||||||
// Return extracted data AND the file path so we don't need to re-upload
|
|
||||||
res.json({
|
res.json({
|
||||||
...data,
|
...data,
|
||||||
tempFilePath: file.path
|
tempFilePath: file.path
|
||||||
@@ -44,7 +42,6 @@ export const extractData = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
@@ -58,31 +55,15 @@ export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
} = req.body;
|
} = req.body;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
|
||||||
// Note: New logic prefers 'file' upload which we store in DB.
|
let fileId: string | undefined = undefined;
|
||||||
// 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 || '';
|
let finalFileUrl = fileUrl || '';
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
// Read file buffer
|
|
||||||
const buffer = fs.readFileSync(file.path);
|
const buffer = fs.readFileSync(file.path);
|
||||||
|
const savedFile = await fileService.saveFile(file.originalname, file.mimetype, buffer);
|
||||||
|
fileId = savedFile.id;
|
||||||
|
finalFileUrl = savedFile.id; // Using ID as reference
|
||||||
|
|
||||||
// Save to StoredFile collection
|
|
||||||
const { default: StoredFile } = await import('../models/StoredFile.js');
|
|
||||||
const newFile = await StoredFile.create({
|
|
||||||
filename: file.originalname,
|
|
||||||
contentType: file.mimetype,
|
|
||||||
data: buffer,
|
|
||||||
size: file.size,
|
|
||||||
uploadDate: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
fileId = newFile._id;
|
|
||||||
finalFileUrl = newFile._id.toString(); // Use ID as URL reference for consistency with frontend expectations if possible, or we might need to adjust frontend to use /api/datasheets/file/:id
|
|
||||||
|
|
||||||
// Clean up temp file
|
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(file.path);
|
fs.unlinkSync(file.path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -90,16 +71,6 @@ export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fileId && !finalFileUrl) {
|
|
||||||
// Check if fileUrl allows empty. The schema says optional now, but logically a datasheet usually has a file.
|
|
||||||
// However, for simplified Diluent registration, we might not have one.
|
|
||||||
// If the user didn't send a file and didn't send a URL, and schema is optional, we can proceed.
|
|
||||||
// But let's check if we want to enforce it.
|
|
||||||
// If manufacturerCode (Diluent indicator?) is present, maybe skip check?
|
|
||||||
// Actually, I removed 'required' from schema, so I should probably relax this check too.
|
|
||||||
// return res.status(400).json({ error: 'File is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSheet = await dataSheetService.createDataSheet({
|
const newSheet = await dataSheetService.createDataSheet({
|
||||||
name,
|
name,
|
||||||
manufacturer,
|
manufacturer,
|
||||||
@@ -127,14 +98,13 @@ export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
organizationId
|
organizationId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notificação de Nova Ficha Técnica
|
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
await notificationService.create({
|
await notificationService.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
title: 'Nova Ficha Técnica',
|
title: 'Nova Ficha Técnica',
|
||||||
message: `A ficha técnica "${name}" (${manufacturer}) foi adicionada à biblioteca.`,
|
message: `A ficha técnica "${name}" (${manufacturer}) foi adicionada à biblioteca.`,
|
||||||
type: 'info',
|
type: 'info',
|
||||||
metadata: { dataSheetId: newSheet._id, triggerType: 'datasheet_created' }
|
metadata: { dataSheetId: newSheet.id, triggerType: 'datasheet_created' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,10 +120,6 @@ export const deleteDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
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);
|
const success = await dataSheetService.deleteDataSheet(id as string, organizationId);
|
||||||
if (success) {
|
if (success) {
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
@@ -179,8 +145,7 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
notes, fileUrl
|
notes, fileUrl
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const updates: any = {
|
||||||
const updates: Record<string, any> = {
|
|
||||||
name,
|
name,
|
||||||
manufacturer,
|
manufacturer,
|
||||||
type,
|
type,
|
||||||
@@ -202,23 +167,11 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
// Read file buffer
|
|
||||||
const buffer = fs.readFileSync(file.path);
|
const buffer = fs.readFileSync(file.path);
|
||||||
|
const savedFile = await fileService.saveFile(file.originalname, file.mimetype, buffer);
|
||||||
|
updates.fileId = savedFile.id;
|
||||||
|
updates.fileUrl = savedFile.id;
|
||||||
|
|
||||||
// Save to StoredFile collection
|
|
||||||
const { default: StoredFile } = await import('../models/StoredFile.js');
|
|
||||||
const newFile = await StoredFile.create({
|
|
||||||
filename: file.originalname,
|
|
||||||
contentType: file.mimetype,
|
|
||||||
data: buffer,
|
|
||||||
size: file.size,
|
|
||||||
uploadDate: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
updates.fileId = newFile._id;
|
|
||||||
updates.fileUrl = newFile._id.toString();
|
|
||||||
|
|
||||||
// Clean up temp file
|
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(file.path);
|
fs.unlinkSync(file.path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -226,12 +179,6 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
} else if (fileUrl) {
|
} else if (fileUrl) {
|
||||||
updates.fileUrl = String(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);
|
const updatedSheet = await dataSheetService.updateDataSheet(id, updates, organizationId);
|
||||||
@@ -250,15 +197,14 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
export const getFile = async (req: Request, res: Response) => {
|
export const getFile = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id_or_filename = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
|
||||||
// Check if it's a MongoDB ObjectId (24 hex chars)
|
// Check if it's a UUID
|
||||||
if (/^[0-9a-fA-F]{24}$/.test(id_or_filename)) {
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
|
||||||
const { default: StoredFile } = await import('../models/StoredFile.js');
|
const fileDoc = await fileService.getFileById(id);
|
||||||
const fileDoc = await StoredFile.findById(id_or_filename);
|
|
||||||
|
|
||||||
if (fileDoc) {
|
if (fileDoc) {
|
||||||
res.set('Content-Type', fileDoc.contentType || 'application/pdf');
|
res.set('Content-Type', fileDoc.content_type || 'application/pdf');
|
||||||
res.set('Content-Disposition', `inline; filename="${fileDoc.filename}"`);
|
res.set('Content-Disposition', `inline; filename="${fileDoc.filename}"`);
|
||||||
res.set('Access-Control-Allow-Origin', '*');
|
res.set('Access-Control-Allow-Origin', '*');
|
||||||
res.set('Cache-Control', 'public, max-age=3600');
|
res.set('Cache-Control', 'public, max-age=3600');
|
||||||
@@ -266,19 +212,7 @@ export const getFile = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to file system (legacy)
|
res.status(404).json({ error: 'File not found' });
|
||||||
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) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error('Error getting file:', error);
|
console.error('Error getting file:', error);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as inspectionService from '../services/inspectionService.js';
|
import * as inspectionService from '../services/inspectionService.js';
|
||||||
import { notificationService } from '../services/notificationService.js';
|
import { notificationService } from '../services/notificationService.js';
|
||||||
import '../middleware/roleMiddleware.js'; // Ensure type augmentation
|
import * as fileService from '../services/fileService.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
export const createInspection = async (req: Request, res: Response) => {
|
export const createInspection = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -13,14 +14,13 @@ export const createInspection = async (req: Request, res: Response) => {
|
|||||||
createdBy
|
createdBy
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notificação de Inspeção Reprovada
|
|
||||||
if (req.body.appearance === 'rejected' && organizationId) {
|
if (req.body.appearance === 'rejected' && organizationId) {
|
||||||
await notificationService.create({
|
await notificationService.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
title: 'Inspeção Reprovada',
|
title: 'Inspeção Reprovada',
|
||||||
message: `Uma inspeção foi reprovada na obra (ID: ${req.body.projectId}).`,
|
message: `Uma inspeção foi reprovada na obra (ID: ${req.body.projectId}).`,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
metadata: { inspectionId: inspection._id, projectId: req.body.projectId, triggerType: 'inspection_rejected' }
|
metadata: { inspectionId: inspection.id, projectId: req.body.projectId, triggerType: 'inspection_rejected' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +105,15 @@ export const uploadPhoto = async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'No file uploaded' });
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the public URL for the file
|
const buffer = fs.readFileSync(req.file.path);
|
||||||
// Assuming 'uploads' is served statically at /uploads
|
const savedFile = await fileService.saveFile(req.file.originalname, req.file.mimetype, buffer);
|
||||||
const fileUrl = `/uploads/${req.file.filename}`;
|
|
||||||
|
// Clean up temp file
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
|
||||||
|
// We'll use /api/datasheets/file/:id to serve these if we use the same endpoint
|
||||||
|
// or create a new general /api/files/:id
|
||||||
|
const fileUrl = `/api/datasheets/file/${savedFile.id}`;
|
||||||
|
|
||||||
res.json({ url: fileUrl });
|
res.json({ url: fileUrl });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import Instrument from '../models/Instrument.js';
|
import * as instrumentService from '../services/instrumentService.js';
|
||||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||||
|
|
||||||
interface AuthRequest extends Request {
|
interface AuthRequest extends Request {
|
||||||
@@ -9,33 +9,10 @@ interface AuthRequest extends Request {
|
|||||||
export const createInstrument = async (req: AuthRequest, res: Response) => {
|
export const createInstrument = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const { name, type, manufacturer, modelName, serialNumber, calibrationDate, calibrationExpirationDate, certificateUrl, notes } = req.body;
|
const instrument = await instrumentService.createInstrument({
|
||||||
|
...req.body,
|
||||||
const existing = await Instrument.findOne({ organizationId, serialNumber });
|
organizationId
|
||||||
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);
|
res.status(201).json(instrument);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -47,12 +24,7 @@ export const getInstruments = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const { status } = req.query;
|
const { status } = req.query;
|
||||||
|
const instruments = await instrumentService.getInstruments(organizationId, status as string);
|
||||||
// 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);
|
res.json(instruments);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -64,24 +36,7 @@ export const updateInstrument = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const instrument = await instrumentService.updateInstrument(id, req.body, 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.' });
|
if (!instrument) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
||||||
res.json(instrument);
|
res.json(instrument);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -94,10 +49,8 @@ export const deleteInstrument = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const success = await instrumentService.deleteInstrument(id, organizationId);
|
||||||
const deleted = await Instrument.findOneAndDelete({ _id: id, organizationId });
|
if (!success) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
||||||
if (!deleted) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ interface AuthRequest extends Request {
|
|||||||
|
|
||||||
export const createProject = async (req: AuthRequest, res: Response) => {
|
export const createProject = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
console.log('Backend creating project. Body:', req.body);
|
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const project = await projectService.createProject({ ...req.body, organizationId });
|
const project = await projectService.createProject({ ...req.body, organizationId });
|
||||||
console.log('Project created successfully:', project._id);
|
|
||||||
res.status(201).json(project);
|
res.status(201).json(project);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -74,16 +72,13 @@ export const updateProject = async (req: AuthRequest, res: Response) => {
|
|||||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||||
const project = await projectService.updateProject(req.params.id as string, req.body, organizationId, isGlobalAdmin);
|
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) {
|
if (req.body.weightKg !== undefined && organizationId) {
|
||||||
await notificationService.create({
|
await notificationService.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
title: 'Atualização de Obra',
|
title: 'Atualização de Obra',
|
||||||
message: `O peso da obra "${project.name}" foi atualizado para ${project.weightKg}kg.`,
|
message: `O peso da obra "${project.name}" foi atualizado para ${project.weightKg}kg.`,
|
||||||
type: 'info',
|
type: 'info',
|
||||||
metadata: { projectId: project._id, triggerType: 'project_update' }
|
metadata: { projectId: project.id, triggerType: 'project_update' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import StockItem from '../models/StockItem.js';
|
import * as stockItemService from '../services/stockItemService.js';
|
||||||
import StockMovement from '../models/StockMovement.js';
|
|
||||||
|
|
||||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||||
import { notificationService } from '../services/notificationService.js';
|
import { notificationService } from '../services/notificationService.js';
|
||||||
|
|
||||||
@@ -14,80 +12,26 @@ export const createStockItem = async (req: AuthRequest, res: Response) => {
|
|||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const {
|
const {
|
||||||
dataSheetId,
|
dataSheetId, rrNumber, batchNumber, quantity, unit,
|
||||||
rrNumber,
|
expirationDate, notes, color, invoiceNumber, receivedBy, minStock
|
||||||
batchNumber,
|
|
||||||
quantity,
|
|
||||||
unit,
|
|
||||||
expirationDate,
|
|
||||||
notes,
|
|
||||||
color,
|
|
||||||
invoiceNumber,
|
|
||||||
receivedBy,
|
|
||||||
minStock
|
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) {
|
if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) {
|
||||||
return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' });
|
return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate RR within Org
|
const savedItem = await stockItemService.createStockItem({
|
||||||
const existing = await StockItem.findOne({ organizationId, rrNumber });
|
...req.body,
|
||||||
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,
|
organizationId,
|
||||||
createdBy: req.appUser?.externalId,
|
createdBy: req.appUser?.externalId,
|
||||||
dataSheetId,
|
|
||||||
rrNumber,
|
|
||||||
batchNumber,
|
|
||||||
quantity: Number(quantity),
|
quantity: Number(quantity),
|
||||||
unit,
|
minStock: Number(minStock) || 0
|
||||||
minStock: finalMinStock,
|
|
||||||
expirationDate,
|
|
||||||
notes,
|
|
||||||
color,
|
|
||||||
invoiceNumber,
|
|
||||||
receivedBy
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedItem = await newItem.save();
|
await stockItemService.createStockMovement({
|
||||||
|
|
||||||
// Create Initial Movement (ENTRY)
|
|
||||||
await StockMovement.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?.externalId,
|
createdBy: req.appUser?.externalId,
|
||||||
stockItemId: savedItem._id,
|
stockItemId: savedItem.id,
|
||||||
movementNumber: 1,
|
movementNumber: 1,
|
||||||
type: 'ENTRY',
|
type: 'ENTRY',
|
||||||
quantity: Number(quantity),
|
quantity: Number(quantity),
|
||||||
@@ -95,24 +39,19 @@ export const createStockItem = async (req: AuthRequest, res: Response) => {
|
|||||||
notes: 'Abertura de Lote / Entrada Inicial'
|
notes: 'Abertura de Lote / Entrada Inicial'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notificação de Recebimento
|
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
await notificationService.create({
|
await notificationService.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
title: 'Recebimento de Material',
|
title: 'Recebimento de Material',
|
||||||
message: `Recebido: ${quantity}${unit} de ${savedItem.rrNumber} (Lote: ${batchNumber}).`,
|
message: `Recebido: ${quantity}${unit} de ${savedItem.rr_number} (Lote: ${batchNumber}).`,
|
||||||
type: 'info',
|
type: 'info',
|
||||||
metadata: { stockItemId: savedItem._id, triggerType: 'stock_received' }
|
metadata: { stockItemId: savedItem.id, triggerType: 'stock_received' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Low Stock immediately
|
|
||||||
await notificationService.checkLowStock(savedItem._id.toString());
|
|
||||||
|
|
||||||
res.status(201).json(savedItem);
|
res.status(201).json(savedItem);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error('Error creating stock item:', error);
|
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -121,46 +60,16 @@ export const updateStockItem = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
// Only allow updating metadata, NOT quantity directly (quantity must be via adjustments)
|
const { quantity, ...otherData } = req.body;
|
||||||
// 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) {
|
if (quantity !== undefined) {
|
||||||
return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' });
|
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
|
const updated = await stockItemService.updateStockItem(id, organizationId!, otherData);
|
||||||
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.' });
|
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);
|
res.json(updated);
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
@@ -172,43 +81,32 @@ export const adjustStock = async (req: AuthRequest, res: Response) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const { quantityDelta, reason } = req.body; // quantityDelta: +10 or -5
|
const { quantityDelta, reason } = req.body;
|
||||||
|
|
||||||
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
|
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
|
||||||
if (!quantityDelta || isNaN(quantityDelta)) return res.status(400).json({ error: 'Quantidade inválida.' });
|
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: id, organizationId });
|
const item = await stockItemService.getStockItemById(id, organizationId!);
|
||||||
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
|
|
||||||
// Calculate new quantity
|
|
||||||
const newQuantity = Number(item.quantity) + Number(quantityDelta);
|
const newQuantity = Number(item.quantity) + Number(quantityDelta);
|
||||||
if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' });
|
if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' });
|
||||||
|
|
||||||
item.quantity = newQuantity;
|
await stockItemService.updateStockItemQuantity(id, newQuantity);
|
||||||
await item.save();
|
|
||||||
|
|
||||||
// Calculate next movement number
|
const lastNum = await stockItemService.getLatestMovementNumber(id);
|
||||||
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 stockItemService.createStockMovement({
|
||||||
await StockMovement.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?.externalId,
|
createdBy: req.appUser?.externalId,
|
||||||
stockItemId: item._id,
|
stockItemId: id,
|
||||||
movementNumber,
|
movementNumber: lastNum + 1,
|
||||||
type: 'ADJUSTMENT',
|
type: 'ADJUSTMENT',
|
||||||
quantity: Number(quantityDelta),
|
quantity: Number(quantityDelta),
|
||||||
responsible: userName,
|
responsible: userName,
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check Low Stock
|
res.json({ ...item, quantity: newQuantity });
|
||||||
await notificationService.checkLowStock(item._id.toString());
|
|
||||||
|
|
||||||
res.json(item);
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
@@ -222,40 +120,29 @@ export const consumeStock = async (req: AuthRequest, res: Response) => {
|
|||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const { quantityConsumed, requester, date } = req.body;
|
const { quantityConsumed, requester, date } = req.body;
|
||||||
|
|
||||||
if (!requester) return res.status(400).json({ error: 'Solicitante é obrigatório.' });
|
const item = await stockItemService.getStockItemById(id, organizationId!);
|
||||||
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) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
|
|
||||||
if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' });
|
if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' });
|
||||||
|
|
||||||
item.quantity -= Number(quantityConsumed);
|
const newQuantity = item.quantity - Number(quantityConsumed);
|
||||||
await item.save();
|
await stockItemService.updateStockItemQuantity(id, newQuantity);
|
||||||
|
|
||||||
// Calculate next movement number
|
const lastNum = await stockItemService.getLatestMovementNumber(id);
|
||||||
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 stockItemService.createStockMovement({
|
||||||
await StockMovement.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?.externalId,
|
createdBy: req.appUser?.externalId,
|
||||||
stockItemId: item._id,
|
stockItemId: id,
|
||||||
movementNumber,
|
movementNumber: lastNum + 1,
|
||||||
type: 'CONSUMPTION',
|
type: 'CONSUMPTION',
|
||||||
quantity: -Number(quantityConsumed), // Negative
|
quantity: -Number(quantityConsumed),
|
||||||
responsible: userName,
|
responsible: userName,
|
||||||
requester,
|
requester,
|
||||||
date: date || new Date()
|
date: date || new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check Low Stock
|
res.json({ ...item, quantity: newQuantity });
|
||||||
await notificationService.checkLowStock(item._id.toString());
|
|
||||||
|
|
||||||
res.json(item);
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
@@ -266,17 +153,8 @@ export const deleteStockItem = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const success = await stockItemService.deleteStockItem(id, organizationId!);
|
||||||
// Optional: Block delete if there are movements other than ENTRY?
|
if (!success) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
// 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();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -288,16 +166,7 @@ export const getStockItems = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const { dataSheetId } = req.query;
|
const { dataSheetId } = req.query;
|
||||||
|
const items = await stockItemService.getStockItems(organizationId!, dataSheetId as string);
|
||||||
// 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);
|
res.json(items);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -309,12 +178,8 @@ export const getStockItemById = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const item = await stockItemService.getStockItemById(id, 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.' });
|
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
|
|
||||||
res.json(item);
|
res.json(item);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -324,12 +189,9 @@ export const getStockItemById = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
export const getStockMovements = async (req: AuthRequest, res: Response) => {
|
export const getStockMovements = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params; // StockItem ID
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const movements = await stockItemService.getStockMovements(id, organizationId!);
|
||||||
const movements = await StockMovement.find({ stockItemId: id, organizationId })
|
|
||||||
.sort({ date: -1 });
|
|
||||||
|
|
||||||
res.json(movements);
|
res.json(movements);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -337,91 +199,41 @@ export const getStockMovements = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// CRUD & Auditing for Movements
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
import StockAuditLog from '../models/StockAuditLog.js';
|
|
||||||
|
|
||||||
export const updateStockMovement = async (req: AuthRequest, res: Response) => {
|
export const updateStockMovement = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params; // Movement ID
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const userId = req.appUser?.externalId || 'system';
|
const userId = req.appUser?.externalId || 'system';
|
||||||
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
const oldMovement = await stockItemService.getStockMovementById(id, organizationId!);
|
||||||
return res.status(403).json({ error: 'Apenas administradores podem editar movimentações.' });
|
if (!oldMovement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
||||||
}
|
|
||||||
|
|
||||||
const { date, quantity, notes } = req.body;
|
const item = await stockItemService.getStockItemById(oldMovement.stock_item_id, organizationId!);
|
||||||
|
const quantityDiff = Number(req.body.quantity) - Number(oldMovement.quantity);
|
||||||
|
|
||||||
const movement = await StockMovement.findOne({ _id: id, organizationId });
|
|
||||||
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
|
|
||||||
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
|
|
||||||
|
|
||||||
// Calculate Delta
|
|
||||||
// If quantity changed, we need to adjust the item balance
|
|
||||||
// Note: 'quantity' in movement is signed (+ for entry, - for consumption)
|
|
||||||
// If the user edits a Consumption (-10) to (-15), the val passed in body might be absolute or signed?
|
|
||||||
// Let's assume the frontend sends the SIGNED value consistent with the movement type?
|
|
||||||
// Actually best to stick to specific logic:
|
|
||||||
// If movement type is ENTRY/ADJUSTMENT, quantity is usually positive (unless neg adjustment).
|
|
||||||
// If CONSUMPTION, quantity is stored negative.
|
|
||||||
// Let's expect the frontend to send the 'raw' new value.
|
|
||||||
// Be careful: if frontend sends positive 10 for a consumption, we must flip it?
|
|
||||||
// Let's assume frontend sends the value exactly as it should be stored.
|
|
||||||
|
|
||||||
// HOWEVER, it's safer if we check type.
|
|
||||||
const newQuantitySigned = Number(quantity);
|
|
||||||
|
|
||||||
// Validation: Consumption should generally be negative, Entry positive.
|
|
||||||
// But for flexibility let's just trust the arithmetic diff for now,
|
|
||||||
// but warn if sign flips unexpectedly?
|
|
||||||
|
|
||||||
const oldQuantity = Number(movement.quantity);
|
|
||||||
const quantityDiff = newQuantitySigned - oldQuantity;
|
|
||||||
|
|
||||||
// Update Item
|
|
||||||
const newStockLevel = Number(item.quantity) + quantityDiff;
|
const newStockLevel = Number(item.quantity) + quantityDiff;
|
||||||
if (newStockLevel < 0) {
|
if (newStockLevel < 0) return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
|
||||||
return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
item.quantity = newStockLevel;
|
await stockItemService.updateStockItemQuantity(item.id, newStockLevel);
|
||||||
await item.save();
|
|
||||||
|
|
||||||
// Audit Log
|
await stockItemService.createAuditLog({
|
||||||
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
|
|
||||||
const typeLabel = typeMap[movement.type] || movement.type;
|
|
||||||
|
|
||||||
await StockAuditLog.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
stockItemId: item._id,
|
stockItemId: item.id,
|
||||||
movementId: movement._id,
|
movementId: id,
|
||||||
movementNumber: movement.movementNumber,
|
movementNumber: oldMovement.movement_number,
|
||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`,
|
details: `Edição de Movimentação: Qtd ${oldMovement.quantity} -> ${req.body.quantity}`,
|
||||||
oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes },
|
oldValues: oldMovement,
|
||||||
newValues: { date, quantity: newQuantitySigned, notes }
|
newValues: req.body
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Movement
|
const updated = await stockItemService.updateStockMovement(id, organizationId!, req.body);
|
||||||
movement.quantity = newQuantitySigned;
|
res.json(updated);
|
||||||
if (date) movement.date = date;
|
|
||||||
if (notes !== undefined) movement.notes = notes;
|
|
||||||
await movement.save();
|
|
||||||
|
|
||||||
res.json(movement);
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error('Error updating movement:', error);
|
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -432,68 +244,42 @@ export const deleteStockMovement = async (req: AuthRequest, res: Response) => {
|
|||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const userId = req.appUser?.externalId || 'system';
|
const userId = req.appUser?.externalId || 'system';
|
||||||
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
const movement = await stockItemService.getStockMovementById(id, organizationId!);
|
||||||
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.' });
|
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
|
const item = await stockItemService.getStockItemById(movement.stock_item_id, organizationId!);
|
||||||
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
|
const newStockLevel = Number(item.quantity) - Number(movement.quantity);
|
||||||
|
|
||||||
// Reverse the effect
|
if (newStockLevel < 0) return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
|
||||||
// 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);
|
await stockItemService.updateStockItemQuantity(item.id, newStockLevel);
|
||||||
const newStockLevel = Number(item.quantity) - reverseQty;
|
|
||||||
|
|
||||||
if (newStockLevel < 0) {
|
await stockItemService.createAuditLog({
|
||||||
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,
|
organizationId,
|
||||||
stockItemId: item._id,
|
stockItemId: item.id,
|
||||||
movementId: movement._id,
|
movementId: id,
|
||||||
movementNumber: movement.movementNumber,
|
movementNumber: movement.movement_number,
|
||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
action: 'DELETE',
|
action: 'DELETE',
|
||||||
details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`,
|
details: `Exclusão de Movimentação: Qtd ${movement.quantity}`,
|
||||||
oldValues: movement.toObject()
|
oldValues: movement
|
||||||
});
|
});
|
||||||
|
|
||||||
await StockMovement.deleteOne({ _id: id });
|
await stockItemService.deleteStockMovement(id, organizationId!);
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error('Error deleting movement:', error);
|
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
|
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params; // StockItem ID
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const logs = await stockItemService.getStockAuditLogs(id, organizationId!);
|
||||||
const logs = await StockAuditLog.find({ stockItemId: id, organizationId })
|
|
||||||
.sort({ timestamp: -1 });
|
|
||||||
|
|
||||||
res.json(logs);
|
res.json(logs);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import User, { IUser } from '../models/User.js';
|
import * as userService from '../services/userService.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 {
|
interface AuthRequest extends Request {
|
||||||
appUser?: IAppUser;
|
appUser?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync user from Auth to MongoDB
|
|
||||||
* Creates user if doesn't exist, updates if exists
|
|
||||||
* Also creates/updates OrganizationMember for the current organization
|
|
||||||
*/
|
|
||||||
export const syncUser = async (req: Request, res: Response) => {
|
export const syncUser = async (req: Request, res: Response) => {
|
||||||
console.log('--- syncUser called ---', req.body);
|
|
||||||
try {
|
try {
|
||||||
const { externalId, email, name, organizationId, clerkRole } = req.body;
|
const { externalId, email, name, organizationId, clerkRole } = req.body;
|
||||||
|
|
||||||
@@ -27,75 +13,21 @@ export const syncUser = async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'externalId, email e name são obrigatórios.' });
|
return res.status(400).json({ error: 'externalId, email e name são obrigatórios.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Upsert the global User record
|
const user = await userService.syncUser({
|
||||||
let user = await User.findOne({ externalId });
|
externalId,
|
||||||
|
email,
|
||||||
if (user) {
|
name,
|
||||||
user.email = email;
|
organizationId,
|
||||||
user.name = name;
|
clerkRole
|
||||||
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);
|
res.json(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error syncing user:', 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)) });
|
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) => {
|
export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
@@ -103,45 +35,28 @@ export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
const user = await userService.getCurrentUser(req.appUser.clerk_id || req.appUser.logto_id || req.appUser.externalId, organizationId);
|
||||||
|
|
||||||
if (organizationId) {
|
if (!user) {
|
||||||
const member = await OrganizationMember.findOne({
|
return res.status(404).json({ error: 'Usuário não encontrado.' });
|
||||||
userId: req.appUser.externalId,
|
|
||||||
organizationId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (member) {
|
|
||||||
return res.json({
|
|
||||||
...req.appUser.toObject(),
|
|
||||||
role: member.role,
|
|
||||||
isBanned: member.isBanned,
|
|
||||||
organizationId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(req.appUser);
|
res.json(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting current user:', error);
|
console.error('Error getting current user:', error);
|
||||||
res.status(500).json({ error: 'Erro ao buscar usuário.' });
|
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) => {
|
export const getAllUsers = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
console.log('getAllUsers called with organizationId:', organizationId);
|
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await OrganizationMember.find({ organizationId }).sort({ createdAt: -1 });
|
const members = await userService.getAllUsersInOrg(organizationId);
|
||||||
console.log(`Found ${members.length} members for org ${organizationId}:`, members.map(m => ({ name: m.name, email: m.email, externalId: m.userId })));
|
|
||||||
res.json(members);
|
res.json(members);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting users:', error);
|
console.error('Error getting users:', error);
|
||||||
@@ -149,9 +64,6 @@ export const getAllUsers = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user role within organization (admin only)
|
|
||||||
*/
|
|
||||||
export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -162,36 +74,18 @@ export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['guest', 'user', 'admin'].includes(role)) {
|
const member = await userService.updateUserRole(id, organizationId, role);
|
||||||
return res.status(400).json({ error: 'Role inválido. Use: guest, user ou admin.' });
|
if (!member) {
|
||||||
}
|
|
||||||
|
|
||||||
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.' });
|
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);
|
res.json(member);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling ban:', error);
|
console.error('Error updating role:', error);
|
||||||
res.status(500).json({ error: 'Erro ao alterar status de banimento.' });
|
res.status(500).json({ error: 'Erro ao alterar role.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Ban or unban user within organization (admin only)
|
|
||||||
*/
|
|
||||||
export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -202,24 +96,11 @@ export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await OrganizationMember.findById(id);
|
const member = await userService.toggleBanUser(id, organizationId, isBanned);
|
||||||
if (!member || member.organizationId !== organizationId) {
|
if (!member) {
|
||||||
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
|
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);
|
res.json(member);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling ban:', error);
|
console.error('Error toggling ban:', error);
|
||||||
@@ -227,62 +108,29 @@ export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update current user's lastSeenAt timestamp
|
|
||||||
*/
|
|
||||||
export const heartbeat = async (req: AuthRequest, res: Response) => {
|
export const heartbeat = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
return res.status(401).json({ error: 'Não autenticado.' });
|
return res.status(401).json({ error: 'Não autenticado.' });
|
||||||
}
|
}
|
||||||
|
await userService.heartbeat(req.appUser.id);
|
||||||
// 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();
|
res.status(200).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Silent fail for heartbeat
|
|
||||||
console.error('Heartbeat error:', error);
|
console.error('Heartbeat error:', error);
|
||||||
res.status(500).send();
|
res.status(500).send();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get active users in the same organization (seen in last 2 mins)
|
|
||||||
*/
|
|
||||||
export const getActiveUsers = async (req: AuthRequest, res: Response) => {
|
export const getActiveUsers = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
const currentUserId = req.appUser?._id;
|
const currentUserId = req.appUser?.id;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
return res.status(400).json([]);
|
return res.status(400).json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find members of this org
|
const activeUsers = await userService.getActiveUsers(organizationId, currentUserId);
|
||||||
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);
|
res.json(activeUsers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting active users:', error);
|
console.error('Error getting active users:', error);
|
||||||
@@ -290,7 +138,6 @@ export const getActiveUsers = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete organization member
|
|
||||||
export const deleteUser = async (req: Request, res: Response) => {
|
export const deleteUser = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -300,18 +147,13 @@ export const deleteUser = async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Deleting member ${id} from organization ${organizationId}`);
|
const success = await userService.deleteUserFromOrg(id, organizationId);
|
||||||
|
|
||||||
// Delete from OrganizationMember collection
|
if (!success) {
|
||||||
const result = await OrganizationMember.findByIdAndDelete(id);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return res.status(404).json({ error: 'Membro não encontrado.' });
|
return res.status(404).json({ error: 'Membro não encontrado.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Member ${result.name} deleted successfully`);
|
res.json({ message: 'Membro removido com sucesso.' });
|
||||||
|
|
||||||
res.json({ message: 'Membro removido com sucesso.', deletedMember: result });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting user:', error);
|
console.error('Error deleting user:', error);
|
||||||
res.status(500).json({ error: 'Erro ao remover membro.' });
|
res.status(500).json({ error: 'Erro ao remover membro.' });
|
||||||
|
|||||||
@@ -1,72 +1,162 @@
|
|||||||
import ApplicationRecord from '../models/ApplicationRecord.js';
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
interface ApplicationRecordItem {
|
||||||
export const createApplicationRecord = async (data: any & { organizationId?: string, createdBy?: string }) => {
|
partId: string;
|
||||||
const newRecord = new ApplicationRecord({
|
quantity: number;
|
||||||
...data,
|
}
|
||||||
date: data.date ? new Date(data.date) : null,
|
|
||||||
organizationId: data.organizationId,
|
interface ApplicationRecordData {
|
||||||
createdBy: data.createdBy
|
organizationId?: string;
|
||||||
});
|
createdBy?: string;
|
||||||
const saved = await newRecord.save();
|
projectId: string;
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
coatStage: string;
|
||||||
|
pieceDescription?: string;
|
||||||
|
date?: Date;
|
||||||
|
operator?: string;
|
||||||
|
realWeight?: number;
|
||||||
|
volumeUsed?: number;
|
||||||
|
areaPainted?: number;
|
||||||
|
wetThicknessAvg?: number;
|
||||||
|
dryThicknessCalc?: number;
|
||||||
|
method?: string;
|
||||||
|
diluentUsed?: number;
|
||||||
|
notes?: string;
|
||||||
|
items?: ApplicationRecordItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createApplicationRecord = async (data: ApplicationRecordData) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.application_records (
|
||||||
|
organization_id, created_by, project_id, coat_stage, piece_description,
|
||||||
|
date, operator, real_weight, volume_used, area_painted,
|
||||||
|
wet_thickness_avg, dry_thickness_calc, method, diluent_used, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.createdBy, data.projectId, data.coatStage, data.pieceDescription,
|
||||||
|
data.date, data.operator, data.realWeight, data.volumeUsed, data.areaPainted,
|
||||||
|
data.wetThicknessAvg, data.dryThicknessCalc, data.method, data.diluentUsed, data.notes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const record = result.rows[0];
|
||||||
|
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
for (const item of data.items) {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO gpi.application_record_items (application_record_id, part_id, quantity) VALUES ($1, $2, $3)',
|
||||||
|
[record.id, item.partId, item.quantity]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getApplicationRecordsByProject = async (projectId: string, organizationId?: string) => {
|
export const getApplicationRecordsByProject = async (projectId: string, organizationId?: string) => {
|
||||||
const query = { projectId, ...(organizationId ? { organizationId } : {}) };
|
let sql = 'SELECT * FROM gpi.application_records WHERE project_id = $1';
|
||||||
const records = await ApplicationRecord.find(query).sort({ date: -1 }).lean();
|
const params: any[] = [projectId];
|
||||||
return records.map(r => ({ ...r, id: r._id.toString() }));
|
|
||||||
|
if (organizationId) {
|
||||||
|
sql += ' AND organization_id = $2';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY date DESC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
// Fetch items for each record
|
||||||
|
const records = [];
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const itemsResult = await query(
|
||||||
|
'SELECT part_id as "partId", quantity FROM gpi.application_record_items WHERE application_record_id = $1',
|
||||||
|
[row.id]
|
||||||
|
);
|
||||||
|
records.push({ ...row, items: itemsResult.rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const updateApplicationRecord = async (
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
id: string,
|
||||||
export const updateApplicationRecord = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
data: Partial<ApplicationRecordData>,
|
||||||
const existing = await ApplicationRecord.findById(id);
|
organizationId?: string,
|
||||||
if (!existing) return null;
|
userId?: string,
|
||||||
|
userRole?: string,
|
||||||
|
isDeveloper: boolean = false
|
||||||
|
) => {
|
||||||
|
const checkResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]);
|
||||||
|
if (checkResult.rows.length === 0) return null;
|
||||||
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
// Organization Check
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role/Ownership check
|
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
|
||||||
console.warn(`Permission Denied: User ${userId} tried to update record ${id} created by ${existing.createdBy}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
...data,
|
|
||||||
date: data.date ? new Date(data.date) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
|
||||||
updateData.organizationId = organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await ApplicationRecord.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
|
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
|
if (!isPowerUser && existing.created_by && existing.created_by !== userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
const updatableFields = [
|
||||||
|
'coatStage', 'pieceDescription', 'date', 'operator', 'realWeight',
|
||||||
|
'volumeUsed', 'areaPainted', 'wetThicknessAvg', 'dryThicknessCalc',
|
||||||
|
'method', 'diluentUsed', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of updatableFields) {
|
||||||
|
if ((data as any)[key] !== undefined) {
|
||||||
|
// Convert camelCase to snake_case
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${paramIdx++}`);
|
||||||
|
params.push((data as any)[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length > 0) {
|
||||||
|
await query(
|
||||||
|
`UPDATE gpi.application_records SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle items update (replace all)
|
||||||
|
if (data.items) {
|
||||||
|
await query('DELETE FROM gpi.application_record_items WHERE application_record_id = $1', [id]);
|
||||||
|
for (const item of data.items) {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO gpi.application_record_items (application_record_id, part_id, quantity) VALUES ($1, $2, $3)',
|
||||||
|
[id, item.partId, item.quantity]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]);
|
||||||
|
const itemsFinal = await query('SELECT part_id as "partId", quantity FROM gpi.application_record_items WHERE application_record_id = $1', [id]);
|
||||||
|
|
||||||
|
return { ...finalResult.rows[0], items: itemsFinal.rows };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteApplicationRecord = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
export const deleteApplicationRecord = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
||||||
const existing = await ApplicationRecord.findById(id);
|
const checkResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]);
|
||||||
if (!existing) return false;
|
if (checkResult.rows.length === 0) return false;
|
||||||
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
// Organization Check
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Role/Ownership check
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
if (!isPowerUser && existing.created_by && existing.created_by !== userId) {
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await ApplicationRecord.deleteOne({ _id: id });
|
await query('DELETE FROM gpi.application_records WHERE id = $1', [id]);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,174 +1,133 @@
|
|||||||
import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
|
import { query } from '../config/database.js';
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { bucket } from '../config/database.js';
|
|
||||||
import { ObjectId } from 'mongodb';
|
|
||||||
|
|
||||||
export const saveFileToGridFS = (localPath: string, filename: string): Promise<string> => {
|
interface DataSheetData {
|
||||||
return new Promise((resolve, reject) => {
|
organizationId?: string;
|
||||||
const uploadStream = bucket.openUploadStream(filename);
|
name: string;
|
||||||
const readStream = fs.createReadStream(localPath);
|
manufacturer?: string;
|
||||||
|
manufacturerCode?: string;
|
||||||
readStream.pipe(uploadStream)
|
type?: string;
|
||||||
.on('error', reject)
|
minStock?: number;
|
||||||
.on('finish', () => {
|
typicalApplication?: string;
|
||||||
// Remove local file after upload
|
fileId?: string;
|
||||||
fs.unlink(localPath, (err) => {
|
fileUrl?: string;
|
||||||
if (err) console.error('Failed to delete local temp file:', err);
|
solidsVolume?: number;
|
||||||
});
|
density?: number;
|
||||||
resolve(uploadStream.id.toString());
|
mixingRatio?: string;
|
||||||
});
|
mixingRatioWeight?: string;
|
||||||
});
|
mixingRatioVolume?: string;
|
||||||
};
|
wftMin?: number;
|
||||||
|
wftMax?: number;
|
||||||
export const deleteFileFromGridFS = async (fileId: string) => {
|
dftMin?: number;
|
||||||
try {
|
dftMax?: number;
|
||||||
await bucket.delete(new ObjectId(fileId));
|
reducer?: string;
|
||||||
return true;
|
yieldTheoretical?: number;
|
||||||
} catch (err) {
|
dftReference?: number;
|
||||||
console.error('Failed to delete file from GridFS:', err);
|
yieldFactor?: number;
|
||||||
return false;
|
dilution?: number;
|
||||||
}
|
notes?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const getFileStream = (fileId: string) => {
|
|
||||||
if (!ObjectId.isValid(fileId)) {
|
|
||||||
throw new Error('Invalid file ID format');
|
|
||||||
}
|
|
||||||
return bucket.openDownloadStream(new ObjectId(fileId));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllDataSheets = async (organizationId?: string) => {
|
export const getAllDataSheets = async (organizationId?: string) => {
|
||||||
const query = organizationId
|
let sql = 'SELECT * FROM gpi.technical_data_sheets';
|
||||||
? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }
|
const params: any[] = [];
|
||||||
: {};
|
|
||||||
const sheets = await TechnicalDataSheet.find(query).sort({ uploadDate: -1 }).lean();
|
if (organizationId) {
|
||||||
return sheets.map(s => ({ ...s, id: s._id.toString() }));
|
sql += ' WHERE (organization_id = $1 OR organization_id IS NULL)';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const matchSheets = async (query: string, organizationId?: string) => {
|
export const matchSheets = async (searchTerm: string, organizationId?: string) => {
|
||||||
const orgFilter = organizationId
|
let sql = `
|
||||||
? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }
|
SELECT * FROM gpi.technical_data_sheets
|
||||||
: {};
|
WHERE (name ILIKE $1 OR manufacturer ILIKE $1 OR type ILIKE $1)`;
|
||||||
|
const params: any[] = [`%${searchTerm}%`];
|
||||||
|
|
||||||
const filter = {
|
if (organizationId) {
|
||||||
...orgFilter,
|
sql += ' AND (organization_id = $2 OR organization_id IS NULL)';
|
||||||
$or: [
|
params.push(organizationId);
|
||||||
{ name: { $regex: query, $options: 'i' } },
|
}
|
||||||
{ manufacturer: { $regex: query, $options: 'i' } },
|
|
||||||
{ type: { $regex: query, $options: 'i' } }
|
const result = await query(sql, params);
|
||||||
]
|
return result.rows;
|
||||||
};
|
|
||||||
const sheets = await TechnicalDataSheet.find(filter).lean();
|
|
||||||
return sheets.map(s => ({ ...s, id: s._id.toString() }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const createDataSheet = async (data: DataSheetData) => {
|
||||||
export const createDataSheet = async (data: any & { organizationId?: string }) => {
|
const result = await query(
|
||||||
let fileId = data.fileUrl;
|
`INSERT INTO gpi.technical_data_sheets (
|
||||||
|
organization_id, name, manufacturer, manufacturer_code, type,
|
||||||
|
min_stock, typical_application, file_id, file_url, solids_volume,
|
||||||
|
density, mixing_ratio, mixing_ratio_weight, mixing_ratio_volume,
|
||||||
|
wft_min, wft_max, dft_min, dft_max, reducer, yield_theoretical,
|
||||||
|
dft_reference, yield_factor, dilution, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.name, data.manufacturer, data.manufacturerCode, data.type,
|
||||||
|
data.minStock, data.typicalApplication, data.fileId, data.fileUrl, data.solidsVolume,
|
||||||
|
data.density, data.mixingRatio, data.mixingRatioWeight, data.mixingRatioVolume,
|
||||||
|
data.wftMin, data.wftMax, data.dftMin, data.dftMax, data.reducer, data.yieldTheoretical,
|
||||||
|
data.dftReference, data.yieldFactor, data.dilution, data.notes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
// If fileUrl is a local path (exists on disk), move to GridFS
|
export const updateDataSheet = async (id: string, data: Partial<DataSheetData>, organizationId?: string) => {
|
||||||
if (data.fileUrl && fs.existsSync(data.fileUrl)) {
|
const checkResult = await query('SELECT * FROM gpi.technical_data_sheets WHERE id = $1', [id]);
|
||||||
fileId = await saveFileToGridFS(data.fileUrl, data.name + '.pdf');
|
if (checkResult.rows.length === 0) return null;
|
||||||
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
const updatableFields = [
|
||||||
|
'name', 'manufacturer', 'manufacturerCode', 'type', 'minStock',
|
||||||
|
'typicalApplication', 'fileId', 'fileUrl', 'solidsVolume', 'density',
|
||||||
|
'mixingRatio', 'mixingRatioWeight', 'mixingRatioVolume', 'wftMin',
|
||||||
|
'wftMax', 'dftMin', 'dftMax', 'reducer', 'yieldTheoretical',
|
||||||
|
'dftReference', 'yieldFactor', 'dilution', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of updatableFields) {
|
||||||
|
if ((data as any)[key] !== undefined) {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${paramIdx++}`);
|
||||||
|
params.push((data as any)[key]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newSheet = new TechnicalDataSheet({
|
if (fields.length === 0) return existing;
|
||||||
...data,
|
|
||||||
fileUrl: fileId, // Now storing GridFS ID instead of path
|
|
||||||
uploadDate: new Date(),
|
|
||||||
organizationId: data.organizationId
|
|
||||||
});
|
|
||||||
|
|
||||||
const saved = await newSheet.save();
|
const updateSql = `
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
UPDATE gpi.technical_data_sheets
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`;
|
||||||
|
|
||||||
|
const result = await query(updateSql, params);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteDataSheet = async (id: string, organizationId?: string) => {
|
export const deleteDataSheet = async (id: string, organizationId?: string) => {
|
||||||
// Find first to check permissions
|
const checkResult = await query('SELECT * FROM gpi.technical_data_sheets WHERE id = $1', [id]);
|
||||||
const sheet = await TechnicalDataSheet.findById(id);
|
if (checkResult.rows.length === 0) return false;
|
||||||
if (!sheet) return false;
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
// Permission Check:
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
// If current user is in an Org, and Sheet is in a DIFFERENT Org, deny.
|
return false;
|
||||||
// Explicitly allow if Sheet has NO Org (Legacy/Global).
|
}
|
||||||
if (organizationId && sheet.organizationId && sheet.organizationId !== organizationId) {
|
|
||||||
console.warn(`[Delete DataSheet] Access Denied. User Org: ${organizationId}, Sheet Org: ${sheet.organizationId}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete from GridFS if not a full URL
|
await query('DELETE FROM gpi.technical_data_sheets WHERE id = $1', [id]);
|
||||||
if (sheet.fileUrl && !sheet.fileUrl.startsWith('http')) {
|
return true;
|
||||||
await deleteFileFromGridFS(sheet.fileUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
await TechnicalDataSheet.findByIdAndDelete(id);
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const updateDataSheet = async (id: string, updates: any, organizationId?: string) => {
|
|
||||||
// SECURITY FIX: Allow update if:
|
|
||||||
// 1. Matches ID AND Matches Organization
|
|
||||||
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
|
|
||||||
|
|
||||||
const oldSheet = await TechnicalDataSheet.findById(id);
|
|
||||||
if (!oldSheet) return null;
|
|
||||||
|
|
||||||
if (organizationId && oldSheet.organizationId && oldSheet.organizationId !== organizationId) {
|
|
||||||
console.warn(`Access Denied: Sheet ${id} belongs to ${oldSheet.organizationId}, user is ${organizationId}`);
|
|
||||||
return null; // Return null effectively hides it or acts as fail
|
|
||||||
}
|
|
||||||
|
|
||||||
// If new file is uploaded (path exists locally)
|
|
||||||
if (updates.fileUrl && updates.fileUrl !== oldSheet.fileUrl && fs.existsSync(updates.fileUrl)) {
|
|
||||||
// Upload new file
|
|
||||||
const newFileId = await saveFileToGridFS(updates.fileUrl, (updates.name || oldSheet.name) + '.pdf');
|
|
||||||
|
|
||||||
// Delete old file from GridFS
|
|
||||||
if (oldSheet.fileUrl && !oldSheet.fileUrl.startsWith('http')) {
|
|
||||||
await deleteFileFromGridFS(oldSheet.fileUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
updates.fileUrl = newFileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organizationId && !oldSheet.organizationId) {
|
|
||||||
updates.organizationId = organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await TechnicalDataSheet.findOneAndUpdate({ _id: id }, updates, { new: true }).lean();
|
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const migrateFilesToGridFS = async () => {
|
|
||||||
try {
|
|
||||||
const sheets = await TechnicalDataSheet.find({ fileUrl: { $regex: /^uploads\// } });
|
|
||||||
console.log(`[MIGRATION] Found ${sheets.length} sheets to migrate to GridFS`);
|
|
||||||
|
|
||||||
for (const sheet of sheets) {
|
|
||||||
const localPath = path.join(process.cwd(), sheet.fileUrl);
|
|
||||||
if (fs.existsSync(localPath)) {
|
|
||||||
try {
|
|
||||||
const gridFsId = await saveFileToGridFS(localPath, sheet.name + '.pdf');
|
|
||||||
sheet.fileUrl = gridFsId;
|
|
||||||
await sheet.save();
|
|
||||||
console.log(`[MIGRATION] Successfully migrated: ${sheet.name}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[MIGRATION] Error migrating ${sheet.name}:`, err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`[MIGRATION] File not found for ${sheet.name}: ${localPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MIGRATION] Migration failed:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
18
src/server/services/fileService.ts
Normal file
18
src/server/services/fileService.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
|
export const saveFile = async (filename: string, contentType: string, data: Buffer) => {
|
||||||
|
const result = await query(
|
||||||
|
'INSERT INTO gpi.files (filename, content_type, data, size) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||||
|
[filename, contentType, data, data.length]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileById = async (id: string) => {
|
||||||
|
const result = await query('SELECT * FROM gpi.files WHERE id = $1', [id]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFile = async (id: string) => {
|
||||||
|
await query('DELETE FROM gpi.files WHERE id = $1', [id]);
|
||||||
|
};
|
||||||
@@ -1,81 +1,155 @@
|
|||||||
import Inspection from '../models/Inspection.js';
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
interface InspectionData {
|
||||||
export const createInspection = async (data: any & { organizationId?: string, createdBy?: string }) => {
|
projectId: string;
|
||||||
const newInspection = new Inspection({
|
partId: string;
|
||||||
...data,
|
type: string;
|
||||||
date: data.date ? new Date(data.date) : null,
|
status: string;
|
||||||
organizationId: data.organizationId,
|
notes?: string;
|
||||||
createdBy: data.createdBy
|
inspectorId?: string;
|
||||||
});
|
organizationId?: string;
|
||||||
const saved = await newInspection.save();
|
createdBy?: string;
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
}
|
||||||
|
|
||||||
|
export const createInspection = async (data: InspectionData) => {
|
||||||
|
const {
|
||||||
|
projectId,
|
||||||
|
partId,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
notes,
|
||||||
|
inspectorId,
|
||||||
|
organizationId
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.inspections (
|
||||||
|
project_id,
|
||||||
|
part_id,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
notes,
|
||||||
|
inspector_id,
|
||||||
|
organization_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[projectId, partId, type, status, notes, inspectorId, organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInspectionsByProject = async (projectId: string, organizationId?: string) => {
|
export const getInspectionsByProject = async (projectId: string, organizationId?: string) => {
|
||||||
const query = { projectId, ...(organizationId ? { organizationId } : {}) };
|
let sql = 'SELECT * FROM gpi.inspections WHERE project_id = $1';
|
||||||
const inspections = await Inspection.find(query).sort({ date: -1 }).lean();
|
const params: any[] = [projectId];
|
||||||
return inspections.map(i => ({ ...i, id: i._id.toString() }));
|
|
||||||
|
if (organizationId) {
|
||||||
|
sql += ' AND organization_id = $2';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const updateInspection = async (
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
id: string,
|
||||||
export const updateInspection = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
data: Partial<InspectionData>,
|
||||||
const existing = await Inspection.findById(id);
|
organizationId?: string,
|
||||||
if (!existing) return null;
|
userId?: string,
|
||||||
|
userRole?: string,
|
||||||
|
isDeveloper: boolean = false
|
||||||
|
) => {
|
||||||
|
// First check if exists and permissions
|
||||||
|
const checkSql = 'SELECT * FROM gpi.inspections WHERE id = $1';
|
||||||
|
const checkResult = await query(checkSql, [id]);
|
||||||
|
|
||||||
// Organization Check
|
if (checkResult.rows.length === 0) return null;
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
const existing = checkResult.rows[0];
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role/Ownership check
|
// Organization Check
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
|
||||||
console.warn(`Permission Denied: User ${userId} tried to update inspection ${id} created by ${existing.createdBy}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
...data,
|
|
||||||
date: data.date ? new Date(data.date) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
|
||||||
updateData.organizationId = organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await Inspection.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
|
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role/Ownership check (using inspector_id as owner for now as created_by isn't in original schema but in model)
|
||||||
|
// Wait, let's check the schema again. There is no created_by in inspections table in schema.sql.
|
||||||
|
// There is inspector_id.
|
||||||
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
|
if (!isPowerUser && existing.inspector_id && existing.inspector_id !== userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
if (data.type) {
|
||||||
|
fields.push(`type = $${paramIdx++}`);
|
||||||
|
params.push(data.type);
|
||||||
|
}
|
||||||
|
if (data.status) {
|
||||||
|
fields.push(`status = $${paramIdx++}`);
|
||||||
|
params.push(data.status);
|
||||||
|
}
|
||||||
|
if (data.notes !== undefined) {
|
||||||
|
fields.push(`notes = $${paramIdx++}`);
|
||||||
|
params.push(data.notes);
|
||||||
|
}
|
||||||
|
if (data.inspectorId) {
|
||||||
|
fields.push(`inspector_id = $${paramIdx++}`);
|
||||||
|
params.push(data.inspectorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return existing;
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE gpi.inspections
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`;
|
||||||
|
|
||||||
|
const result = await query(updateSql, params);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteInspection = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
export const deleteInspection = async (
|
||||||
const existing = await Inspection.findById(id);
|
id: string,
|
||||||
if (!existing) return false;
|
organizationId?: string,
|
||||||
|
userId?: string,
|
||||||
|
userRole?: string,
|
||||||
|
isDeveloper: boolean = false
|
||||||
|
) => {
|
||||||
|
const checkSql = 'SELECT * FROM gpi.inspections WHERE id = $1';
|
||||||
|
const checkResult = await query(checkSql, [id]);
|
||||||
|
|
||||||
// Organization Check
|
if (checkResult.rows.length === 0) return false;
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
const existing = checkResult.rows[0];
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role/Ownership check
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
return false;
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Inspection.deleteOne({ _id: id });
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
return true;
|
if (!isPowerUser && existing.inspector_id && existing.inspector_id !== userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await query('DELETE FROM gpi.inspections WHERE id = $1', [id]);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllInspections = async (organizationId?: string) => {
|
export const getAllInspections = async (organizationId?: string) => {
|
||||||
const query = organizationId ? { organizationId } : {};
|
let sql = 'SELECT * FROM gpi.inspections';
|
||||||
const inspections = await Inspection.find(query).lean();
|
const params: any[] = [];
|
||||||
return inspections.map(i => ({ ...i, id: i._id.toString() }));
|
|
||||||
|
if (organizationId) {
|
||||||
|
sql += ' WHERE organization_id = $1';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
91
src/server/services/instrumentService.ts
Normal file
91
src/server/services/instrumentService.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
|
interface InstrumentData {
|
||||||
|
organizationId: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
modelName?: string;
|
||||||
|
serialNumber: string;
|
||||||
|
calibrationDate?: Date;
|
||||||
|
calibrationExpirationDate?: Date;
|
||||||
|
certificateUrl?: string;
|
||||||
|
status?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInstrument = async (data: InstrumentData) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.instruments (
|
||||||
|
organization_id, name, type, manufacturer, model_name,
|
||||||
|
serial_number, calibration_date, calibration_expiration_date,
|
||||||
|
certificate_url, status, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.name, data.type, data.manufacturer, data.modelName,
|
||||||
|
data.serialNumber, data.calibrationDate, data.calibrationExpirationDate,
|
||||||
|
data.certificateUrl, data.status || 'active', data.notes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInstruments = async (organizationId: string, status?: string) => {
|
||||||
|
let sql = 'SELECT * FROM gpi.instruments WHERE organization_id = $1';
|
||||||
|
const params: any[] = [organizationId];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
sql += ' AND status = $2';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY name ASC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateInstrument = async (id: string, organizationId: string, data: Partial<InstrumentData>) => {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id, organizationId];
|
||||||
|
let paramIdx = 3;
|
||||||
|
|
||||||
|
const updatableFields = [
|
||||||
|
'name', 'type', 'manufacturer', 'modelName', 'serialNumber',
|
||||||
|
'calibrationDate', 'calibrationExpirationDate', 'certificateUrl',
|
||||||
|
'status', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of updatableFields) {
|
||||||
|
if ((data as any)[key] !== undefined) {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${paramIdx++}`);
|
||||||
|
params.push((data as any)[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
const res = await query('SELECT * FROM gpi.instruments WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
return res.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE gpi.instruments
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND organization_id = $2
|
||||||
|
RETURNING *`;
|
||||||
|
|
||||||
|
const result = await query(updateSql, params);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteInstrument = async (id: string, organizationId: string) => {
|
||||||
|
const result = await query('DELETE FROM gpi.instruments WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkSerialNumberExists = async (organizationId: string, serialNumber: string) => {
|
||||||
|
const result = await query('SELECT id FROM gpi.instruments WHERE organization_id = $1 AND serial_number = $2', [organizationId, serialNumber]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
};
|
||||||
@@ -1,72 +1,94 @@
|
|||||||
import PaintingScheme from '../models/PaintingScheme.js';
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
interface PaintingSchemeData {
|
||||||
export const createPaintingScheme = async (data: any & { organizationId?: string }) => {
|
organizationId?: string;
|
||||||
const newScheme = new PaintingScheme({ ...data, organizationId: data.organizationId });
|
projectId?: string;
|
||||||
const saved = await newScheme.save();
|
name: string;
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPaintingScheme = async (data: PaintingSchemeData) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.painting_schemes (organization_id, project_id, name, description)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[data.organizationId, data.projectId, data.name, data.description]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPaintingSchemesByProject = async (projectId: string, organizationId?: string) => {
|
export const getPaintingSchemesByProject = async (projectId: string, organizationId?: string) => {
|
||||||
const query = { projectId, ...(organizationId ? { organizationId } : {}) };
|
let sql = 'SELECT * FROM gpi.painting_schemes WHERE project_id = $1';
|
||||||
const schemes = await PaintingScheme.find(query).lean();
|
const params: any[] = [projectId];
|
||||||
return schemes.map(s => ({ ...s, id: s._id.toString() }));
|
|
||||||
|
if (organizationId) {
|
||||||
|
sql += ' AND organization_id = $2';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const updatePaintingScheme = async (id: string, data: Partial<PaintingSchemeData>, organizationId?: string) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const checkResult = await query('SELECT * FROM gpi.painting_schemes WHERE id = $1', [id]);
|
||||||
export const updatePaintingScheme = async (id: string, data: any, organizationId?: string) => {
|
if (checkResult.rows.length === 0) return null;
|
||||||
// SECURITY FIX: Allow update if:
|
const existing = checkResult.rows[0];
|
||||||
// 1. Matches ID AND Matches Organization
|
|
||||||
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
|
|
||||||
|
|
||||||
let query: any = { _id: id };
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
|
|
||||||
// First, check if the record exists and what is its state
|
|
||||||
const existing = await PaintingScheme.findById(id);
|
|
||||||
|
|
||||||
if (!existing) return null;
|
|
||||||
|
|
||||||
// Check ownership
|
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
// Exists but belongs to ANOTHER organization -> Deny
|
|
||||||
console.warn(`Access Denied: Scheme ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return null; // Return null effectively hides it or acts as fail
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we passed the check, we perform the update.
|
|
||||||
// Ensure we "adopt" the record if it didn't have an orgId
|
|
||||||
if (organizationId && !data.organizationId) {
|
|
||||||
data.organizationId = organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await PaintingScheme.findOneAndUpdate({ _id: id }, data, { new: true }).lean();
|
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
if (data.name) {
|
||||||
|
fields.push(`name = $${paramIdx++}`);
|
||||||
|
params.push(data.name);
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
fields.push(`description = $${paramIdx++}`);
|
||||||
|
params.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.projectId) {
|
||||||
|
fields.push(`project_id = $${paramIdx++}`);
|
||||||
|
params.push(data.projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return existing;
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE gpi.painting_schemes
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`;
|
||||||
|
|
||||||
|
const result = await query(updateSql, params);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deletePaintingScheme = async (id: string, organizationId?: string) => {
|
export const deletePaintingScheme = async (id: string, organizationId?: string) => {
|
||||||
// Find first to check permissions
|
const checkResult = await query('SELECT * FROM gpi.painting_schemes WHERE id = $1', [id]);
|
||||||
const existing = await PaintingScheme.findById(id);
|
if (checkResult.rows.length === 0) return;
|
||||||
if (!existing) return;
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
// Permissions:
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
// If user has org, and item has OTHER org, deny.
|
return;
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
}
|
||||||
console.warn(`[Delete PaintingScheme] Access Denied. User Org: ${organizationId}, Scheme Org: ${existing.organizationId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await PaintingScheme.findByIdAndDelete(id);
|
await query('DELETE FROM gpi.painting_schemes WHERE id = $1', [id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllSchemes = async (organizationId?: string) => {
|
export const getAllSchemes = async (organizationId?: string) => {
|
||||||
const query = organizationId ? { organizationId } : {};
|
let sql = 'SELECT * FROM gpi.painting_schemes';
|
||||||
const schemes = await PaintingScheme.find(query).lean();
|
const params: any[] = [];
|
||||||
return schemes.map(s => ({ ...s, id: s._id.toString() }));
|
|
||||||
|
if (organizationId) {
|
||||||
|
sql += ' WHERE organization_id = $1';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,114 @@
|
|||||||
import Part from '../models/Part.js';
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
interface PartData {
|
||||||
export const createPart = async (data: any & { organizationId?: string }) => {
|
projectId: string;
|
||||||
const newPart = new Part({ ...data, organizationId: data.organizationId });
|
organizationId?: string;
|
||||||
const saved = await newPart.save();
|
name: string;
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
weightKg?: number;
|
||||||
|
drawingNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPart = async (data: PartData) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.parts (
|
||||||
|
project_id, organization_id, name, description, quantity, weight_kg, drawing_number
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.projectId,
|
||||||
|
data.organizationId,
|
||||||
|
data.name,
|
||||||
|
data.description,
|
||||||
|
data.quantity || 1,
|
||||||
|
data.weightKg,
|
||||||
|
data.drawingNumber
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPartsByProject = async (projectId: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const getPartsByProject = async (projectId: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const query = isGlobalAdmin
|
let sql = 'SELECT * FROM gpi.parts WHERE project_id = $1';
|
||||||
? { projectId }
|
const params: any[] = [projectId];
|
||||||
: { projectId, $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
|
|
||||||
const parts = await Part.find(query).lean();
|
if (!isGlobalAdmin && organizationId) {
|
||||||
return parts.map(p => ({ ...p, id: p._id.toString() }));
|
sql += ' AND (organization_id = $2 OR organization_id IS NULL)';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const updatePart = async (id: string, data: Partial<PartData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const checkResult = await query('SELECT * FROM gpi.parts WHERE id = $1', [id]);
|
||||||
export const updatePart = async (id: string, data: any, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
if (checkResult.rows.length === 0) return null;
|
||||||
const existing = await Part.findById(id);
|
const existing = checkResult.rows[0];
|
||||||
if (!existing) return null;
|
|
||||||
|
|
||||||
if (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
console.warn(`Access Denied: Part ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
|
||||||
data.organizationId = organizationId; // Adopt
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await Part.findOneAndUpdate({ _id: id }, data, { new: true }).lean();
|
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
if (data.name) {
|
||||||
|
fields.push(`name = $${paramIdx++}`);
|
||||||
|
params.push(data.name);
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
fields.push(`description = $${paramIdx++}`);
|
||||||
|
params.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.quantity !== undefined) {
|
||||||
|
fields.push(`quantity = $${paramIdx++}`);
|
||||||
|
params.push(data.quantity);
|
||||||
|
}
|
||||||
|
if (data.weightKg !== undefined) {
|
||||||
|
fields.push(`weight_kg = $${paramIdx++}`);
|
||||||
|
params.push(data.weightKg);
|
||||||
|
}
|
||||||
|
if (data.drawingNumber !== undefined) {
|
||||||
|
fields.push(`drawing_number = $${paramIdx++}`);
|
||||||
|
params.push(data.drawingNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return existing;
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE gpi.parts
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`;
|
||||||
|
|
||||||
|
const result = await query(updateSql, params);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deletePart = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const deletePart = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const part = await Part.findById(id);
|
const checkResult = await query('SELECT * FROM gpi.parts WHERE id = $1', [id]);
|
||||||
if (!part) return;
|
if (checkResult.rows.length === 0) return;
|
||||||
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
if (!isGlobalAdmin && organizationId && part.organizationId && part.organizationId !== organizationId) {
|
if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
throw new Error('Sem permissão para excluir esta peça');
|
throw new Error('Sem permissão para excluir esta peça');
|
||||||
}
|
}
|
||||||
|
|
||||||
await Part.findByIdAndDelete(id);
|
await query('DELETE FROM gpi.parts WHERE id = $1', [id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllParts = async (organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const getAllParts = async (organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const query = isGlobalAdmin
|
let sql = 'SELECT * FROM gpi.parts';
|
||||||
? {}
|
const params: any[] = [];
|
||||||
: { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
|
|
||||||
const parts = await Part.find(query).lean();
|
if (!isGlobalAdmin && organizationId) {
|
||||||
return parts.map(p => ({ ...p, id: p._id.toString() }));
|
sql += ' WHERE (organization_id = $1 OR organization_id IS NULL)';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface ProjectData {
|
|||||||
technician?: string;
|
technician?: string;
|
||||||
environment?: string;
|
environment?: string;
|
||||||
weightKg?: number;
|
weightKg?: number;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createProject = async (data: ProjectData & { organizationId?: string }) => {
|
export const createProject = async (data: ProjectData & { organizationId?: string }) => {
|
||||||
@@ -24,7 +25,7 @@ export const getDashboardProjects = async (organizationId?: string) => {
|
|||||||
(SELECT json_agg(s) FROM gpi.painting_schemes s WHERE s.project_id = p.id) as schemes,
|
(SELECT json_agg(s) FROM gpi.painting_schemes s WHERE s.project_id = p.id) as schemes,
|
||||||
(SELECT SUM(weight_kg) FROM gpi.inspections i WHERE i.project_id = p.id) as painted_weight
|
(SELECT SUM(weight_kg) FROM gpi.inspections i WHERE i.project_id = p.id) as painted_weight
|
||||||
FROM gpi.projects p
|
FROM gpi.projects p
|
||||||
WHERE p.organization_id = $1 OR $1 IS NULL
|
WHERE (p.organization_id = $1 OR $1 IS NULL) AND p.status = 'active'
|
||||||
ORDER BY p.name ASC
|
ORDER BY p.name ASC
|
||||||
`;
|
`;
|
||||||
const res = await query(sql, [organizationId || null]);
|
const res = await query(sql, [organizationId || null]);
|
||||||
@@ -58,7 +59,7 @@ export const getProjectById = async (id: string, organizationId?: string, isGlob
|
|||||||
const [parts, schemes, records, inspections] = await Promise.all([
|
const [parts, schemes, records, inspections] = await Promise.all([
|
||||||
query('SELECT * FROM gpi.parts WHERE project_id = $1', [id]),
|
query('SELECT * FROM gpi.parts WHERE project_id = $1', [id]),
|
||||||
query('SELECT * FROM gpi.painting_schemes WHERE project_id = $1', [id]),
|
query('SELECT * FROM gpi.painting_schemes WHERE project_id = $1', [id]),
|
||||||
query('SELECT * FROM gpi.application_records WHERE project_id = $1', [id]), // Assuming this table exists in gpi
|
query('SELECT * FROM gpi.application_records WHERE project_id = $1', [id]),
|
||||||
query('SELECT * FROM gpi.inspections WHERE project_id = $1', [id])
|
query('SELECT * FROM gpi.inspections WHERE project_id = $1', [id])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -72,8 +73,29 @@ export const getProjectById = async (id: string, organizationId?: string, isGlob
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateProject = async (id: string, data: Partial<ProjectData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const updateProject = async (id: string, data: Partial<ProjectData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
// Basic update logic
|
const fields: string[] = [];
|
||||||
const res = await query('UPDATE gpi.projects SET name = COALESCE($1, name), client = COALESCE($2, client), updated_at = NOW() WHERE id = $3 RETURNING *', [data.name, data.client, id]);
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
const updatableFields = ['name', 'client', 'startDate', 'endDate', 'technician', 'environment', 'weightKg', 'status'];
|
||||||
|
|
||||||
|
for (const key of updatableFields) {
|
||||||
|
if ((data as any)[key] !== undefined) {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${paramIdx++}`);
|
||||||
|
params.push((data as any)[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
|
||||||
|
const sql = `UPDATE gpi.projects SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1 RETURNING *`;
|
||||||
|
const res = await query(sql, params);
|
||||||
|
return res.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const archiveProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
|
const res = await query("UPDATE gpi.projects SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *", [id]);
|
||||||
return res.rows[0];
|
return res.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
163
src/server/services/stockItemService.ts
Normal file
163
src/server/services/stockItemService.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
|
export const createStockItem = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.stock_items (
|
||||||
|
organization_id, created_by, data_sheet_id, rr_number, batch_number,
|
||||||
|
color, invoice_number, received_by, quantity, unit, min_stock,
|
||||||
|
expiration_date, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.createdBy, data.dataSheetId, data.rrNumber, data.batchNumber,
|
||||||
|
data.color, data.invoiceNumber, data.receivedBy, data.quantity, data.unit, data.minStock,
|
||||||
|
data.expirationDate, data.notes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createStockMovement = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.stock_movements (
|
||||||
|
organization_id, created_by, stock_item_id, movement_number, type,
|
||||||
|
quantity, date, responsible, reason, requester, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.createdBy, data.stockItemId, data.movementNumber, data.type,
|
||||||
|
data.quantity, data.date || new Date(), data.responsible, data.reason, data.requester, data.notes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockItems = async (organizationId: string, dataSheetId?: string) => {
|
||||||
|
let sql = `
|
||||||
|
SELECT si.*, ds.name as "dataSheetName", ds.manufacturer as "dataSheetManufacturer"
|
||||||
|
FROM gpi.stock_items si
|
||||||
|
LEFT JOIN gpi.technical_data_sheets ds ON si.data_sheet_id = ds.id
|
||||||
|
WHERE si.organization_id = $1`;
|
||||||
|
const params: any[] = [organizationId];
|
||||||
|
|
||||||
|
if (dataSheetId) {
|
||||||
|
sql += ' AND si.data_sheet_id = $2';
|
||||||
|
params.push(dataSheetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY si.expiration_date ASC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockItemById = async (id: string, organizationId: string) => {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT si.*, ds.name as "dataSheetName", ds.manufacturer as "dataSheetManufacturer"
|
||||||
|
FROM gpi.stock_items si
|
||||||
|
LEFT JOIN gpi.technical_data_sheets ds ON si.data_sheet_id = ds.id
|
||||||
|
WHERE si.id = $1 AND si.organization_id = $2`,
|
||||||
|
[id, organizationId]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateStockItem = async (id: string, organizationId: string, data: any) => {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id, organizationId];
|
||||||
|
let idx = 3;
|
||||||
|
|
||||||
|
for (const key in data) {
|
||||||
|
if (data[idx] !== undefined && key !== 'id' && key !== 'organizationId') {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${idx++}`);
|
||||||
|
params.push(data[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual loop just in case
|
||||||
|
const updatable = ['notes', 'expirationDate', 'minStock', 'invoiceNumber', 'receivedBy', 'batchNumber', 'rrNumber', 'color'];
|
||||||
|
const finalFields = [];
|
||||||
|
const finalParams = [id, organizationId];
|
||||||
|
let pIdx = 3;
|
||||||
|
|
||||||
|
for (const key of updatable) {
|
||||||
|
if (data[key] !== undefined) {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`);
|
||||||
|
finalFields.push(`${dbKey} = $${pIdx++}`);
|
||||||
|
finalParams.push(data[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalFields.length === 0) return null;
|
||||||
|
|
||||||
|
const sql = `UPDATE gpi.stock_items SET ${finalFields.join(', ')}, updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING *`;
|
||||||
|
const res = await query(sql, finalParams);
|
||||||
|
return res.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateStockItemQuantity = async (id: string, newQuantity: number) => {
|
||||||
|
await query('UPDATE gpi.stock_items SET quantity = $1, updated_at = NOW() WHERE id = $2', [newQuantity, id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteStockItem = async (id: string, organizationId: string) => {
|
||||||
|
await query('DELETE FROM gpi.stock_movements WHERE stock_item_id = $1', [id]);
|
||||||
|
await query('DELETE FROM gpi.stock_audit_logs WHERE stock_item_id = $1', [id]);
|
||||||
|
const res = await query('DELETE FROM gpi.stock_items WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
return res.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockMovements = async (stockItemId: string, organizationId: string) => {
|
||||||
|
const result = await query(
|
||||||
|
'SELECT * FROM gpi.stock_movements WHERE stock_item_id = $1 AND organization_id = $2 ORDER BY date DESC',
|
||||||
|
[stockItemId, organizationId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLatestMovementNumber = async (stockItemId: string) => {
|
||||||
|
const result = await query(
|
||||||
|
'SELECT MAX(movement_number) as max FROM gpi.stock_movements WHERE stock_item_id = $1',
|
||||||
|
[stockItemId]
|
||||||
|
);
|
||||||
|
return result.rows[0].max || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockMovementById = async (id: string, organizationId: string) => {
|
||||||
|
const res = await query('SELECT * FROM gpi.stock_movements WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
return res.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateStockMovement = async (id: string, organizationId: string, data: any) => {
|
||||||
|
const res = await query(
|
||||||
|
'UPDATE gpi.stock_movements SET quantity = COALESCE($1, quantity), date = COALESCE($2, date), notes = COALESCE($3, notes), updated_at = NOW() WHERE id = $4 AND organization_id = $5 RETURNING *',
|
||||||
|
[data.quantity, data.date, data.notes, id, organizationId]
|
||||||
|
);
|
||||||
|
return res.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteStockMovement = async (id: string, organizationId: string) => {
|
||||||
|
const res = await query('DELETE FROM gpi.stock_movements WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
return res.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAuditLog = async (data: any) => {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO gpi.stock_audit_logs (
|
||||||
|
organization_id, stock_item_id, movement_id, movement_number,
|
||||||
|
user_id, user_name, action, details, old_values, new_values
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.stockItemId, data.movementId, data.movementNumber,
|
||||||
|
data.userId, data.userName, data.action, data.details, data.oldValues, data.newValues
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockAuditLogs = async (stockItemId: string, organizationId: string) => {
|
||||||
|
const res = await query(
|
||||||
|
'SELECT * FROM gpi.stock_audit_logs WHERE stock_item_id = $1 AND organization_id = $2 ORDER BY timestamp DESC',
|
||||||
|
[stockItemId, organizationId]
|
||||||
|
);
|
||||||
|
return res.rows;
|
||||||
|
};
|
||||||
145
src/server/services/userService.ts
Normal file
145
src/server/services/userService.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
|
export const syncUser = async (data: { externalId: string, email: string, name: string, organizationId?: string, clerkRole?: string }) => {
|
||||||
|
const { externalId, email, name, organizationId, clerkRole } = data;
|
||||||
|
|
||||||
|
let result = await query(
|
||||||
|
`INSERT INTO gpi.users (clerk_id, email, name, role, updated_at)
|
||||||
|
VALUES ($1, $2, $3, 'guest', NOW())
|
||||||
|
ON CONFLICT (clerk_id) DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name, updated_at = NOW()
|
||||||
|
RETURNING *`,
|
||||||
|
[externalId, email, name]
|
||||||
|
);
|
||||||
|
|
||||||
|
let user = result.rows[0];
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
let appRole = 'guest';
|
||||||
|
if (clerkRole === 'org:admin') appRole = 'admin';
|
||||||
|
else if (clerkRole === 'org:member') appRole = 'user';
|
||||||
|
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length > 0) {
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO gpi.user_organizations (user_id, organization_id, role, updated_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
ON CONFLICT (user_id, organization_id) DO NOTHING`,
|
||||||
|
[user.id, orgUuid, appRole]
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberResult = await query(
|
||||||
|
'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2',
|
||||||
|
[user.id, orgUuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
organizationRole: memberResult.rows[0].role,
|
||||||
|
organizationBanned: memberResult.rows[0].is_banned
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentUser = async (externalId: string, organizationId?: string) => {
|
||||||
|
const userResult = await query('SELECT * FROM gpi.users WHERE clerk_id = $1 OR logto_id = $1', [externalId]);
|
||||||
|
if (userResult.rows.length === 0) return null;
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length > 0) {
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
const memberResult = await query(
|
||||||
|
'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2',
|
||||||
|
[user.id, orgUuid]
|
||||||
|
);
|
||||||
|
if (memberResult.rows.length > 0) {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
role: memberResult.rows[0].role,
|
||||||
|
isBanned: memberResult.rows[0].is_banned,
|
||||||
|
organizationId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllUsersInOrg = async (organizationId: string) => {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length === 0) return [];
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
const results = await query(
|
||||||
|
`SELECT u.*, uo.role as organization_role, uo.is_banned as organization_banned, uo.id as member_id
|
||||||
|
FROM gpi.users u
|
||||||
|
JOIN gpi.user_organizations uo ON u.id = uo.user_id
|
||||||
|
WHERE uo.organization_id = $1
|
||||||
|
ORDER BY uo.created_at DESC`,
|
||||||
|
[orgUuid]
|
||||||
|
);
|
||||||
|
return results.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserRole = async (userIdOrMemberId: string, organizationId: string, role: string) => {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length === 0) return null;
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
// Try update by member_id or user_id
|
||||||
|
const result = await query(
|
||||||
|
'UPDATE gpi.user_organizations SET role = $1, updated_at = NOW() WHERE (id = $2 OR user_id = $2) AND organization_id = $3 RETURNING *',
|
||||||
|
[role, userIdOrMemberId, orgUuid]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleBanUser = async (userIdOrMemberId: string, organizationId: string, isBanned: boolean) => {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length === 0) return null;
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
'UPDATE gpi.user_organizations SET is_banned = $1, updated_at = NOW() WHERE (id = $2 OR user_id = $2) AND organization_id = $3 RETURNING *',
|
||||||
|
[isBanned, userIdOrMemberId, orgUuid]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const heartbeat = async (userId: string) => {
|
||||||
|
await query('UPDATE gpi.users SET last_seen_at = NOW() WHERE id = $1', [userId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveUsers = async (organizationId: string, excludeUserId?: string) => {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length === 0) return [];
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
|
||||||
|
const result = await query(
|
||||||
|
`SELECT u.id, u.name, u.email, u.last_seen_at, u.clerk_id as "externalId"
|
||||||
|
FROM gpi.users u
|
||||||
|
JOIN gpi.user_organizations uo ON u.id = uo.user_id
|
||||||
|
WHERE uo.organization_id = $1 AND u.last_seen_at >= $2 AND u.id != $3`,
|
||||||
|
[orgUuid, twoMinutesAgo, excludeUserId || '00000000-0000-0000-0000-000000000000']
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserFromOrg = async (userIdOrMemberId: string, organizationId: string) => {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length === 0) return false;
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
'DELETE FROM gpi.user_organizations WHERE (id = $1 OR user_id = $1) AND organization_id = $2',
|
||||||
|
[userIdOrMemberId, orgUuid]
|
||||||
|
);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user