COMMIT 16MAR
This commit is contained in:
137
api/app.ts
137
api/app.ts
@@ -1,137 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
import projectRoutes from '../src/server/routes/projectRoutes.js';
|
|
||||||
import partRoutes from '../src/server/routes/partRoutes.js';
|
|
||||||
import paintingSchemeRoutes from '../src/server/routes/paintingSchemeRoutes.js';
|
|
||||||
import applicationRecordRoutes from '../src/server/routes/applicationRecordRoutes.js';
|
|
||||||
import inspectionRoutes from '../src/server/routes/inspectionRoutes.js';
|
|
||||||
import analysisRoutes from '../src/server/routes/analysisRoutes.js';
|
|
||||||
import dataSheetRoutes from '../src/server/routes/dataSheetRoutes.js';
|
|
||||||
import yieldStudyRoutes from '../src/server/routes/yieldStudyRoutes.js';
|
|
||||||
import userRoutes from '../src/server/routes/userRoutes.js';
|
|
||||||
import systemSettingsRoutes from '../src/server/routes/systemSettingsRoutes.js';
|
|
||||||
import geometryTypeRoutes from '../src/server/routes/geometryTypeRoutes.js';
|
|
||||||
import stockRoutes from '../src/server/routes/stockRoutes.js';
|
|
||||||
import authRoutes from '../src/server/routes/authRoutes.js';
|
|
||||||
import notificationRoutes from '../src/server/routes/notificationRoutes.js';
|
|
||||||
import instrumentRoutes from '../src/server/routes/instrumentRoutes.js';
|
|
||||||
import { extractUser } from '../src/server/middleware/roleMiddleware.js';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
app.use(cors({
|
|
||||||
origin: '*', // Be more specific in production
|
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
||||||
allowedHeaders: ['Content-Type', 'Authorization', 'x-organization-id', 'x-organization-name']
|
|
||||||
}));
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Global Middleware
|
|
||||||
import { authMiddleware } from '../src/server/middleware/auth.js';
|
|
||||||
app.use(authMiddleware);
|
|
||||||
app.use(extractUser);
|
|
||||||
|
|
||||||
// Static Uploads
|
|
||||||
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
|
||||||
|
|
||||||
import pool from '../src/server/config/postgres.js';
|
|
||||||
|
|
||||||
// ... (existing routes)
|
|
||||||
|
|
||||||
app.get('/api/admin/migrate-to-gpi', async (req, res) => {
|
|
||||||
const TABLES = [
|
|
||||||
"organizations",
|
|
||||||
"users",
|
|
||||||
"user_organizations",
|
|
||||||
"projects",
|
|
||||||
"parts",
|
|
||||||
"technical_data_sheets",
|
|
||||||
"painting_schemes",
|
|
||||||
"inspections",
|
|
||||||
"instruments",
|
|
||||||
"stock_items",
|
|
||||||
"stock_movements",
|
|
||||||
"application_records",
|
|
||||||
"application_record_items"
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = await pool.connect();
|
|
||||||
await client.query("CREATE SCHEMA IF NOT EXISTS gpi;");
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
for (const table of TABLES) {
|
|
||||||
try {
|
|
||||||
// Try to move from public to gpi
|
|
||||||
await client.query(`ALTER TABLE public.${table} SET SCHEMA gpi;`);
|
|
||||||
results.push({ table, action: 'moved', status: 'success' });
|
|
||||||
} catch (err: any) {
|
|
||||||
// If it fails, maybe it's already in gpi?
|
|
||||||
try {
|
|
||||||
await client.query(`SELECT 1 FROM gpi.${table} LIMIT 1`);
|
|
||||||
results.push({ table, action: 'check', status: 'already_in_gpi' });
|
|
||||||
} catch (checkErr: any) {
|
|
||||||
results.push({ table, action: 'move', status: 'failed', error: err.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure new tables exist
|
|
||||||
await client.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS gpi.files (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
content_type TEXT NOT NULL,
|
|
||||||
data BYTEA NOT NULL,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
results.push({ table: 'files', action: 'create', status: 'success_or_exists' });
|
|
||||||
|
|
||||||
await client.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
organization_id UUID,
|
|
||||||
stock_item_id UUID,
|
|
||||||
movement_id UUID,
|
|
||||||
movement_number INTEGER,
|
|
||||||
user_id TEXT,
|
|
||||||
user_name TEXT,
|
|
||||||
action TEXT,
|
|
||||||
details TEXT,
|
|
||||||
old_values JSONB,
|
|
||||||
new_values JSONB,
|
|
||||||
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
results.push({ table: 'stock_audit_logs', action: 'create', status: 'success_or_exists' });
|
|
||||||
client.release();
|
|
||||||
res.json({ message: "Migration check completed", results });
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/users', userRoutes);
|
|
||||||
app.use('/api/projects', projectRoutes);
|
|
||||||
app.use('/api/parts', partRoutes);
|
|
||||||
app.use('/api/painting-schemes', paintingSchemeRoutes);
|
|
||||||
app.use('/api/application-records', applicationRecordRoutes);
|
|
||||||
app.use('/api/inspections', inspectionRoutes);
|
|
||||||
app.use('/api', analysisRoutes);
|
|
||||||
app.use('/api/datasheets', dataSheetRoutes);
|
|
||||||
app.use('/api/yield-studies', yieldStudyRoutes);
|
|
||||||
app.use('/api/system-settings', systemSettingsRoutes);
|
|
||||||
app.use('/api/geometry-types', geometryTypeRoutes);
|
|
||||||
app.use('/api/stock', stockRoutes);
|
|
||||||
app.use('/api/notifications', notificationRoutes);
|
|
||||||
app.use('/api/instruments', instrumentRoutes);
|
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', timestamp: new Date() });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
||||||
import mongoose from 'mongoose';
|
|
||||||
|
|
||||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
||||||
try {
|
|
||||||
const uri = process.env.MONGODB_URI;
|
|
||||||
if (!uri) throw new Error('MONGODB_URI is missing from Vercel settings');
|
|
||||||
|
|
||||||
await mongoose.connect(uri);
|
|
||||||
const state = mongoose.connection.readyState;
|
|
||||||
await mongoose.disconnect();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'MongoDB Connection verified!',
|
|
||||||
state: state === 1 ? 'Connected' : state
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
const stack = error instanceof Error ? error.stack : undefined;
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: message,
|
|
||||||
stack: stack
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
api/index.ts
28
api/index.ts
@@ -1,28 +0,0 @@
|
|||||||
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
||||||
import app from './app.js';
|
|
||||||
import mongoose from 'mongoose';
|
|
||||||
|
|
||||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
||||||
try {
|
|
||||||
console.log('--- API CALL:', req.url);
|
|
||||||
|
|
||||||
// Inline connection to avoid external file dependency issues during boot
|
|
||||||
if (mongoose.connection.readyState !== 1) {
|
|
||||||
const uri = process.env.MONGODB_URI;
|
|
||||||
if (!uri) throw new Error('MONGODB_URI environment variable is missing');
|
|
||||||
await mongoose.connect(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the localized app.js
|
|
||||||
return app(req, res);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error('SERVERLESS BOOT ERROR:', error);
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
return res.status(500).json({
|
|
||||||
error: 'Serverless Boot Error',
|
|
||||||
message: message,
|
|
||||||
path: req.url,
|
|
||||||
suggestion: 'Check Vercel Logs for module resolution errors'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10
api/ping.ts
10
api/ping.ts
@@ -1,10 +0,0 @@
|
|||||||
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
||||||
|
|
||||||
export default function handler(req: VercelRequest, res: VercelResponse) {
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
message: 'Vercel API is alive',
|
|
||||||
time: new Date().toISOString(),
|
|
||||||
env_check: process.env.MONGODB_URI ? 'URI present' : 'URI MISSING'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
console.log('Loading Netlify Function...');
|
|
||||||
import serverless from 'serverless-http';
|
|
||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
import mongoose from 'mongoose';
|
|
||||||
|
|
||||||
// Import local database connection that sets up GridFS/Bucket
|
|
||||||
import { connectDB } from '../../src/server/config/database.js';
|
|
||||||
|
|
||||||
// Static imports for routes to ensure esbuild bundles them correctly
|
|
||||||
import projectRoutes from '../../src/server/routes/projectRoutes.js';
|
|
||||||
import partRoutes from '../../src/server/routes/partRoutes.js';
|
|
||||||
import paintingSchemeRoutes from '../../src/server/routes/paintingSchemeRoutes.js';
|
|
||||||
import applicationRecordRoutes from '../../src/server/routes/applicationRecordRoutes.js';
|
|
||||||
import inspectionRoutes from '../../src/server/routes/inspectionRoutes.js';
|
|
||||||
import analysisRoutes from '../../src/server/routes/analysisRoutes.js';
|
|
||||||
import dataSheetRoutes from '../../src/server/routes/dataSheetRoutes.js';
|
|
||||||
import yieldStudyRoutes from '../../src/server/routes/yieldStudyRoutes.js';
|
|
||||||
import userRoutes from '../../src/server/routes/userRoutes.js';
|
|
||||||
import uploadsRoutes from '../../src/server/routes/uploadsRoutes.js';
|
|
||||||
import systemSettingsRoutes from '../../src/server/routes/systemSettingsRoutes.js';
|
|
||||||
import geometryTypeRoutes from '../../src/server/routes/geometryTypeRoutes.js';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(cors({
|
|
||||||
origin: '*',
|
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
||||||
allowedHeaders: ['Content-Type', 'Authorization', 'x-clerk-user-id', 'x-organization-id']
|
|
||||||
}));
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
// We register them immediately since we use serverless-http's cold start handling
|
|
||||||
app.use('/api/users', userRoutes);
|
|
||||||
app.use('/api/projects', projectRoutes);
|
|
||||||
app.use('/api/parts', partRoutes);
|
|
||||||
app.use('/api/painting-schemes', paintingSchemeRoutes);
|
|
||||||
app.use('/api/application-records', applicationRecordRoutes);
|
|
||||||
app.use('/api/inspections', inspectionRoutes);
|
|
||||||
app.use('/api', analysisRoutes);
|
|
||||||
app.use('/api/datasheets', dataSheetRoutes);
|
|
||||||
app.use('/api/yield-studies', yieldStudyRoutes);
|
|
||||||
app.use('/api/system-settings', systemSettingsRoutes);
|
|
||||||
app.use('/api/geometry-types', geometryTypeRoutes);
|
|
||||||
|
|
||||||
// Serve uploads (from /tmp in serverless or local dir)
|
|
||||||
app.use('/uploads', uploadsRoutes);
|
|
||||||
|
|
||||||
// Simple test endpoint
|
|
||||||
app.get('/api/health', (req, res) => {
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
timestamp: new Date(),
|
|
||||||
mongoState: mongoose.connection.readyState
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const handler = async (event: any, context: any) => {
|
|
||||||
context.callbackWaitsForEmptyEventLoop = false;
|
|
||||||
|
|
||||||
// Ensure DB is connected before processing request
|
|
||||||
try {
|
|
||||||
await connectDB();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('DB Connection Failed:', e);
|
|
||||||
// We let it proceed, maybe it's a health check or will fail gracefully later
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpHandler = serverless(app);
|
|
||||||
return await httpHandler(event, context);
|
|
||||||
};
|
|
||||||
@@ -14,9 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/mongoose": "^5.11.96",
|
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vercel/speed-insights": "^1.3.1",
|
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -28,8 +26,6 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mongodb": "^7.0.0",
|
|
||||||
"mongoose": "^9.1.5",
|
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
@@ -40,7 +36,6 @@
|
|||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"search-web": "^1.0.3",
|
"search-web": "^1.0.3",
|
||||||
"serverless-http": "^4.0.0",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
@@ -56,7 +51,6 @@
|
|||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.18.0",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vercel/node": "^5.5.28",
|
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
|
|||||||
@@ -51,34 +51,6 @@ if (!fs.existsSync(uploadsPath)) {
|
|||||||
|
|
||||||
app.use('/uploads', express.static(uploadsPath));
|
app.use('/uploads', express.static(uploadsPath));
|
||||||
|
|
||||||
// Migration Route (Temporary)
|
|
||||||
import pool from './config/postgres.js';
|
|
||||||
app.get('/api/admin/migrate-schema', async (req, res) => {
|
|
||||||
const TABLES = [
|
|
||||||
"organizations", "users", "user_organizations", "projects",
|
|
||||||
"parts", "painting_schemes", "inspections", "instruments",
|
|
||||||
"stock_items", "stock_movements"
|
|
||||||
];
|
|
||||||
try {
|
|
||||||
const client = await pool.connect();
|
|
||||||
await client.query("CREATE SCHEMA IF NOT EXISTS gpi;");
|
|
||||||
const results = [];
|
|
||||||
for (const table of TABLES) {
|
|
||||||
try {
|
|
||||||
// Try to move from public to gpi
|
|
||||||
await client.query(`ALTER TABLE public."${table}" SET SCHEMA gpi;`);
|
|
||||||
results.push({ table, status: 'moved to gpi' });
|
|
||||||
} catch (err: any) {
|
|
||||||
results.push({ table, status: 'error', error: err.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client.release();
|
|
||||||
res.json({ message: "Schema migration finished", results });
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import User, { IUser } from '../models/User.js';
|
import { query } from '../config/database.js';
|
||||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod';
|
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod';
|
||||||
@@ -16,8 +15,8 @@ export const register = async (req: Request, res: Response): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await User.findOne({ email });
|
const existingRes = await query('SELECT id FROM gpi.users WHERE email = $1', [email]);
|
||||||
if (existingUser) {
|
if (existingRes.rows.length > 0) {
|
||||||
res.status(400).json({ error: 'Email já cadastrado' });
|
res.status(400).json({ error: 'Email já cadastrado' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -25,22 +24,26 @@ export const register = async (req: Request, res: Response): Promise<void> => {
|
|||||||
const salt = await bcrypt.genSalt(10);
|
const salt = await bcrypt.genSalt(10);
|
||||||
const passwordHash = await bcrypt.hash(password, salt);
|
const passwordHash = await bcrypt.hash(password, salt);
|
||||||
|
|
||||||
// Gere um externalId falso apenas para manter retrocompatibilidade no banco
|
// Gere um fakeAuthId para manter compatibilidade com sistemas que esperam um externalId
|
||||||
const fakeAuthId = `user_${uuidv4().replace(/-/g, '')}`;
|
const fakeAuthId = `user_${uuidv4().replace(/-/g, '')}`;
|
||||||
|
|
||||||
const newUser = new User({
|
const insertRes = await query(
|
||||||
name,
|
`INSERT INTO gpi.users (name, email, clerk_id, role, is_banned, updated_at)
|
||||||
email,
|
VALUES ($1, $2, $3, 'user', false, NOW()) RETURNING *`,
|
||||||
passwordHash,
|
[name, email, fakeAuthId]
|
||||||
externalId: fakeAuthId,
|
);
|
||||||
role: 'member',
|
|
||||||
isBanned: false
|
|
||||||
});
|
|
||||||
|
|
||||||
await newUser.save();
|
const newUser = insertRes.rows[0];
|
||||||
|
|
||||||
|
// Se houver uma coluna de password_hash no banco (precisamos garantir que exista)
|
||||||
|
try {
|
||||||
|
await query('UPDATE gpi.users SET password_hash = $1 WHERE id = $2', [passwordHash, newUser.id]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not update password_hash, check if column exists', e);
|
||||||
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ userId: newUser._id.toString(), externalId: newUser.externalId, role: newUser.role, organizationId: newUser.organizationId },
|
{ userId: newUser.id, externalId: newUser.clerk_id, role: newUser.role },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '7d' }
|
{ expiresIn: '7d' }
|
||||||
);
|
);
|
||||||
@@ -48,7 +51,7 @@ export const register = async (req: Request, res: Response): Promise<void> => {
|
|||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: 'Usuário criado com sucesso',
|
message: 'Usuário criado com sucesso',
|
||||||
token,
|
token,
|
||||||
user: { id: newUser._id, name: newUser.name, email: newUser.email, role: newUser.role, externalId: newUser.externalId }
|
user: { id: newUser.id, name: newUser.name, email: newUser.email, role: newUser.role, externalId: newUser.clerk_id }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Register Error:', error);
|
console.error('Register Error:', error);
|
||||||
@@ -65,25 +68,29 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findOne({ email });
|
const userRes = await query('SELECT * FROM gpi.users WHERE email = $1', [email]);
|
||||||
|
const user = userRes.rows[0];
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(400).json({ error: 'Usuário não encontrado' });
|
res.status(400).json({ error: 'Usuário não encontrado' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.passwordHash) {
|
// Recuperar password_hash de algum lugar se não estiver no select principal (alguns schemas escondem)
|
||||||
res.status(400).json({ error: 'Usuário do sistema antigo. Por favor, solicite a redefinição de senha ou recrie sua conta se possível.' });
|
// No nosso caso a query SELECT * deve trazer se existir.
|
||||||
|
if (!user.password_hash) {
|
||||||
|
res.status(400).json({ error: 'Usuário sem senha definida ou método de login externo.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMatch = await bcrypt.compare(password, user.passwordHash);
|
const isMatch = await bcrypt.compare(password, user.password_hash);
|
||||||
if (!isMatch) {
|
if (!isMatch) {
|
||||||
res.status(400).json({ error: 'Credenciais inválidas' });
|
res.status(400).json({ error: 'Credenciais inválidas' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ userId: user._id.toString(), externalId: user.externalId, role: user.role, organizationId: user.organizationId },
|
{ userId: user.id, externalId: user.clerk_id || user.logto_id, role: user.role },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '7d' }
|
{ expiresIn: '7d' }
|
||||||
);
|
);
|
||||||
@@ -92,12 +99,11 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
|||||||
message: 'Login realizado com sucesso',
|
message: 'Login realizado com sucesso',
|
||||||
token,
|
token,
|
||||||
user: {
|
user: {
|
||||||
id: user._id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
externalId: user.externalId,
|
externalId: user.clerk_id || user.logto_id
|
||||||
organizationId: user.organizationId
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,20 +114,12 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
|||||||
|
|
||||||
export const getMe = async (req: Request, res: Response): Promise<void> => {
|
export const getMe = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// O usuário é extraído pelo middleware extractUser e colocado em req.appUser
|
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
res.status(401).json({ error: 'Não autorizado' });
|
res.status(401).json({ error: 'Não autorizado' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json(req.appUser);
|
||||||
id: req.appUser._id,
|
|
||||||
name: req.appUser.name,
|
|
||||||
email: req.appUser.email,
|
|
||||||
role: req.appUser.role,
|
|
||||||
externalId: req.appUser.externalId,
|
|
||||||
organizationId: req.appUser.organizationId
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GetMe Error:', error);
|
console.error('GetMe Error:', error);
|
||||||
res.status(500).json({ error: 'Erro no servidor' });
|
res.status(500).json({ error: 'Erro no servidor' });
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import GeometryType from '../models/GeometryType.js';
|
import { query } from '../config/database.js';
|
||||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
|
||||||
|
|
||||||
interface AuthRequest extends Request {
|
|
||||||
appUser?: IAppUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default geometry types to seed if none exist
|
// Default geometry types to seed if none exist
|
||||||
const DEFAULT_TYPES = [
|
const DEFAULT_TYPES = [
|
||||||
@@ -22,139 +17,110 @@ const DEFAULT_TYPES = [
|
|||||||
{ name: 'Peças diversas (outras)', efficiencyLoss: 20 }
|
{ name: 'Peças diversas (outras)', efficiencyLoss: 20 }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getAllnames = async (req: AuthRequest, res: Response) => {
|
export const getAllnames = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||||
console.log(`[GeometryType] Fetching for org: ${organizationId}, globalAdmin: ${isGlobalAdmin}`);
|
|
||||||
|
|
||||||
if (!organizationId && !isGlobalAdmin) {
|
if (!organizationId && !isGlobalAdmin) {
|
||||||
return res.status(400).json({ error: 'Organization ID missing' });
|
return res.status(400).json({ error: 'Organization ID missing' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for org-specific types OR orphan types (legacy)
|
let sql = 'SELECT * FROM gpi.geometry_types';
|
||||||
const query = isGlobalAdmin
|
const params: any[] = [];
|
||||||
? {}
|
|
||||||
: { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
|
|
||||||
|
|
||||||
let types = await GeometryType.find(query).sort({ name: 1 });
|
if (!isGlobalAdmin) {
|
||||||
|
sql += ' WHERE organization_id = $1';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-seed if empty AND we HAVE an organization (don't seed for global view)
|
sql += ' ORDER BY name ASC';
|
||||||
if (types.length === 0 && organizationId) {
|
const result = await query(sql, params);
|
||||||
console.log(`[GeometryType] No types found. Seeding defaults...`);
|
let types = result.rows;
|
||||||
try {
|
|
||||||
const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId }));
|
// Auto-seed if empty for org
|
||||||
types = await GeometryType.insertMany(seedData) as any;
|
if (types.length === 0 && organizationId && !isGlobalAdmin) {
|
||||||
console.log(`[GeometryType] Seeded ${types.length} types successfully.`);
|
for (const t of DEFAULT_TYPES) {
|
||||||
} catch (seedError) {
|
await query(
|
||||||
console.error('[GeometryType] Seeding failed:', seedError);
|
'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
|
||||||
return res.json([]);
|
[organizationId, t.name, t.efficiencyLoss]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
const reFetch = await query('SELECT * FROM gpi.geometry_types WHERE organization_id = $1 ORDER BY name ASC', [organizationId]);
|
||||||
|
types = reFetch.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(types);
|
res.json(types);
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
console.error('[GeometryType] Error in getAllnames:', error);
|
console.error('[GeometryType] Error:', error);
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
res.status(500).json({ error: error.message });
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const restoreDefaults = async (req: AuthRequest, res: Response) => {
|
export const restoreDefaults = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
if (!organizationId) {
|
if (!organizationId) return res.status(400).json({ error: 'Organization ID missing' });
|
||||||
return res.status(400).json({ error: 'Organization ID missing' });
|
|
||||||
|
await query('DELETE FROM gpi.geometry_types WHERE organization_id = $1', [organizationId]);
|
||||||
|
|
||||||
|
for (const t of DEFAULT_TYPES) {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3)',
|
||||||
|
[organizationId, t.name, t.efficiencyLoss]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all existing types for this org
|
const result = await query('SELECT * FROM gpi.geometry_types WHERE organization_id = $1 ORDER BY name ASC', [organizationId]);
|
||||||
await GeometryType.deleteMany({ organizationId });
|
res.json(result.rows);
|
||||||
|
} catch (error: any) {
|
||||||
// Insert defaults
|
res.status(500).json({ error: error.message });
|
||||||
const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId }));
|
|
||||||
const types = await GeometryType.insertMany(seedData);
|
|
||||||
|
|
||||||
res.json(types);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error('[GeometryType] Error in restoreDefaults:', error);
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createType = async (req: AuthRequest, res: Response) => {
|
export const createType = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const { name, efficiencyLoss } = req.body;
|
const { name, efficiencyLoss } = req.body;
|
||||||
|
|
||||||
if (!name) {
|
const result = await query(
|
||||||
return res.status(400).json({ error: 'Name is required' });
|
'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3) RETURNING *',
|
||||||
}
|
[organizationId, name, Number(efficiencyLoss) || 0]
|
||||||
|
);
|
||||||
const newType = new GeometryType({
|
res.status(201).json(result.rows[0]);
|
||||||
name,
|
} catch (error: any) {
|
||||||
efficiencyLoss: Number(efficiencyLoss) || 0,
|
if (error.code === '23505') return res.status(409).json({ error: 'Already exists' });
|
||||||
organizationId
|
res.status(500).json({ error: error.message });
|
||||||
});
|
|
||||||
|
|
||||||
const saved = await newType.save();
|
|
||||||
res.status(201).json(saved);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
if (message.includes('E11000')) {
|
|
||||||
return res.status(409).json({ error: 'A geometry type with this name already exists' });
|
|
||||||
}
|
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateType = async (req: AuthRequest, res: Response) => {
|
export const updateType = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
|
||||||
const { name, efficiencyLoss } = req.body;
|
const { name, efficiencyLoss } = req.body;
|
||||||
|
|
||||||
const query = isGlobalAdmin
|
const result = await query(
|
||||||
? { _id: id }
|
'UPDATE gpi.geometry_types SET name = $1, efficiency_loss = $2, updated_at = NOW() WHERE id = $3 AND organization_id = $4 RETURNING *',
|
||||||
: { _id: id, organizationId };
|
[name, Number(efficiencyLoss), id, organizationId]
|
||||||
|
|
||||||
const updated = await GeometryType.findOneAndUpdate(
|
|
||||||
query,
|
|
||||||
{ name, efficiencyLoss: Number(efficiencyLoss) },
|
|
||||||
{ new: true }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!updated) {
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
return res.status(404).json({ error: 'Record not found' });
|
res.json(result.rows[0]);
|
||||||
}
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
res.json(updated);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteType = async (req: AuthRequest, res: Response) => {
|
export const deleteType = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
|
||||||
|
|
||||||
const query = isGlobalAdmin
|
|
||||||
? { _id: id }
|
|
||||||
: { _id: id, organizationId };
|
|
||||||
|
|
||||||
const deleted = await GeometryType.findOneAndDelete(query);
|
|
||||||
|
|
||||||
if (!deleted) {
|
|
||||||
return res.status(404).json({ error: 'Record not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const result = await query('DELETE FROM gpi.geometry_types WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
res.status(500).json({ error: error.message });
|
||||||
res.status(500).json({ error: message });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import Message from '../models/Message.js';
|
import { query } from '../config/database.js';
|
||||||
import OrganizationMember from '../models/OrganizationMember.js';
|
|
||||||
|
|
||||||
// Send a message
|
// Send a message
|
||||||
export const sendMessage = async (req: Request, res: Response) => {
|
export const sendMessage = async (req: Request, res: Response) => {
|
||||||
@@ -21,36 +20,27 @@ export const sendMessage = async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Destinatário e mensagem são obrigatórios.' });
|
return res.status(400).json({ error: 'Destinatário e mensagem são obrigatórios.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.length > 255) {
|
|
||||||
return res.status(400).json({ error: 'Mensagem muito longa (máximo 255 caracteres).' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's already a pending (unread) message from this user to that user
|
// Check if there's already a pending (unread) message from this user to that user
|
||||||
const existingMessage = await Message.findOne({
|
const existingRes = await query(
|
||||||
organizationId,
|
'SELECT * FROM gpi.messages WHERE organization_id = $1 AND from_user_id = $2 AND to_user_id = $3 AND is_read = false',
|
||||||
fromUserId,
|
[organizationId, fromUserId, toUserId]
|
||||||
toUserId,
|
);
|
||||||
isRead: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingMessage) {
|
if (existingRes.rows.length > 0) {
|
||||||
// Update existing message instead of creating a new one
|
const updatedRes = await query(
|
||||||
existingMessage.message = message;
|
'UPDATE gpi.messages SET message = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
|
||||||
existingMessage.updatedAt = new Date();
|
[message, existingRes.rows[0].id]
|
||||||
await existingMessage.save();
|
);
|
||||||
return res.json(existingMessage);
|
return res.json(updatedRes.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new message
|
const insertRes = await query(
|
||||||
const newMessage = new Message({
|
`INSERT INTO gpi.messages (organization_id, from_user_id, to_user_id, message)
|
||||||
organizationId,
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
fromUserId,
|
[organizationId, fromUserId, toUserId, message]
|
||||||
toUserId,
|
);
|
||||||
message,
|
|
||||||
});
|
|
||||||
|
|
||||||
await newMessage.save();
|
res.status(201).json(insertRes.rows[0]);
|
||||||
res.status(201).json(newMessage);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending message:', error);
|
console.error('Error sending message:', error);
|
||||||
res.status(500).json({ error: 'Erro ao enviar mensagem.' });
|
res.status(500).json({ error: 'Erro ao enviar mensagem.' });
|
||||||
@@ -63,34 +53,25 @@ export const getUnreadMessages = async (req: Request, res: Response) => {
|
|||||||
const toUserId = req.appUser?.externalId;
|
const toUserId = req.appUser?.externalId;
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId || !toUserId) {
|
||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Contexto incompleto.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!toUserId) {
|
const sql = `
|
||||||
return res.status(401).json({ error: 'Usuário não autenticado.' });
|
SELECT m.*, u.name as "fromUserName", u.email as "fromUserEmail"
|
||||||
}
|
FROM gpi.messages m
|
||||||
|
LEFT JOIN gpi.users u ON m.from_user_id = u.clerk_id OR m.from_user_id = u.logto_id
|
||||||
|
WHERE m.organization_id = $1 AND m.to_user_id = $2 AND m.is_read = false AND m.is_archived = false AND m.is_deleted_by_recipient = false
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
`;
|
||||||
|
const result = await query(sql, [organizationId, toUserId]);
|
||||||
|
|
||||||
const messages = await Message.find({
|
const messages = result.rows.map(m => ({
|
||||||
organizationId,
|
...m,
|
||||||
toUserId,
|
fromUser: { name: m.fromUserName, email: m.fromUserEmail }
|
||||||
isRead: false,
|
}));
|
||||||
isArchived: false,
|
|
||||||
isDeletedByRecipient: false,
|
|
||||||
}).sort({ createdAt: -1 });
|
|
||||||
|
|
||||||
// Populate sender info
|
res.json(messages);
|
||||||
const messagesWithSender = await Promise.all(
|
|
||||||
messages.map(async (msg) => {
|
|
||||||
const sender = await OrganizationMember.findOne({ userId: msg.fromUserId });
|
|
||||||
return {
|
|
||||||
...msg.toObject(),
|
|
||||||
fromUser: sender ? { name: sender.name, email: sender.email } : null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json(messagesWithSender);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting unread messages:', error);
|
console.error('Error getting unread messages:', error);
|
||||||
res.status(500).json({ error: 'Erro ao buscar mensagens.' });
|
res.status(500).json({ error: 'Erro ao buscar mensagens.' });
|
||||||
@@ -104,29 +85,16 @@ export const markMessageAsRead = async (req: Request, res: Response) => {
|
|||||||
const userId = req.appUser?.externalId;
|
const userId = req.appUser?.externalId;
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
if (!organizationId) {
|
const result = await query(
|
||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
'UPDATE gpi.messages SET is_read = true, read_at = NOW() WHERE id = $1 AND organization_id = $2 AND to_user_id = $3 RETURNING *',
|
||||||
}
|
[id, organizationId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
if (!userId) {
|
if (result.rows.length === 0) {
|
||||||
return res.status(401).json({ error: 'Usuário não autenticado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = await Message.findOne({
|
|
||||||
_id: id,
|
|
||||||
organizationId,
|
|
||||||
toUserId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
message.isRead = true;
|
res.json(result.rows[0]);
|
||||||
message.readAt = new Date();
|
|
||||||
await message.save();
|
|
||||||
|
|
||||||
res.json(message);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error marking message as read:', error);
|
console.error('Error marking message as read:', error);
|
||||||
res.status(500).json({ error: 'Erro ao marcar mensagem como lida.' });
|
res.status(500).json({ error: 'Erro ao marcar mensagem como lida.' });
|
||||||
@@ -139,32 +107,25 @@ export const getMyPendingMessages = async (req: Request, res: Response) => {
|
|||||||
const fromUserId = req.appUser?.externalId;
|
const fromUserId = req.appUser?.externalId;
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId || !fromUserId) {
|
||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Contexto incompleto.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fromUserId) {
|
const sql = `
|
||||||
return res.status(401).json({ error: 'Usuário não autenticado.' });
|
SELECT m.*, u.name as "toUserName", u.email as "toUserEmail"
|
||||||
}
|
FROM gpi.messages m
|
||||||
|
LEFT JOIN gpi.users u ON m.to_user_id = u.clerk_id OR m.to_user_id = u.logto_id
|
||||||
|
WHERE m.organization_id = $1 AND m.from_user_id = $2 AND m.is_read = false
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
`;
|
||||||
|
const result = await query(sql, [organizationId, fromUserId]);
|
||||||
|
|
||||||
const messages = await Message.find({
|
const messages = result.rows.map(m => ({
|
||||||
organizationId,
|
...m,
|
||||||
fromUserId,
|
toUser: { name: m.toUserName, email: m.toUserEmail }
|
||||||
isRead: false,
|
}));
|
||||||
}).sort({ createdAt: -1 });
|
|
||||||
|
|
||||||
// Populate recipient info
|
res.json(messages);
|
||||||
const messagesWithRecipient = await Promise.all(
|
|
||||||
messages.map(async (msg) => {
|
|
||||||
const recipient = await OrganizationMember.findOne({ userId: msg.toUserId });
|
|
||||||
return {
|
|
||||||
...msg.toObject(),
|
|
||||||
toUser: recipient ? { name: recipient.name, email: recipient.email } : null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json(messagesWithRecipient);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting pending messages:', error);
|
console.error('Error getting pending messages:', error);
|
||||||
res.status(500).json({ error: 'Erro ao buscar mensagens pendentes.' });
|
res.status(500).json({ error: 'Erro ao buscar mensagens pendentes.' });
|
||||||
@@ -178,26 +139,15 @@ export const deleteMessage = async (req: Request, res: Response) => {
|
|||||||
const userId = req.appUser?.externalId;
|
const userId = req.appUser?.externalId;
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
if (!organizationId) {
|
const result = await query(
|
||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
'DELETE FROM gpi.messages WHERE id = $1 AND from_user_id = $2 AND organization_id = $3 AND is_read = false',
|
||||||
|
[id, userId, organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ error: 'Mensagem não encontrada ou já lida.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return res.status(401).json({ error: 'Usuário não autenticado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = await Message.findOne({
|
|
||||||
_id: id,
|
|
||||||
organizationId,
|
|
||||||
fromUserId: userId,
|
|
||||||
isRead: false, // Can only delete unread messages
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
return res.status(404).json({ error: 'Mensagem não encontrada ou já foi lida.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await message.deleteOne();
|
|
||||||
res.json({ message: 'Mensagem deletada com sucesso.' });
|
res.json({ message: 'Mensagem deletada com sucesso.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting message:', error);
|
console.error('Error deleting message:', error);
|
||||||
@@ -205,20 +155,21 @@ export const deleteMessage = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Recipient deletes/archives a message
|
// Recipient archives a message
|
||||||
export const archiveMessage = async (req: Request, res: Response) => {
|
export const archiveMessage = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.appUser?.externalId;
|
const userId = req.appUser?.externalId;
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
|
const result = await query(
|
||||||
if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
'UPDATE gpi.messages SET is_archived = true, is_read = true WHERE id = $1 AND to_user_id = $2 AND organization_id = $3 RETURNING *',
|
||||||
|
[id, userId, organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
message.isArchived = true;
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
||||||
message.isRead = true; // Arquivar implica ler
|
|
||||||
await message.save();
|
res.json(result.rows[0]);
|
||||||
res.json(message);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error archiving message:', error);
|
console.error('Error archiving message:', error);
|
||||||
res.status(500).json({ error: 'Erro ao arquivar mensagem.' });
|
res.status(500).json({ error: 'Erro ao arquivar mensagem.' });
|
||||||
@@ -231,11 +182,13 @@ export const recipientDeleteMessage = async (req: Request, res: Response) => {
|
|||||||
const userId = req.appUser?.externalId;
|
const userId = req.appUser?.externalId;
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
|
const result = await query(
|
||||||
if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
'UPDATE gpi.messages SET is_deleted_by_recipient = true WHERE id = $1 AND to_user_id = $2 AND organization_id = $3 RETURNING *',
|
||||||
|
[id, userId, organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Mensagem não encontrada.' });
|
||||||
|
|
||||||
message.isDeletedByRecipient = true;
|
|
||||||
await message.save();
|
|
||||||
res.json({ message: 'Mensagem excluída com sucesso.' });
|
res.json({ message: 'Mensagem excluída com sucesso.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting message:', error);
|
console.error('Error deleting message:', error);
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import SystemSettings from '../models/SystemSettings.js';
|
import { query } from '../config/database.js';
|
||||||
import User from '../models/User.js';
|
|
||||||
import OrganizationMember from '../models/OrganizationMember.js';
|
|
||||||
import Organization from '../models/Organization.js';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
|
||||||
export const getSettings = async (req: Request, res: Response) => {
|
export const getSettings = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
let settings = await SystemSettings.findOne({ settingsId: 'global' });
|
const resSettings = await query('SELECT * FROM gpi.system_settings WHERE settings_id = $1', ['global']);
|
||||||
|
let settings = resSettings.rows[0];
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
// Create default if not exists
|
const insertRes = await query(
|
||||||
settings = await SystemSettings.create({
|
'INSERT INTO gpi.system_settings (settings_id, app_name, app_subtitle) VALUES ($1, $2, $3) RETURNING *',
|
||||||
settingsId: 'global',
|
['global', 'GPI', 'Gestão de Pintura Industrial']
|
||||||
appName: 'GPI',
|
);
|
||||||
appSubtitle: 'Gestão de Pintura Industrial'
|
settings = insertRes.rows[0];
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(settings);
|
res.json(settings);
|
||||||
@@ -31,41 +28,34 @@ export const updateSettings = async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { appName, appSubtitle, appLogoUrl } = req.body;
|
const { appName, appSubtitle, appLogoUrl } = req.body;
|
||||||
|
|
||||||
const settings = await SystemSettings.findOneAndUpdate(
|
const result = await query(
|
||||||
{ settingsId: 'global' },
|
`INSERT INTO gpi.system_settings (settings_id, app_name, app_subtitle, app_logo_url, updated_by, updated_at)
|
||||||
{
|
VALUES ('global', $1, $2, $3, $4, NOW())
|
||||||
appName,
|
ON CONFLICT (settings_id) DO UPDATE SET
|
||||||
appSubtitle,
|
app_name = EXCLUDED.app_name,
|
||||||
appLogoUrl,
|
app_subtitle = EXCLUDED.app_subtitle,
|
||||||
updatedBy: req.appUser?.email
|
app_logo_url = EXCLUDED.app_logo_url,
|
||||||
},
|
updated_by = EXCLUDED.updated_by,
|
||||||
{ new: true, upsert: true } // Create if not exists
|
updated_at = NOW()
|
||||||
|
RETURNING *`,
|
||||||
|
[appName, appSubtitle, appLogoUrl, req.appUser?.email]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`⚙️ System Settings updated by ${req.appUser?.email}`);
|
res.json(result.rows[0]);
|
||||||
res.json(settings);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating system settings:', error);
|
console.error('Error updating system settings:', error);
|
||||||
res.status(500).json({ error: 'Erro ao atualizar configurações do sistema' });
|
res.status(500).json({ error: 'Erro ao atualizar configurações do sistema' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const serveLogo = async (req: Request, res: Response) => {
|
export const serveLogo = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { filename } = req.params as { filename: string };
|
const { filename } = req.params as { filename: string };
|
||||||
|
|
||||||
// Check tmp dir first (Serverless/Netlify uploads)
|
|
||||||
const tmpPath = path.join(os.tmpdir(), 'uploads', filename);
|
|
||||||
// Check local dir (Development)
|
|
||||||
const localPath = path.join(process.cwd(), 'uploads', filename);
|
const localPath = path.join(process.cwd(), 'uploads', filename);
|
||||||
|
|
||||||
if (fs.existsSync(tmpPath)) {
|
if (fs.existsSync(localPath)) {
|
||||||
res.sendFile(tmpPath);
|
|
||||||
} else if (fs.existsSync(localPath)) {
|
|
||||||
res.sendFile(localPath);
|
res.sendFile(localPath);
|
||||||
} else {
|
} else {
|
||||||
console.error(`Logo file not found in tmp or local: ${filename}`);
|
|
||||||
res.status(404).json({ error: 'Imagem não encontrada' });
|
res.status(404).json({ error: 'Imagem não encontrada' });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -79,11 +69,7 @@ export const uploadLogo = async (req: Request, res: Response) => {
|
|||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
|
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the API URL instead of static path
|
|
||||||
// This ensures requests go through /api proxy and we control serving
|
|
||||||
const fileUrl = `/api/system-settings/logo-image/${req.file.filename}`;
|
const fileUrl = `/api/system-settings/logo-image/${req.file.filename}`;
|
||||||
|
|
||||||
res.json({ url: fileUrl });
|
res.json({ url: fileUrl });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading logo:', error);
|
console.error('Error uploading logo:', error);
|
||||||
@@ -91,11 +77,10 @@ export const uploadLogo = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global Admin Functions
|
|
||||||
export const getGlobalUsers = async (req: Request, res: Response) => {
|
export const getGlobalUsers = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const users = await User.find({}).sort({ createdAt: -1 });
|
const resUsers = await query('SELECT * FROM gpi.users ORDER BY created_at DESC');
|
||||||
res.json(users);
|
res.json(resUsers.rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting global users:', error);
|
console.error('Error getting global users:', error);
|
||||||
res.status(500).json({ error: 'Erro ao buscar usuários globais.' });
|
res.status(500).json({ error: 'Erro ao buscar usuários globais.' });
|
||||||
@@ -104,51 +89,20 @@ export const getGlobalUsers = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
export const getGlobalOrganizations = async (req: Request, res: Response) => {
|
export const getGlobalOrganizations = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// Aggregate members to group by org and get full member lists
|
const sql = `
|
||||||
const organizations = await OrganizationMember.aggregate([
|
SELECT
|
||||||
{
|
o.id as _id,
|
||||||
$group: {
|
o.name,
|
||||||
_id: '$organizationId',
|
o.is_banned as "isBanned",
|
||||||
members: {
|
COUNT(uo.user_id) as "memberCount",
|
||||||
$push: {
|
MAX(uo.updated_at) as "lastActive"
|
||||||
name: '$name',
|
FROM gpi.organizations o
|
||||||
email: '$email',
|
LEFT JOIN gpi.user_organizations uo ON o.id = uo.organization_id
|
||||||
role: '$role',
|
GROUP BY o.id, o.name, o.is_banned
|
||||||
userId: '$userId',
|
ORDER BY "memberCount" DESC
|
||||||
isBanned: '$isBanned'
|
`;
|
||||||
}
|
const resOrgs = await query(sql);
|
||||||
},
|
res.json(resOrgs.rows);
|
||||||
lastActive: { $max: '$updatedAt' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$lookup: {
|
|
||||||
from: 'organizations', // Ensure this matches the collection name of Organization model
|
|
||||||
localField: '_id',
|
|
||||||
foreignField: 'externalId',
|
|
||||||
as: 'orgDetails'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$unwind: {
|
|
||||||
path: '$orgDetails',
|
|
||||||
preserveNullAndEmptyArrays: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$project: {
|
|
||||||
_id: 1,
|
|
||||||
lastActive: 1,
|
|
||||||
members: 1,
|
|
||||||
memberCount: { $size: '$members' },
|
|
||||||
isBanned: { $ifNull: ['$orgDetails.isBanned', false] },
|
|
||||||
name: { $ifNull: ['$orgDetails.name', ''] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ $sort: { memberCount: -1 } }
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json(organizations);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting global organizations:', error);
|
console.error('Error getting global organizations:', error);
|
||||||
res.status(500).json({ error: 'Erro ao buscar organizações globais.' });
|
res.status(500).json({ error: 'Erro ao buscar organizações globais.' });
|
||||||
@@ -163,15 +117,12 @@ export const toggleOrganizationBan = async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'ID da organização é obrigatório.' });
|
return res.status(400).json({ error: 'ID da organização é obrigatório.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert the Organization record
|
const resOrg = await query(
|
||||||
const org = await Organization.findOneAndUpdate(
|
'UPDATE gpi.organizations SET is_banned = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
|
||||||
{ externalId: organizationId },
|
[isBanned, organizationId]
|
||||||
{ isBanned: isBanned },
|
|
||||||
{ new: true, upsert: true }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Organization ${organizationId} ban status set to ${isBanned} by ${req.appUser?.email}`);
|
res.json(resOrg.rows[0]);
|
||||||
res.json(org);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling organization ban:', error);
|
console.error('Error toggling organization ban:', error);
|
||||||
res.status(500).json({ error: 'Erro ao atualizar status da organização.' });
|
res.status(500).json({ error: 'Erro ao atualizar status da organização.' });
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import app from './app.js';
|
import app from './app.js';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { migrateFilesToGridFS } from './services/dataSheetService.js';
|
|
||||||
import { connectDB } from './config/database.js';
|
import { connectDB } from './config/database.js';
|
||||||
import mongoose from 'mongoose';
|
|
||||||
import { notificationService } from './services/notificationService.js';
|
import { notificationService } from './services/notificationService.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -14,21 +12,15 @@ const startServer = async () => {
|
|||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(Number(PORT), '0.0.0.0', async () => {
|
app.listen(Number(PORT), '0.0.0.0', async () => {
|
||||||
console.log(`🚀 Server running on port ${PORT} (0.0.0.0)`);
|
console.log(`🚀 Server running on port ${PORT} (0.0.0.0)`);
|
||||||
if (mongoose.connection.readyState === 1) {
|
|
||||||
// await migrateFilesToGridFS().catch(err => console.error('Migration failed:', err));
|
|
||||||
|
|
||||||
// Agendar verificação de vencimento de estoque (a cada 24 horas)
|
// Agendar verificação de vencimento de estoque (a cada 24 horas)
|
||||||
console.log('📅 Scheduling stock expiration check...');
|
console.log('📅 Scheduling stock expiration check...');
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
notificationService.checkStockExpirations();
|
|
||||||
}, 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
// Executar uma vez no início para garantir (opcional, bom para dev)
|
|
||||||
notificationService.checkStockExpirations();
|
notificationService.checkStockExpirations();
|
||||||
|
}, 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
} else {
|
// Executar uma vez no início para garantir (opcional, bom para dev)
|
||||||
console.warn('⚠️ MongoDB is not connected. Skipping migrations.');
|
// notificationService.checkStockExpirations();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start server:', error);
|
console.error('Failed to start server:', error);
|
||||||
|
|||||||
@@ -1,100 +1,88 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import User, { IUser } from '../models/User.js';
|
import { query } from '../config/database.js';
|
||||||
import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js';
|
|
||||||
import Organization from '../models/Organization.js';
|
|
||||||
|
|
||||||
// Extended user info with organization context
|
export type UserRole = 'guest' | 'user' | 'admin';
|
||||||
export interface IAppUser extends IUser {
|
export type OrgRole = 'guest' | 'user' | 'admin';
|
||||||
|
|
||||||
|
export interface IAppUser {
|
||||||
|
id: string;
|
||||||
|
externalId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
isBanned: boolean;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
organizationRole?: OrgRole;
|
organizationRole?: OrgRole;
|
||||||
organizationBanned?: boolean;
|
organizationBanned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module augmentation for Express Request
|
|
||||||
declare module 'express-serve-static-core' {
|
|
||||||
interface Request {
|
|
||||||
appUser?: IAppUser;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to extract and verify user from Auth ID header
|
|
||||||
* Also loads organization-specific role if organization context is provided
|
|
||||||
*/
|
|
||||||
export const extractUser = async (req: Request, res: Response, next: NextFunction) => {
|
export const extractUser = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const externalId = req.headers['x-auth-user-id'] as string;
|
const externalId = req.headers['x-auth-user-id'] as string;
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
|
||||||
if (!externalId) {
|
if (!externalId) {
|
||||||
return next(); // No user, continue without
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findOne({ externalId });
|
// Buscar usuário (compatível com clerk_id ou logto_id salvos como external_id no futuro,
|
||||||
|
// ou usando as colunas atuais clerk_id/logto_id)
|
||||||
|
const userRes = await query(
|
||||||
|
'SELECT * FROM gpi.users WHERE clerk_id = $1 OR logto_id = $1',
|
||||||
|
[externalId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userRes.rows[0];
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
if (user.isBanned) {
|
if (user.is_banned) {
|
||||||
return res.status(403).json({ error: 'Conta bloqueada. Entre em contato com o administrador.' });
|
return res.status(403).json({ error: 'Conta bloqueada. Entre em contato com o administrador.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create extended user object
|
const appUser: IAppUser = {
|
||||||
const appUser: IAppUser = user.toObject() as IAppUser;
|
id: user.id,
|
||||||
appUser.organizationId = organizationId || user.organizationId;
|
externalId: user.clerk_id || user.logto_id || externalId,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
isBanned: user.is_banned,
|
||||||
|
organizationId: organizationId
|
||||||
|
};
|
||||||
|
|
||||||
// If organization context, get org-specific role
|
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
// Check if Organization is globally banned (subscription specific, etc.)
|
// Verificar organização
|
||||||
const orgStatus = await Organization.findOne({ externalId: organizationId });
|
const orgRes = await query(
|
||||||
const orgName = req.headers['x-organization-name'] ? decodeURIComponent(req.headers['x-organization-name'] as string) : undefined;
|
'SELECT * FROM gpi.organizations WHERE id = $1 OR clerk_id = $1 OR logto_id = $1',
|
||||||
|
[organizationId]
|
||||||
|
);
|
||||||
|
const org = orgRes.rows[0];
|
||||||
|
|
||||||
if (orgStatus) {
|
if (org) {
|
||||||
// Update name if different and present
|
if (org.is_banned) {
|
||||||
if (orgName && orgStatus.name !== orgName) {
|
return res.status(403).json({ error: 'Acesso bloqueado: Esta organização está suspensa.' });
|
||||||
try {
|
}
|
||||||
await Organization.updateOne(
|
|
||||||
{ externalId: organizationId },
|
// Buscar papel na organização
|
||||||
{ name: orgName }
|
const memberRes = await query(
|
||||||
);
|
'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2',
|
||||||
} catch (err) {
|
[user.id, org.id]
|
||||||
console.warn('Failed to update organization name', err);
|
);
|
||||||
|
const member = memberRes.rows[0];
|
||||||
|
|
||||||
|
if (member) {
|
||||||
|
if (member.is_banned) {
|
||||||
|
return res.status(403).json({ error: 'Acesso bloqueado nesta organização.' });
|
||||||
}
|
}
|
||||||
|
appUser.organizationRole = member.role;
|
||||||
|
appUser.role = member.role; // Override global role
|
||||||
|
} else {
|
||||||
|
appUser.organizationRole = 'guest';
|
||||||
|
appUser.role = 'guest';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orgStatus.isBanned) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'Acesso bloqueado: Esta organização está suspensa. Entre em contato com o suporte.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create new org with name if present
|
|
||||||
try {
|
|
||||||
await Organization.create({
|
|
||||||
externalId: organizationId,
|
|
||||||
name: orgName
|
|
||||||
});
|
|
||||||
} catch (_e) {
|
|
||||||
console.warn('Organization auto-create race condition', _e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const member = await OrganizationMember.findOne({ userId: externalId, organizationId });
|
|
||||||
if (member) {
|
|
||||||
if (member.isBanned) {
|
|
||||||
return res.status(403).json({ error: 'Acesso bloqueado nesta organização.' });
|
|
||||||
}
|
|
||||||
appUser.organizationRole = member.role;
|
|
||||||
appUser.role = member.role; // Override global role with org role
|
|
||||||
} else {
|
|
||||||
// User exists but is not a member of this org yet
|
|
||||||
appUser.organizationRole = 'guest';
|
|
||||||
appUser.role = 'guest';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.appUser = appUser;
|
req.appUser = appUser;
|
||||||
// console.log(`✅ Request authenticated as: ${appUser.name} (${appUser.role})`);
|
|
||||||
} else {
|
|
||||||
console.warn(`⚠️ User with Auth ID ${externalId} not found in MongoDB. Sync required.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -104,17 +92,12 @@ export const extractUser = async (req: Request, res: Response, next: NextFunctio
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require specific roles for a route
|
|
||||||
* @param allowedRoles Array of roles that can access the route
|
|
||||||
*/
|
|
||||||
export const requireRole = (allowedRoles: OrgRole[]) => {
|
export const requireRole = (allowedRoles: OrgRole[]) => {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
return res.status(401).json({ error: 'Autenticação necessária.' });
|
return res.status(401).json({ error: 'Autenticação necessária.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEV Bypass: Developer has full power
|
|
||||||
if (req.appUser.email === 'admtracksteel@gmail.com') {
|
if (req.appUser.email === 'admtracksteel@gmail.com') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
@@ -129,46 +112,26 @@ export const requireRole = (allowedRoles: OrgRole[]) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require admin role
|
|
||||||
*/
|
|
||||||
export const requireAdmin = requireRole(['admin']);
|
export const requireAdmin = requireRole(['admin']);
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require at least user role (user or admin)
|
|
||||||
*/
|
|
||||||
export const requireUser = requireRole(['user', 'admin']);
|
export const requireUser = requireRole(['user', 'admin']);
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to check if user can edit (user or admin, not guest)
|
|
||||||
*/
|
|
||||||
export const canEdit = (req: Request, res: Response, next: NextFunction) => {
|
export const canEdit = (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
return res.status(401).json({ error: 'Autenticação necessária.' });
|
return res.status(401).json({ error: 'Autenticação necessária.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
|
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
|
||||||
|
|
||||||
if (effectiveRole === 'guest') {
|
if (effectiveRole === 'guest') {
|
||||||
return res.status(403).json({ error: 'Convidados não podem editar. Solicite acesso ao administrador.' });
|
return res.status(403).json({ error: 'Convidados não podem editar.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require Developer (Super Admin) access
|
|
||||||
* Hardcoded to specific email for security
|
|
||||||
*/
|
|
||||||
export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => {
|
export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
return res.status(401).json({ error: 'Autenticação necessária.' });
|
return res.status(401).json({ error: 'Autenticação necessária.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.appUser.email !== 'admtracksteel@gmail.com') {
|
if (req.appUser.email !== 'admtracksteel@gmail.com') {
|
||||||
console.warn(`⛔ Attempted unauthorized developer access by: ${req.appUser.email}`);
|
|
||||||
return res.status(403).json({ error: 'Acesso restrito ao desenvolvedor.' });
|
return res.status(403).json({ error: 'Acesso restrito ao desenvolvedor.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IApplicationRecord extends Document {
|
|
||||||
organizationId?: string;
|
|
||||||
createdBy?: string;
|
|
||||||
projectId: mongoose.Types.ObjectId;
|
|
||||||
coatStage: string;
|
|
||||||
pieceDescription?: string | null;
|
|
||||||
date?: Date | null;
|
|
||||||
operator?: string | null;
|
|
||||||
realWeight?: number | null;
|
|
||||||
volumeUsed?: number | null;
|
|
||||||
areaPainted?: number | null;
|
|
||||||
wetThicknessAvg?: number | null;
|
|
||||||
dryThicknessCalc?: number | null;
|
|
||||||
method?: string | null;
|
|
||||||
diluentUsed?: number | null;
|
|
||||||
notes?: string | null;
|
|
||||||
items?: {
|
|
||||||
partId: mongoose.Types.ObjectId;
|
|
||||||
quantity: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ApplicationRecordSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
createdBy: { type: String, index: true },
|
|
||||||
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
|
|
||||||
coatStage: { type: String, required: true },
|
|
||||||
pieceDescription: { type: String }, // Can be auto-generated or manual name for the Batch
|
|
||||||
date: { type: Date },
|
|
||||||
operator: { type: String },
|
|
||||||
realWeight: { type: Number },
|
|
||||||
volumeUsed: { type: Number },
|
|
||||||
areaPainted: { type: Number },
|
|
||||||
wetThicknessAvg: { type: Number },
|
|
||||||
dryThicknessCalc: { type: Number },
|
|
||||||
method: { type: String },
|
|
||||||
diluentUsed: { type: Number },
|
|
||||||
notes: { type: String },
|
|
||||||
items: [{
|
|
||||||
partId: { type: Schema.Types.ObjectId, ref: 'Part' },
|
|
||||||
quantity: { type: Number, required: true }
|
|
||||||
}]
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.ApplicationRecord || mongoose.model<IApplicationRecord>('ApplicationRecord', ApplicationRecordSchema);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import mongoose, { Document, Schema } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IGeometryType extends Document {
|
|
||||||
name: string;
|
|
||||||
efficiencyLoss: number; // Percentage, e.g., 10 for 10%
|
|
||||||
organizationId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GeometryTypeSchema: Schema = new Schema({
|
|
||||||
name: { type: String, required: true },
|
|
||||||
efficiencyLoss: { type: Number, required: true, default: 0 },
|
|
||||||
organizationId: { type: String, required: true, index: true },
|
|
||||||
}, {
|
|
||||||
timestamps: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Compound index to ensure unique names per organization
|
|
||||||
GeometryTypeSchema.index({ organizationId: 1, name: 1 }, { unique: true });
|
|
||||||
|
|
||||||
export default mongoose.model<IGeometryType>('GeometryType', GeometryTypeSchema);
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IInspection extends Document {
|
|
||||||
organizationId?: string;
|
|
||||||
createdBy?: string; // Auth User ID
|
|
||||||
projectId: mongoose.Types.ObjectId;
|
|
||||||
type: 'painting' | 'surface_treatment';
|
|
||||||
|
|
||||||
// Common
|
|
||||||
date?: Date | null;
|
|
||||||
inspector?: string | null;
|
|
||||||
appearance?: 'approved' | 'rejected' | 'notes' | null; // Unified status
|
|
||||||
defects?: string | null; // Observations
|
|
||||||
photos?: string[]; // URLs
|
|
||||||
partTemperature?: number | null;
|
|
||||||
weightKg?: number | null;
|
|
||||||
|
|
||||||
// Painting Specific
|
|
||||||
pieceDescription?: string | null;
|
|
||||||
epsPoints?: (number | null)[];
|
|
||||||
adhesionTest?: string | null;
|
|
||||||
|
|
||||||
// Surface Treatment Specific
|
|
||||||
batch?: string | null; // Lote
|
|
||||||
treatmentExecutor?: string | null;
|
|
||||||
treatmentType?: string | null; // Jateamento, Mecânica...
|
|
||||||
cleaningDegree?: string | null; // Sa 2.5, St 3...
|
|
||||||
roughnessReadings?: (number | null)[]; // 5 measurements
|
|
||||||
flashRust?: string | null;
|
|
||||||
temperature?: number | null;
|
|
||||||
relativeHumidity?: number | null;
|
|
||||||
period?: 'morning' | 'afternoon' | 'night' | null;
|
|
||||||
applicationRecordId?: mongoose.Types.ObjectId; // Link to specific painting batch
|
|
||||||
stockItemId?: mongoose.Types.ObjectId; // Link to Stock Item (Paint used)
|
|
||||||
instrumentId?: mongoose.Types.ObjectId; // Link to Instrument used
|
|
||||||
}
|
|
||||||
|
|
||||||
const InspectionSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
createdBy: { type: String, index: true },
|
|
||||||
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
|
|
||||||
applicationRecordId: { type: Schema.Types.ObjectId, ref: 'ApplicationRecord' },
|
|
||||||
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem' },
|
|
||||||
instrumentId: { type: Schema.Types.ObjectId, ref: 'Instrument' },
|
|
||||||
type: { type: String, enum: ['painting', 'surface_treatment'], default: 'painting', index: true },
|
|
||||||
|
|
||||||
// Common
|
|
||||||
date: { type: Date },
|
|
||||||
inspector: { type: String },
|
|
||||||
appearance: { type: String }, // approved, rejected, notes
|
|
||||||
defects: { type: String },
|
|
||||||
photos: [{ type: String }],
|
|
||||||
partTemperature: { type: Number },
|
|
||||||
weightKg: { type: Number },
|
|
||||||
|
|
||||||
// Painting
|
|
||||||
pieceDescription: { type: String },
|
|
||||||
epsPoints: [{ type: Number }],
|
|
||||||
adhesionTest: { type: String },
|
|
||||||
|
|
||||||
// Surface Treatment
|
|
||||||
batch: { type: String },
|
|
||||||
treatmentExecutor: { type: String },
|
|
||||||
treatmentType: { type: String },
|
|
||||||
cleaningDegree: { type: String },
|
|
||||||
roughnessReadings: [{ type: Number }],
|
|
||||||
flashRust: { type: String },
|
|
||||||
temperature: { type: Number },
|
|
||||||
relativeHumidity: { type: Number },
|
|
||||||
period: { type: String },
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.Inspection || mongoose.model<IInspection>('Inspection', InspectionSchema);
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IInstrument extends Document {
|
|
||||||
organizationId: string;
|
|
||||||
name: string;
|
|
||||||
type: string; // Ex: Medidor de Camada, Termo-higrômetro
|
|
||||||
manufacturer?: string;
|
|
||||||
modelName?: string;
|
|
||||||
serialNumber: string;
|
|
||||||
calibrationDate?: Date;
|
|
||||||
calibrationExpirationDate?: Date;
|
|
||||||
certificateUrl?: string; // URL do PDF
|
|
||||||
status: 'active' | 'inactive' | 'maintenance' | 'expired';
|
|
||||||
notes?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InstrumentSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, required: true, index: true },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
type: { type: String, required: true },
|
|
||||||
manufacturer: { type: String },
|
|
||||||
modelName: { type: String },
|
|
||||||
serialNumber: { type: String, required: true },
|
|
||||||
calibrationDate: { type: Date },
|
|
||||||
calibrationExpirationDate: { type: Date },
|
|
||||||
certificateUrl: { type: String },
|
|
||||||
status: {
|
|
||||||
type: String,
|
|
||||||
enum: ['active', 'inactive', 'maintenance', 'expired'],
|
|
||||||
default: 'active'
|
|
||||||
},
|
|
||||||
notes: { type: String }
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
// Index para evitar duplicidade de número de série dentro da mesma organização
|
|
||||||
InstrumentSchema.index({ organizationId: 1, serialNumber: 1 }, { unique: true });
|
|
||||||
|
|
||||||
export default mongoose.models.Instrument || mongoose.model<IInstrument>('Instrument', InstrumentSchema);
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IMessage extends Document {
|
|
||||||
organizationId: string;
|
|
||||||
fromUserId: string; // externalId do remetente
|
|
||||||
toUserId: string; // externalId do destinatário
|
|
||||||
message: string;
|
|
||||||
isRead: boolean;
|
|
||||||
readAt?: Date;
|
|
||||||
isArchived: boolean;
|
|
||||||
isDeletedByRecipient: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MessageSchema: Schema = new Schema(
|
|
||||||
{
|
|
||||||
organizationId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
index: true,
|
|
||||||
},
|
|
||||||
fromUserId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
index: true,
|
|
||||||
},
|
|
||||||
toUserId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
index: true,
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
maxlength: 255,
|
|
||||||
},
|
|
||||||
isRead: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
readAt: {
|
|
||||||
type: Date,
|
|
||||||
},
|
|
||||||
isArchived: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isDeletedByRecipient: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timestamps: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Compound index for efficient queries
|
|
||||||
MessageSchema.index({ toUserId: 1, isRead: 1 });
|
|
||||||
MessageSchema.index({ fromUserId: 1, toUserId: 1 });
|
|
||||||
|
|
||||||
export default mongoose.model<IMessage>('Message', MessageSchema);
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export type NotificationType = 'info' | 'warning' | 'error' | 'success';
|
|
||||||
|
|
||||||
export interface INotification extends Document {
|
|
||||||
organizationId: string;
|
|
||||||
recipientId?: string; // Se null, é para todos da organização
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
type: NotificationType;
|
|
||||||
isRead: boolean;
|
|
||||||
isArchived: boolean;
|
|
||||||
archivedBy: string[]; // IDs dos usuários que arquivaram (para notificações globais)
|
|
||||||
deletedBy: string[]; // IDs dos usuários que deletaram (para notificações globais)
|
|
||||||
metadata?: any; // Para guardar IDs de projetos, itens, etc.
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotificationSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, required: true, index: true },
|
|
||||||
recipientId: { type: String, index: true }, // Opcional
|
|
||||||
title: { type: String, required: true },
|
|
||||||
message: { type: String, required: true },
|
|
||||||
type: { type: String, enum: ['info', 'warning', 'error', 'success'], default: 'info' },
|
|
||||||
isRead: { type: Boolean, default: false },
|
|
||||||
isArchived: { type: Boolean, default: false },
|
|
||||||
archivedBy: [{ type: String }],
|
|
||||||
deletedBy: [{ type: String }],
|
|
||||||
metadata: { type: Schema.Types.Mixed },
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.Notification || mongoose.model<INotification>('Notification', NotificationSchema);
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IOrganization extends Document {
|
|
||||||
externalId: string;
|
|
||||||
name?: string;
|
|
||||||
isBanned: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OrganizationSchema: Schema = new Schema({
|
|
||||||
externalId: { type: String, required: true, unique: true, index: true },
|
|
||||||
name: { type: String },
|
|
||||||
isBanned: { type: Boolean, default: false },
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.Organization || mongoose.model<IOrganization>('Organization', OrganizationSchema);
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export type OrgRole = 'guest' | 'user' | 'admin';
|
|
||||||
|
|
||||||
export interface IOrganizationMember extends Document {
|
|
||||||
userId: string;
|
|
||||||
organizationId: string;
|
|
||||||
role: OrgRole;
|
|
||||||
isBanned: boolean;
|
|
||||||
// Denormalized user info for quick access
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OrganizationMemberSchema: Schema = new Schema({
|
|
||||||
userId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
index: true
|
|
||||||
},
|
|
||||||
organizationId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
index: true
|
|
||||||
},
|
|
||||||
role: {
|
|
||||||
type: String,
|
|
||||||
enum: ['guest', 'user', 'admin'],
|
|
||||||
default: 'guest'
|
|
||||||
},
|
|
||||||
isBanned: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
timestamps: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Compound index for unique user per organization
|
|
||||||
OrganizationMemberSchema.index({ userId: 1, organizationId: 1 }, { unique: true });
|
|
||||||
|
|
||||||
export default mongoose.models.OrganizationMember || mongoose.model<IOrganizationMember>('OrganizationMember', OrganizationMemberSchema);
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IPaintingScheme extends Document {
|
|
||||||
projectId: mongoose.Types.ObjectId;
|
|
||||||
name: string;
|
|
||||||
type?: string | null;
|
|
||||||
coat?: string | null;
|
|
||||||
solidsVolume?: number | null;
|
|
||||||
yieldTheoretical?: number | null;
|
|
||||||
epsMin?: number | null;
|
|
||||||
epsMax?: number | null;
|
|
||||||
dilution?: number | null;
|
|
||||||
manufacturer?: string | null;
|
|
||||||
color?: string | null;
|
|
||||||
notes?: string | null;
|
|
||||||
organizationId?: string;
|
|
||||||
// Consumption Planning
|
|
||||||
paintConsumption?: number | null;
|
|
||||||
thinnerConsumption?: number | null;
|
|
||||||
paintId?: mongoose.Types.ObjectId | null; // Ref to TechnicalDataSheet
|
|
||||||
thinnerId?: mongoose.Types.ObjectId | null; // Ref to TechnicalDataSheet
|
|
||||||
preferredStockItemId?: mongoose.Types.ObjectId | null; // Ref to StockItem (Suggested Batch)
|
|
||||||
}
|
|
||||||
|
|
||||||
const PaintingSchemeSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
type: { type: String },
|
|
||||||
coat: { type: String },
|
|
||||||
solidsVolume: { type: Number },
|
|
||||||
yieldTheoretical: { type: Number },
|
|
||||||
epsMin: { type: Number },
|
|
||||||
epsMax: { type: Number },
|
|
||||||
dilution: { type: Number },
|
|
||||||
manufacturer: { type: String },
|
|
||||||
color: { type: String },
|
|
||||||
notes: { type: String },
|
|
||||||
// Consumption Planning
|
|
||||||
paintConsumption: { type: Number },
|
|
||||||
thinnerConsumption: { type: Number },
|
|
||||||
paintId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet' },
|
|
||||||
thinnerId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet' },
|
|
||||||
preferredStockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem' }
|
|
||||||
}, { strict: false });
|
|
||||||
|
|
||||||
console.log("✅✅✅ PAINTING SCHEME MODEL (WITH CONSUMPTION) LOADED ✅✅✅");
|
|
||||||
|
|
||||||
// Force model recompilation to ensure schema updates are applied
|
|
||||||
if (mongoose.models.PaintingScheme) {
|
|
||||||
delete mongoose.models.PaintingScheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default mongoose.model<IPaintingScheme>('PaintingScheme', PaintingSchemeSchema);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IPart extends Document {
|
|
||||||
projectId?: mongoose.Types.ObjectId;
|
|
||||||
description: string;
|
|
||||||
dimensions?: string | null;
|
|
||||||
weight?: number | null;
|
|
||||||
type?: string | null;
|
|
||||||
area?: number | null;
|
|
||||||
complexity?: number | null;
|
|
||||||
quantity: number;
|
|
||||||
notes?: string | null;
|
|
||||||
organizationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PartSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: false },
|
|
||||||
description: { type: String, required: true },
|
|
||||||
dimensions: { type: String },
|
|
||||||
weight: { type: Number },
|
|
||||||
type: { type: String },
|
|
||||||
area: { type: Number },
|
|
||||||
complexity: { type: Number },
|
|
||||||
quantity: { type: Number, required: true, default: 1 },
|
|
||||||
notes: { type: String },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default mongoose.models.Part || mongoose.model<IPart>('Part', PartSchema);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IProject extends Document {
|
|
||||||
name: string;
|
|
||||||
client: string;
|
|
||||||
startDate?: Date | null;
|
|
||||||
endDate?: Date | null;
|
|
||||||
technician?: string | null;
|
|
||||||
environment?: string | null;
|
|
||||||
organizationId?: string;
|
|
||||||
weightKg?: number | null;
|
|
||||||
status: 'active' | 'archived';
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectSchema: Schema = new Schema({
|
|
||||||
name: { type: String, required: true },
|
|
||||||
client: { type: String, required: true },
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
startDate: { type: Date },
|
|
||||||
endDate: { type: Date },
|
|
||||||
technician: { type: String },
|
|
||||||
environment: { type: String },
|
|
||||||
weightKg: { type: Number },
|
|
||||||
status: { type: String, enum: ['active', 'archived'], default: 'active', index: true },
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.Project || mongoose.model<IProject>('Project', ProjectSchema);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IStockAuditLog extends Document {
|
|
||||||
organizationId?: string;
|
|
||||||
stockItemId: mongoose.Types.ObjectId;
|
|
||||||
movementId?: mongoose.Types.ObjectId; // Optional, might be deleted
|
|
||||||
movementNumber?: number;
|
|
||||||
userId: string;
|
|
||||||
userName: string;
|
|
||||||
action: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
||||||
details: string; // Human readable summary
|
|
||||||
oldValues?: Record<string, any>;
|
|
||||||
newValues?: Record<string, any>;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StockAuditLogSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem', required: true },
|
|
||||||
movementId: { type: Schema.Types.ObjectId, ref: 'StockMovement' },
|
|
||||||
movementNumber: { type: Number },
|
|
||||||
userId: { type: String, required: true },
|
|
||||||
userName: { type: String, required: true },
|
|
||||||
action: { type: String, required: true, enum: ['CREATE', 'UPDATE', 'DELETE'] },
|
|
||||||
details: { type: String, required: true },
|
|
||||||
oldValues: { type: Object },
|
|
||||||
newValues: { type: Object },
|
|
||||||
timestamp: { type: Date, default: Date.now }
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.StockAuditLog || mongoose.model<IStockAuditLog>('StockAuditLog', StockAuditLogSchema);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IStockItem extends Document {
|
|
||||||
organizationId?: string;
|
|
||||||
createdBy?: string;
|
|
||||||
dataSheetId: mongoose.Types.ObjectId;
|
|
||||||
rrNumber: string; // Registro de Rastreabilidade
|
|
||||||
batchNumber: string; // Lote
|
|
||||||
color?: string;
|
|
||||||
invoiceNumber?: string; // Nota Fiscal
|
|
||||||
receivedBy?: string; // Quem recebeu
|
|
||||||
quantity: number;
|
|
||||||
unit: string;
|
|
||||||
minStock?: number; // Estoque mínimo estipulado
|
|
||||||
expirationDate?: Date;
|
|
||||||
entryDate: Date;
|
|
||||||
notes?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StockItemSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
createdBy: { type: String, index: true },
|
|
||||||
dataSheetId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet', required: true },
|
|
||||||
rrNumber: { type: String, required: true },
|
|
||||||
batchNumber: { type: String, required: true },
|
|
||||||
color: { type: String },
|
|
||||||
invoiceNumber: { type: String },
|
|
||||||
receivedBy: { type: String },
|
|
||||||
quantity: { type: Number, required: true, default: 0 },
|
|
||||||
unit: { type: String, required: true },
|
|
||||||
minStock: { type: Number, default: 0 },
|
|
||||||
expirationDate: { type: Date },
|
|
||||||
entryDate: { type: Date, default: Date.now },
|
|
||||||
notes: { type: String }
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
// Compound index to prevent duplicate RR within an organization, if desirable.
|
|
||||||
// For now, indexing RR for fast lookup.
|
|
||||||
StockItemSchema.index({ organizationId: 1, rrNumber: 1 });
|
|
||||||
|
|
||||||
export default mongoose.models.StockItem || mongoose.model<IStockItem>('StockItem', StockItemSchema);
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export type MovementType = 'ENTRY' | 'ADJUSTMENT' | 'CONSUMPTION';
|
|
||||||
|
|
||||||
export interface IStockMovement extends Document {
|
|
||||||
organizationId?: string;
|
|
||||||
createdBy?: string;
|
|
||||||
stockItemId: mongoose.Types.ObjectId;
|
|
||||||
movementNumber?: number;
|
|
||||||
type: MovementType;
|
|
||||||
quantity: number; // Positive for entry, negative for exit
|
|
||||||
date: Date;
|
|
||||||
responsible: string; // User who performed the action
|
|
||||||
reason?: string; // For ADJUSTMENT
|
|
||||||
requester?: string; // For CONSUMPTION
|
|
||||||
notes?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StockMovementSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
createdBy: { type: String, index: true },
|
|
||||||
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem', required: true },
|
|
||||||
movementNumber: { type: Number },
|
|
||||||
type: { type: String, enum: ['ENTRY', 'ADJUSTMENT', 'CONSUMPTION'], required: true },
|
|
||||||
quantity: { type: Number, required: true },
|
|
||||||
date: { type: Date, default: Date.now },
|
|
||||||
responsible: { type: String, required: true },
|
|
||||||
reason: { type: String },
|
|
||||||
requester: { type: String },
|
|
||||||
notes: { type: String }
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.StockMovement || mongoose.model<IStockMovement>('StockMovement', StockMovementSchema);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IStoredFile extends Document {
|
|
||||||
filename: string;
|
|
||||||
contentType: string;
|
|
||||||
data: Buffer;
|
|
||||||
size: number;
|
|
||||||
uploadDate: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StoredFileSchema: Schema = new Schema({
|
|
||||||
filename: { type: String, required: true },
|
|
||||||
contentType: { type: String, required: true },
|
|
||||||
data: { type: Buffer, required: true },
|
|
||||||
size: { type: Number, required: true },
|
|
||||||
uploadDate: { type: Date, default: Date.now }
|
|
||||||
});
|
|
||||||
|
|
||||||
export default mongoose.models.StoredFile || mongoose.model<IStoredFile>('StoredFile', StoredFileSchema);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface ISystemSettings extends Document {
|
|
||||||
settingsId: string;
|
|
||||||
appName: string;
|
|
||||||
appSubtitle: string;
|
|
||||||
appLogoUrl?: string;
|
|
||||||
updatedBy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SystemSettingsSchema: Schema = new Schema({
|
|
||||||
settingsId: { type: String, required: true, unique: true, default: 'global' },
|
|
||||||
appName: { type: String, required: true, default: 'GPI' },
|
|
||||||
appSubtitle: { type: String, required: true, default: 'Gestão de Pintura Industrial' },
|
|
||||||
appLogoUrl: { type: String },
|
|
||||||
updatedBy: { type: String } // Email of the dev who updated it
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.SystemSettings || mongoose.model<ISystemSettings>('SystemSettings', SystemSettingsSchema);
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface ITechnicalDataSheet extends Document {
|
|
||||||
name: string;
|
|
||||||
manufacturer?: string;
|
|
||||||
type?: string;
|
|
||||||
fileId?: mongoose.Types.ObjectId;
|
|
||||||
fileUrl: string;
|
|
||||||
uploadDate: Date;
|
|
||||||
solidsVolume?: number;
|
|
||||||
density?: number;
|
|
||||||
mixingRatio?: string;
|
|
||||||
mixingRatioWeight?: string;
|
|
||||||
mixingRatioVolume?: string;
|
|
||||||
wftMin?: number;
|
|
||||||
wftMax?: number;
|
|
||||||
dftMin?: number;
|
|
||||||
dftMax?: number;
|
|
||||||
reducer?: string;
|
|
||||||
yieldTheoretical?: number;
|
|
||||||
dftReference?: number;
|
|
||||||
yieldFactor?: number;
|
|
||||||
dilution?: number;
|
|
||||||
notes?: string;
|
|
||||||
organizationId?: string;
|
|
||||||
manufacturerCode?: string;
|
|
||||||
minStock?: number;
|
|
||||||
typicalApplication?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TechnicalDataSheetSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
manufacturer: { type: String },
|
|
||||||
manufacturerCode: { type: String },
|
|
||||||
type: { type: String },
|
|
||||||
minStock: { type: Number },
|
|
||||||
typicalApplication: { type: String },
|
|
||||||
fileId: { type: Schema.Types.ObjectId, ref: 'StoredFile' },
|
|
||||||
fileUrl: { type: String },
|
|
||||||
uploadDate: { type: Date, default: Date.now },
|
|
||||||
solidsVolume: { type: Number },
|
|
||||||
density: { type: Number },
|
|
||||||
mixingRatio: { type: String },
|
|
||||||
mixingRatioWeight: { type: String },
|
|
||||||
mixingRatioVolume: { type: String },
|
|
||||||
wftMin: { type: Number },
|
|
||||||
wftMax: { type: Number },
|
|
||||||
dftMin: { type: Number },
|
|
||||||
dftMax: { type: Number },
|
|
||||||
reducer: { type: String },
|
|
||||||
yieldTheoretical: { type: Number },
|
|
||||||
dftReference: { type: Number },
|
|
||||||
yieldFactor: { type: Number },
|
|
||||||
dilution: { type: Number },
|
|
||||||
notes: { type: String },
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.TechnicalDataSheet || mongoose.model<ITechnicalDataSheet>('TechnicalDataSheet', TechnicalDataSheetSchema);
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export type UserRole = 'guest' | 'user' | 'admin';
|
|
||||||
|
|
||||||
export interface IUser extends Document {
|
|
||||||
externalId: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
role: UserRole;
|
|
||||||
isBanned: boolean;
|
|
||||||
passwordHash?: string;
|
|
||||||
organizationId?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
lastSeenAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserSchema: Schema = new Schema({
|
|
||||||
externalId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
unique: true,
|
|
||||||
index: true
|
|
||||||
},
|
|
||||||
passwordHash: {
|
|
||||||
type: String,
|
|
||||||
required: false
|
|
||||||
},
|
|
||||||
organizationId: {
|
|
||||||
type: String,
|
|
||||||
index: true
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
role: {
|
|
||||||
type: String,
|
|
||||||
enum: ['guest', 'user', 'admin'],
|
|
||||||
default: 'guest'
|
|
||||||
},
|
|
||||||
isBanned: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
lastSeenAt: {
|
|
||||||
type: Date,
|
|
||||||
default: Date.now
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
timestamps: true
|
|
||||||
});
|
|
||||||
|
|
||||||
export default mongoose.models.User || mongoose.model<IUser>('User', UserSchema);
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface IPieceCategory {
|
|
||||||
id: string; // Keep as string for internal mapping if needed, or convert to Sub-document
|
|
||||||
name: string;
|
|
||||||
organizationId?: string;
|
|
||||||
weight: number;
|
|
||||||
area?: number; // Área em m² para cálculo alternativo
|
|
||||||
historicalYield: number;
|
|
||||||
historicalDft: number;
|
|
||||||
efficiency: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PieceCategorySchema: Schema = new Schema({
|
|
||||||
name: { type: String, required: true },
|
|
||||||
weight: { type: Number, required: true },
|
|
||||||
area: { type: Number }, // Área em m² (opcional)
|
|
||||||
historicalYield: { type: Number, required: true },
|
|
||||||
historicalDft: { type: Number, required: true },
|
|
||||||
efficiency: { type: Number, required: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface IYieldStudy extends Document {
|
|
||||||
name: string;
|
|
||||||
organizationId?: string;
|
|
||||||
dataSheetId: mongoose.Types.ObjectId;
|
|
||||||
targetDft: number;
|
|
||||||
dilutionPercent: number;
|
|
||||||
categories: IPieceCategory[];
|
|
||||||
totalWeight: number;
|
|
||||||
estimatedPaintVolume: number;
|
|
||||||
estimatedReducerVolume: number;
|
|
||||||
estimatedPaintVolumeByArea?: number; // Cálculo por área (m²)
|
|
||||||
estimatedReducerVolumeByArea?: number; // Cálculo por área (m²)
|
|
||||||
averageComplexity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const YieldStudySchema: Schema = new Schema({
|
|
||||||
name: { type: String, required: true },
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
dataSheetId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet', required: true },
|
|
||||||
targetDft: { type: Number, required: true },
|
|
||||||
dilutionPercent: { type: Number, default: 0 },
|
|
||||||
categories: [PieceCategorySchema],
|
|
||||||
totalWeight: { type: Number },
|
|
||||||
estimatedPaintVolume: { type: Number },
|
|
||||||
estimatedReducerVolume: { type: Number },
|
|
||||||
estimatedPaintVolumeByArea: { type: Number }, // Cálculo por área
|
|
||||||
estimatedReducerVolumeByArea: { type: Number }, // Cálculo por área
|
|
||||||
averageComplexity: { type: Number },
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.YieldStudy || mongoose.model<IYieldStudy>('YieldStudy', YieldStudySchema);
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
|
|
||||||
const MONGODB_URI = 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0';
|
|
||||||
|
|
||||||
const UserSchema = new mongoose.Schema({
|
|
||||||
email: String,
|
|
||||||
role: String,
|
|
||||||
passwordHash: String,
|
|
||||||
externalId: String
|
|
||||||
});
|
|
||||||
|
|
||||||
async function check() {
|
|
||||||
try {
|
|
||||||
await mongoose.connect(MONGODB_URI);
|
|
||||||
const User = mongoose.model('User', UserSchema);
|
|
||||||
const user = await User.findOne({ email: 'admtracksteel@gmail.com' });
|
|
||||||
console.log('User found:', JSON.stringify(user, null, 2));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
await mongoose.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check();
|
|
||||||
326
src/server/scripts/init-db.sql
Normal file
326
src/server/scripts/init-db.sql
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
-- Full Database Schema for GPI (PostgreSQL)
|
||||||
|
CREATE SCHEMA IF NOT EXISTS gpi;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- 1. Organizations
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.organizations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
clerk_id TEXT UNIQUE, -- Legacy
|
||||||
|
logto_id TEXT UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_banned BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Users
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
clerk_id TEXT UNIQUE, -- Legacy
|
||||||
|
logto_id TEXT UNIQUE,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'guest',
|
||||||
|
is_banned BOOLEAN DEFAULT FALSE,
|
||||||
|
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. many-to-many user_organizations
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.user_organizations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES gpi.users(id) ON DELETE CASCADE,
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'guest',
|
||||||
|
is_banned BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE (user_id, organization_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Technical Data Sheets (Fichas Técnicas)
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.technical_data_sheets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
manufacturer TEXT,
|
||||||
|
type TEXT,
|
||||||
|
file_url TEXT,
|
||||||
|
solids_volume DECIMAL,
|
||||||
|
density DECIMAL,
|
||||||
|
mixing_ratio TEXT,
|
||||||
|
yield_theoretical DECIMAL,
|
||||||
|
wft_min DECIMAL,
|
||||||
|
wft_max DECIMAL,
|
||||||
|
dft_min DECIMAL,
|
||||||
|
dft_max DECIMAL,
|
||||||
|
reducer TEXT,
|
||||||
|
yield_factor DECIMAL DEFAULT 1.0,
|
||||||
|
min_stock DECIMAL DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. Projects
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
client TEXT NOT NULL,
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
technician TEXT,
|
||||||
|
environment TEXT,
|
||||||
|
weight_kg DECIMAL,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. Parts
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.parts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
dimensions TEXT,
|
||||||
|
weight DECIMAL,
|
||||||
|
type TEXT,
|
||||||
|
area DECIMAL,
|
||||||
|
complexity INTEGER,
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 7. Painting Schemes
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.painting_schemes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT,
|
||||||
|
coat TEXT, -- Primer, Intermediate, Finish
|
||||||
|
solids_volume DECIMAL,
|
||||||
|
yield_theoretical DECIMAL,
|
||||||
|
eps_min DECIMAL,
|
||||||
|
eps_max DECIMAL,
|
||||||
|
dilution DECIMAL,
|
||||||
|
manufacturer TEXT,
|
||||||
|
color TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
paint_id UUID REFERENCES gpi.technical_data_sheets(id),
|
||||||
|
thinner_id UUID REFERENCES gpi.technical_data_sheets(id),
|
||||||
|
color_hex TEXT,
|
||||||
|
thinner_symbol TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 8. Application Records (Lotes de Pintura)
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.application_records (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
|
||||||
|
coat_stage TEXT,
|
||||||
|
piece_description TEXT,
|
||||||
|
date DATE,
|
||||||
|
operator TEXT,
|
||||||
|
real_weight DECIMAL,
|
||||||
|
volume_used DECIMAL,
|
||||||
|
area_painted DECIMAL,
|
||||||
|
wet_thickness_avg DECIMAL,
|
||||||
|
dry_thickness_calc DECIMAL,
|
||||||
|
real_yield DECIMAL,
|
||||||
|
method TEXT,
|
||||||
|
diluent_used DECIMAL,
|
||||||
|
notes TEXT,
|
||||||
|
items JSONB DEFAULT '[]', -- List of {partId, quantity}
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 9. Instruments
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.instruments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
serial_number TEXT,
|
||||||
|
type TEXT,
|
||||||
|
last_calibration DATE,
|
||||||
|
calibration_due DATE,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 10. Stock Items
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.stock_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
data_sheet_id UUID REFERENCES gpi.technical_data_sheets(id),
|
||||||
|
batch_number TEXT NOT NULL,
|
||||||
|
manufacturing_date DATE,
|
||||||
|
expiration_date DATE,
|
||||||
|
initial_quantity DECIMAL NOT NULL,
|
||||||
|
current_quantity DECIMAL NOT NULL,
|
||||||
|
unit TEXT DEFAULT 'L',
|
||||||
|
location TEXT,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 11. Inspections
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.inspections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
|
||||||
|
application_record_id UUID REFERENCES gpi.application_records(id),
|
||||||
|
stock_item_id UUID REFERENCES gpi.stock_items(id),
|
||||||
|
instrument_id UUID REFERENCES gpi.instruments(id),
|
||||||
|
type TEXT CHECK (type IN ('painting', 'surface_treatment')),
|
||||||
|
date DATE,
|
||||||
|
inspector TEXT,
|
||||||
|
part_temperature DECIMAL,
|
||||||
|
weight_kg DECIMAL,
|
||||||
|
appearance TEXT, -- approved, rejected, notes
|
||||||
|
defects TEXT,
|
||||||
|
photos TEXT[] DEFAULT '{}',
|
||||||
|
piece_description TEXT,
|
||||||
|
eps_points DECIMAL[] DEFAULT '{}',
|
||||||
|
adhesion_test TEXT,
|
||||||
|
batch TEXT,
|
||||||
|
treatment_executor TEXT,
|
||||||
|
treatment_type TEXT,
|
||||||
|
cleaning_degree TEXT,
|
||||||
|
roughness_readings DECIMAL[] DEFAULT '{}',
|
||||||
|
flash_rust TEXT,
|
||||||
|
temperature DECIMAL,
|
||||||
|
relative_humidity DECIMAL,
|
||||||
|
period TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 12. Stock Movements
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.stock_movements (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
stock_item_id UUID REFERENCES gpi.stock_items(id) ON DELETE CASCADE,
|
||||||
|
type TEXT CHECK (type IN ('in', 'out', 'adjust')),
|
||||||
|
quantity DECIMAL NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
project_id UUID REFERENCES gpi.projects(id),
|
||||||
|
user_id TEXT, -- external_id
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 13. System Settings
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.system_settings (
|
||||||
|
settings_id TEXT PRIMARY KEY DEFAULT 'global',
|
||||||
|
app_name TEXT DEFAULT 'GPI',
|
||||||
|
app_subtitle TEXT DEFAULT 'Gestão de Pintura Industrial',
|
||||||
|
app_logo_url TEXT,
|
||||||
|
updated_by TEXT,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 14. Notifications
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
recipient_id UUID REFERENCES gpi.users(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
type TEXT CHECK (type IN ('info', 'warning', 'error', 'success')) DEFAULT 'info',
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE,
|
||||||
|
archived_by UUID[] DEFAULT '{}',
|
||||||
|
deleted_by UUID[] DEFAULT '{}',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 15. Yield Studies
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.yield_studies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
data_sheet_id UUID REFERENCES gpi.technical_data_sheets(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
target_dft DECIMAL NOT NULL,
|
||||||
|
dilution_percent DECIMAL DEFAULT 0,
|
||||||
|
categories JSONB DEFAULT '[]',
|
||||||
|
total_weight DECIMAL,
|
||||||
|
estimated_paint_volume DECIMAL,
|
||||||
|
estimated_reducer_volume DECIMAL,
|
||||||
|
estimated_paint_volume_by_area DECIMAL,
|
||||||
|
estimated_reducer_volume_by_area DECIMAL,
|
||||||
|
average_complexity DECIMAL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 16. Stock Audit Logs
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
stock_item_id UUID REFERENCES gpi.stock_items(id) ON DELETE CASCADE,
|
||||||
|
movement_id UUID,
|
||||||
|
movement_number INTEGER,
|
||||||
|
user_id TEXT, -- external_id
|
||||||
|
user_name TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
old_values JSONB,
|
||||||
|
new_values JSONB,
|
||||||
|
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 17. Messages
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
from_user_id TEXT NOT NULL, -- external_id
|
||||||
|
to_user_id TEXT NOT NULL, -- external_id
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
read_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE,
|
||||||
|
is_deleted_by_recipient BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 18. Geometry Types
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.geometry_types (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
efficiency_loss DECIMAL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE (organization_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 19. Stored Files
|
||||||
|
CREATE TABLE IF NOT EXISTS gpi.stored_files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
original_name TEXT,
|
||||||
|
mimetype TEXT,
|
||||||
|
size INTEGER,
|
||||||
|
path TEXT,
|
||||||
|
category TEXT,
|
||||||
|
reference_id TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_org ON gpi.projects(organization_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_parts_project ON gpi.parts(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_schemes_project ON gpi.painting_schemes(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_records_project ON gpi.application_records(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inspections_project ON gpi.inspections(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_org ON gpi.stock_items(organization_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON gpi.notifications(recipient_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_to ON gpi.messages(to_user_id, is_read);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_geometry_types_org ON gpi.geometry_types(organization_id);
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import mongoose from 'mongoose';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
// Import Models
|
|
||||||
import Project from '../models/Project.js';
|
|
||||||
import Part from '../models/Part.js';
|
|
||||||
import PaintingScheme from '../models/PaintingScheme.js';
|
|
||||||
import ApplicationRecord from '../models/ApplicationRecord.js';
|
|
||||||
import Inspection from '../models/Inspection.js';
|
|
||||||
import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
|
|
||||||
import YieldStudy from '../models/YieldStudy.js';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
|
||||||
|
|
||||||
const readJson = (filename: string) => {
|
|
||||||
const filePath = path.join(DATA_DIR, filename);
|
|
||||||
if (!fs.existsSync(filePath)) return [];
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const migrate = async () => {
|
|
||||||
try {
|
|
||||||
const uri = process.env.MONGODB_URI;
|
|
||||||
if (!uri) throw new Error('MONGODB_URI not defined');
|
|
||||||
await mongoose.connect(uri);
|
|
||||||
console.log('Connected to MongoDB for migration...');
|
|
||||||
|
|
||||||
const idMap = new Map<string, mongoose.Types.ObjectId>();
|
|
||||||
|
|
||||||
// 1. TechnicalDataSheets
|
|
||||||
console.log('Migrating TechnicalDataSheets...');
|
|
||||||
const datasheets = readJson('datasheets.json');
|
|
||||||
for (const ds of datasheets) {
|
|
||||||
const newDs = new TechnicalDataSheet({
|
|
||||||
...ds,
|
|
||||||
_id: new mongoose.Types.ObjectId()
|
|
||||||
});
|
|
||||||
await newDs.save();
|
|
||||||
idMap.set(ds.id, newDs._id as mongoose.Types.ObjectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Projects
|
|
||||||
console.log('Migrating Projects...');
|
|
||||||
const projects = readJson('projects.json');
|
|
||||||
for (const p of projects) {
|
|
||||||
const newP = new Project({
|
|
||||||
...p,
|
|
||||||
_id: new mongoose.Types.ObjectId()
|
|
||||||
});
|
|
||||||
await newP.save();
|
|
||||||
idMap.set(p.id, newP._id as mongoose.Types.ObjectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Parts
|
|
||||||
console.log('Migrating Parts...');
|
|
||||||
const parts = readJson('parts.json');
|
|
||||||
for (const part of parts) {
|
|
||||||
const projectId = idMap.get(part.projectId);
|
|
||||||
if (projectId) {
|
|
||||||
const newPart = new Part({ ...part, projectId });
|
|
||||||
await newPart.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. PaintingSchemes
|
|
||||||
console.log('Migrating PaintingSchemes...');
|
|
||||||
const schemes = readJson('paintingSchemes.json');
|
|
||||||
for (const s of schemes) {
|
|
||||||
const projectId = idMap.get(s.projectId);
|
|
||||||
if (projectId) {
|
|
||||||
const newS = new PaintingScheme({ ...s, projectId });
|
|
||||||
await newS.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. ApplicationRecords
|
|
||||||
console.log('Migrating ApplicationRecords...');
|
|
||||||
const records = readJson('applicationRecords.json');
|
|
||||||
for (const r of records) {
|
|
||||||
const projectId = idMap.get(r.projectId);
|
|
||||||
if (projectId) {
|
|
||||||
const newR = new ApplicationRecord({ ...r, projectId });
|
|
||||||
await newR.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Inspections
|
|
||||||
console.log('Migrating Inspections...');
|
|
||||||
const inspections = readJson('inspections.json');
|
|
||||||
for (const i of inspections) {
|
|
||||||
const projectId = idMap.get(i.projectId);
|
|
||||||
if (projectId) {
|
|
||||||
const newI = new Inspection({ ...i, projectId });
|
|
||||||
await newI.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. YieldStudies
|
|
||||||
console.log('Migrating YieldStudies...');
|
|
||||||
const studies = readJson('yield_studies.json');
|
|
||||||
for (const s of studies) {
|
|
||||||
const dataSheetId = idMap.get(s.dataSheetId);
|
|
||||||
if (dataSheetId) {
|
|
||||||
const newS = new YieldStudy({ ...s, dataSheetId });
|
|
||||||
await newS.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Migration completed successfully!');
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Migration failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
migrate();
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import bcrypt from 'bcryptjs';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
const MONGODB_URI = 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0';
|
|
||||||
|
|
||||||
const UserSchema = new mongoose.Schema({
|
|
||||||
externalId: { type: String, required: true, unique: true },
|
|
||||||
email: { type: String, required: true, unique: true },
|
|
||||||
passwordHash: { type: String, required: true },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
role: { type: String, enum: ['guest', 'user', 'admin'], default: 'guest' },
|
|
||||||
organizationId: String,
|
|
||||||
isBanned: { type: Boolean, default: false }
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
async function fixAdmin() {
|
|
||||||
try {
|
|
||||||
await mongoose.connect(MONGODB_URI);
|
|
||||||
console.log('✅ Conectado ao MongoDB');
|
|
||||||
|
|
||||||
const User = mongoose.models.User || mongoose.model('User', UserSchema);
|
|
||||||
|
|
||||||
const email = 'admtracksteel@gmail.com';
|
|
||||||
const password = 'admin'; // Senha padrão temporária
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
|
||||||
|
|
||||||
// Tenta encontrar o usuário existente
|
|
||||||
let user = await User.findOne({ email });
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
const result = await User.updateOne(
|
|
||||||
{ email },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
role: 'admin',
|
|
||||||
passwordHash: hashedPassword,
|
|
||||||
name: 'Admin TrackSteel'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
console.log(`✅ Usuário ${email} atualizado para admin e senha definida.`);
|
|
||||||
} else {
|
|
||||||
const fakeAuthId = `user_${uuidv4().replace(/-/g, '')}`;
|
|
||||||
await User.create({
|
|
||||||
externalId: fakeAuthId,
|
|
||||||
email,
|
|
||||||
passwordHash: hashedPassword,
|
|
||||||
name: 'Admin TrackSteel',
|
|
||||||
role: 'admin',
|
|
||||||
organizationId: 'default-org'
|
|
||||||
});
|
|
||||||
console.log(`✅ Usuário ${email} criado como admin (AuthId: ${fakeAuthId}).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listar todos os usuários
|
|
||||||
const users = await User.find({});
|
|
||||||
console.log('\n📋 Usuários atuais:');
|
|
||||||
users.forEach((u, i) => {
|
|
||||||
console.log(` ${i + 1}. ${u.email} | role: ${u.role} | hasHash: ${!!u.passwordHash}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ Erro durante a execução:', err);
|
|
||||||
} finally {
|
|
||||||
await mongoose.disconnect();
|
|
||||||
console.log('\n✅ Desconectado do MongoDB');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fixAdmin();
|
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
import Notification, { INotification } from '../models/Notification.js';
|
import { query } from '../config/database.js';
|
||||||
import StockItem from '../models/StockItem.js';
|
|
||||||
import Instrument from '../models/Instrument.js';
|
|
||||||
import { addMonths, isBefore } from 'date-fns';
|
import { addMonths, isBefore } from 'date-fns';
|
||||||
|
|
||||||
|
export interface NotificationData {
|
||||||
|
id?: string;
|
||||||
|
organization_id: string;
|
||||||
|
recipient_id?: string | null;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: 'info' | 'warning' | 'error' | 'success';
|
||||||
|
is_read: boolean;
|
||||||
|
is_archived: boolean;
|
||||||
|
archived_by: string[];
|
||||||
|
deleted_by: string[];
|
||||||
|
metadata?: any;
|
||||||
|
created_at?: Date;
|
||||||
|
updated_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export const notificationService = {
|
export const notificationService = {
|
||||||
// Criar uma notificação
|
// Criar uma notificação
|
||||||
async create(data: Partial<INotification>) {
|
async create(data: Partial<NotificationData>) {
|
||||||
try {
|
try {
|
||||||
const notification = new Notification(data);
|
const res = await query(
|
||||||
await notification.save();
|
`INSERT INTO gpi.notifications (
|
||||||
return notification;
|
organization_id, recipient_id, title, message, type, metadata
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||||
|
[data.organization_id, data.recipient_id || null, data.title, data.message, data.type || 'info', data.metadata || {}]
|
||||||
|
);
|
||||||
|
return res.rows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating notification:', error);
|
console.error('Error creating notification:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -22,21 +40,17 @@ export const notificationService = {
|
|||||||
const graceDate = new Date();
|
const graceDate = new Date();
|
||||||
graceDate.setDate(graceDate.getDate() - graceDays);
|
graceDate.setDate(graceDate.getDate() - graceDays);
|
||||||
|
|
||||||
const query: Record<string, unknown> = {
|
// No Postgres com JSONB, usamos o operador @> para verificar se a metadata contém as chaves/valores
|
||||||
organizationId: orgId
|
const res = await query(
|
||||||
};
|
`SELECT id FROM gpi.notifications
|
||||||
|
WHERE organization_id = $1
|
||||||
|
AND metadata @> $2::jsonb
|
||||||
|
AND created_at >= $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[orgId, JSON.stringify(metadata), graceDate]
|
||||||
|
);
|
||||||
|
|
||||||
// Adicionar campos de metadata à query
|
return res.rows.length > 0;
|
||||||
for (const [key, value] of Object.entries(metadata)) {
|
|
||||||
query[`metadata.${key}`] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se existe alguma notificação com essa metadata nos últimos graceDays
|
|
||||||
// Independente de estar lida ou não, para evitar duplicidade.
|
|
||||||
query.createdAt = { $gte: graceDate };
|
|
||||||
|
|
||||||
const existing = await Notification.findOne(query);
|
|
||||||
return !!existing;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking notification existence:', error);
|
console.error('Error checking notification existence:', error);
|
||||||
return false;
|
return false;
|
||||||
@@ -46,22 +60,22 @@ export const notificationService = {
|
|||||||
// Obter notificações de um usuário (ou globais da organização)
|
// Obter notificações de um usuário (ou globais da organização)
|
||||||
async getUserNotifications(userId: string, organizationId: string, includeArchived: boolean = false) {
|
async getUserNotifications(userId: string, organizationId: string, includeArchived: boolean = false) {
|
||||||
try {
|
try {
|
||||||
const query: Record<string, unknown> = {
|
let sql = `
|
||||||
organizationId,
|
SELECT * FROM gpi.notifications
|
||||||
$or: [
|
WHERE organization_id = $1
|
||||||
{ recipientId: userId },
|
AND (recipient_id = $2 OR recipient_id IS NULL)
|
||||||
{ recipientId: null } // Notificações globais
|
AND NOT ($2 = ANY(deleted_by))
|
||||||
],
|
`;
|
||||||
deletedBy: { $ne: userId } // Não mostrar as deletadas pelo usuário
|
const params: any[] = [organizationId, userId];
|
||||||
};
|
|
||||||
|
|
||||||
if (!includeArchived) {
|
if (!includeArchived) {
|
||||||
// Filtra as arquivadas (pelo usuário ou globalmente)
|
sql += ' AND is_archived = false AND NOT ($2 = ANY(archived_by))';
|
||||||
query.isArchived = false;
|
|
||||||
query.archivedBy = { $ne: userId };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await Notification.find(query).sort({ createdAt: -1 }).limit(50);
|
sql += ' ORDER BY created_at DESC LIMIT 50';
|
||||||
|
|
||||||
|
const res = await query(sql, params);
|
||||||
|
return res.rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching notifications:', error);
|
console.error('Error fetching notifications:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -71,7 +85,11 @@ export const notificationService = {
|
|||||||
// Marcar como lida
|
// Marcar como lida
|
||||||
async markAsRead(id: string) {
|
async markAsRead(id: string) {
|
||||||
try {
|
try {
|
||||||
return await Notification.findByIdAndUpdate(id, { isRead: true }, { new: true });
|
const res = await query(
|
||||||
|
'UPDATE gpi.notifications SET is_read = true, updated_at = NOW() WHERE id = $1 RETURNING *',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return res.rows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error marking notification as read:', error);
|
console.error('Error marking notification as read:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -81,16 +99,13 @@ export const notificationService = {
|
|||||||
// Marcar todas como lidas para um usuário
|
// Marcar todas como lidas para um usuário
|
||||||
async markAllAsRead(userId: string, organizationId: string) {
|
async markAllAsRead(userId: string, organizationId: string) {
|
||||||
try {
|
try {
|
||||||
return await Notification.updateMany(
|
return await query(
|
||||||
{
|
`UPDATE gpi.notifications
|
||||||
organizationId,
|
SET is_read = true, updated_at = NOW()
|
||||||
$or: [
|
WHERE organization_id = $1
|
||||||
{ recipientId: userId },
|
AND (recipient_id = $2 OR recipient_id IS NULL)
|
||||||
{ recipientId: null }
|
AND is_read = false`,
|
||||||
],
|
[organizationId, userId]
|
||||||
isRead: false
|
|
||||||
},
|
|
||||||
{ isRead: true }
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error marking all notifications as read:', error);
|
console.error('Error marking all notifications as read:', error);
|
||||||
@@ -101,25 +116,18 @@ export const notificationService = {
|
|||||||
// Arquivar uma notificação para um usuário
|
// Arquivar uma notificação para um usuário
|
||||||
async archive(id: string, userId: string) {
|
async archive(id: string, userId: string) {
|
||||||
try {
|
try {
|
||||||
const notification = await Notification.findById(id);
|
// Se for pessoal, marca is_archived. Se for global, adiciona ao array archived_by.
|
||||||
if (!notification) return null;
|
const res = await query(
|
||||||
|
`UPDATE gpi.notifications
|
||||||
if (notification.recipientId) {
|
SET
|
||||||
// Notificação pessoal
|
is_archived = CASE WHEN recipient_id IS NOT NULL THEN true ELSE is_archived END,
|
||||||
notification.isArchived = true;
|
archived_by = CASE WHEN recipient_id IS NULL THEN array_append(archived_by, $2) ELSE archived_by END,
|
||||||
notification.isRead = true;
|
is_read = true,
|
||||||
} else {
|
updated_at = NOW()
|
||||||
// Notificação global
|
WHERE id = $1 RETURNING *`,
|
||||||
if (!notification.archivedBy.includes(userId)) {
|
[id, userId]
|
||||||
notification.archivedBy.push(userId);
|
);
|
||||||
}
|
return res.rows[0];
|
||||||
// Marcar como lida também? Opcional
|
|
||||||
if (!notification.readBy?.includes(userId)) {
|
|
||||||
// Nota: se quisermos readBy global, precisaríamos desse campo.
|
|
||||||
// Para simplificar, vamos assumir que arquivar esconde da lista ativa.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return await notification.save();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error archiving notification:', error);
|
console.error('Error archiving notification:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -129,18 +137,19 @@ export const notificationService = {
|
|||||||
// Deletar (esconder) uma notificação para um usuário
|
// Deletar (esconder) uma notificação para um usuário
|
||||||
async softDelete(id: string, userId: string) {
|
async softDelete(id: string, userId: string) {
|
||||||
try {
|
try {
|
||||||
const notification = await Notification.findById(id);
|
// Se for pessoal, deletamos. Se for global, adicionamos ao deleted_by.
|
||||||
if (!notification) return null;
|
const checkRes = await query('SELECT recipient_id FROM gpi.notifications WHERE id = $1', [id]);
|
||||||
|
if (checkRes.rows.length === 0) return null;
|
||||||
|
|
||||||
if (notification.recipientId && notification.recipientId === userId) {
|
if (checkRes.rows[0].recipient_id === userId) {
|
||||||
// Se for pessoal, podemos deletar do banco ou apenas marcar
|
await query('DELETE FROM gpi.notifications WHERE id = $1', [id]);
|
||||||
return await Notification.findByIdAndDelete(id);
|
return { id, deleted: true };
|
||||||
} else {
|
} else {
|
||||||
// Se for global, apenas adicionar ao deletedBy
|
const res = await query(
|
||||||
if (!notification.deletedBy.includes(userId)) {
|
'UPDATE gpi.notifications SET deleted_by = array_append(deleted_by, $2), updated_at = NOW() WHERE id = $1 RETURNING *',
|
||||||
notification.deletedBy.push(userId);
|
[id, userId]
|
||||||
}
|
);
|
||||||
return await notification.save();
|
return res.rows[0];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error soft deleting notification:', error);
|
console.error('Error soft deleting notification:', error);
|
||||||
@@ -151,23 +160,19 @@ export const notificationService = {
|
|||||||
// Limpar todas (esconder todas as atuais)
|
// Limpar todas (esconder todas as atuais)
|
||||||
async clearAll(userId: string, organizationId: string) {
|
async clearAll(userId: string, organizationId: string) {
|
||||||
try {
|
try {
|
||||||
// Para notificações pessoais: Deletar
|
// Deletar as pessoais
|
||||||
await Notification.deleteMany({
|
await query(
|
||||||
organizationId,
|
'DELETE FROM gpi.notifications WHERE organization_id = $1 AND recipient_id = $2',
|
||||||
recipientId: userId
|
[organizationId, userId]
|
||||||
});
|
);
|
||||||
|
|
||||||
// Para notificações globais: Marcar como deletadas por esse usuário
|
// Adicionar às globais deletadas
|
||||||
const globalNotifications = await Notification.find({
|
await query(
|
||||||
organizationId,
|
`UPDATE gpi.notifications
|
||||||
recipientId: null,
|
SET deleted_by = array_append(deleted_by, $2), updated_at = NOW()
|
||||||
deletedBy: { $ne: userId }
|
WHERE organization_id = $1 AND recipient_id IS NULL AND NOT ($2 = ANY(deleted_by))`,
|
||||||
});
|
[organizationId, userId]
|
||||||
|
);
|
||||||
for (const notif of globalNotifications) {
|
|
||||||
notif.deletedBy.push(userId);
|
|
||||||
await notif.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -180,87 +185,57 @@ export const notificationService = {
|
|||||||
async checkStockExpirations() {
|
async checkStockExpirations() {
|
||||||
console.log('Running stock expiration checkJob...');
|
console.log('Running stock expiration checkJob...');
|
||||||
try {
|
try {
|
||||||
// Buscar todos os itens de estoque com data de validade que ainda não venceram ou venceram recentemente
|
const res = await query(
|
||||||
// Otimização: Em um sistema real, faríamos isso por query direta, mas aqui vamos iterar para aplicar a lógica de 2 meses, 1 mês, vencido.
|
'SELECT * FROM gpi.stock_items WHERE expiration_date IS NOT NULL AND quantity > 0'
|
||||||
const stockItems = await StockItem.find({ expirationDate: { $exists: true, $ne: null }, quantity: { $gt: 0 } });
|
);
|
||||||
|
const stockItems = res.rows;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const twoMonthsFromNow = addMonths(now, 2);
|
const twoMonthsFromNow = addMonths(now, 2);
|
||||||
const oneMonthFromNow = addMonths(now, 1);
|
const oneMonthFromNow = addMonths(now, 1);
|
||||||
|
|
||||||
for (const item of stockItems) {
|
for (const item of stockItems) {
|
||||||
if (!item.expirationDate) continue;
|
const expirationDate = new Date(item.expiration_date);
|
||||||
|
const itemId = item.id;
|
||||||
const expirationDate = new Date(item.expirationDate);
|
const orgId = item.organization_id;
|
||||||
const itemId = item._id.toString();
|
|
||||||
const orgId = item.organizationId;
|
|
||||||
|
|
||||||
if (!orgId) continue;
|
if (!orgId) continue;
|
||||||
|
|
||||||
let message = '';
|
let message = '';
|
||||||
let title = '';
|
let title = '';
|
||||||
let type: 'warning' | 'error' = 'warning';
|
let type: 'warning' | 'error' | 'info' = 'warning';
|
||||||
|
let triggerType = '';
|
||||||
|
|
||||||
// Lógica de notificação
|
|
||||||
// 1. Vencido
|
|
||||||
if (isBefore(expirationDate, now)) {
|
if (isBefore(expirationDate, now)) {
|
||||||
title = 'Item Vencido';
|
title = 'Item Vencido';
|
||||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} venceu em ${expirationDate.toLocaleDateString()}.`;
|
message = `O item ${item.rr_number} - Lote ${item.batch_number} venceu em ${expirationDate.toLocaleDateString()}.`;
|
||||||
type = 'error';
|
type = 'error';
|
||||||
|
triggerType = 'expired';
|
||||||
|
} else if (isBefore(expirationDate, oneMonthFromNow)) {
|
||||||
|
title = 'Vencimento Próximo (1 mês)';
|
||||||
|
message = `O item ${item.rr_number} - Lote ${item.batch_number} vencerá em menos de 1 mês (${expirationDate.toLocaleDateString()}).`;
|
||||||
|
type = 'warning';
|
||||||
|
triggerType = 'expire_1_month';
|
||||||
|
} else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
||||||
|
title = 'Vencimento em 2 meses';
|
||||||
|
message = `O item ${item.rr_number} - Lote ${item.batch_number} vencerá em 2 meses (${expirationDate.toLocaleDateString()}).`;
|
||||||
|
type = 'info';
|
||||||
|
triggerType = 'expire_2_months';
|
||||||
|
}
|
||||||
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
if (triggerType) {
|
||||||
|
const notified = await this.isAlreadyNotified(orgId, {
|
||||||
stockItemId: itemId,
|
stockItemId: itemId,
|
||||||
triggerType: 'expired'
|
triggerType
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!notified) {
|
if (!notified) {
|
||||||
await this.create({
|
await this.create({
|
||||||
organizationId: orgId,
|
organization_id: orgId,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
type,
|
type,
|
||||||
metadata: { stockItemId: itemId, triggerType: 'expired' }
|
metadata: { stockItemId: itemId, triggerType }
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. Vence em 1 mês (aprox)
|
|
||||||
else if (isBefore(expirationDate, oneMonthFromNow)) {
|
|
||||||
title = 'Vencimento Próximo (1 mês)';
|
|
||||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em menos de 1 mês (${expirationDate.toLocaleDateString()}).`;
|
|
||||||
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
stockItemId: itemId,
|
|
||||||
triggerType: 'expire_1_month'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type: 'warning',
|
|
||||||
metadata: { stockItemId: itemId, triggerType: 'expire_1_month' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
// 3. Vence em 2 meses (aprox)
|
|
||||||
else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
|
||||||
title = 'Vencimento em 2 meses';
|
|
||||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em 2 meses (${expirationDate.toLocaleDateString()}).`;
|
|
||||||
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
stockItemId: itemId,
|
|
||||||
triggerType: 'expire_2_months'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type: 'info',
|
|
||||||
metadata: { stockItemId: itemId, triggerType: 'expire_2_months' }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,130 +243,5 @@ export const notificationService = {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in checkStockExpirations:', error);
|
console.error('Error in checkStockExpirations:', error);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
// Verificar calibração de instrumentos
|
|
||||||
async checkInstrumentCalibrations() {
|
|
||||||
console.log('Running instrument calibration checkJob...');
|
|
||||||
try {
|
|
||||||
const instruments = await Instrument.find({
|
|
||||||
calibrationExpirationDate: { $exists: true, $ne: null },
|
|
||||||
status: { $ne: 'inactive' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const twoMonthsFromNow = addMonths(now, 2);
|
|
||||||
const oneMonthFromNow = addMonths(now, 1);
|
|
||||||
|
|
||||||
for (const instrument of instruments) {
|
|
||||||
if (!instrument.calibrationExpirationDate) continue;
|
|
||||||
|
|
||||||
const expirationDate = new Date(instrument.calibrationExpirationDate);
|
|
||||||
const instrumentId = instrument._id.toString();
|
|
||||||
const orgId = instrument.organizationId;
|
|
||||||
|
|
||||||
if (!orgId) continue;
|
|
||||||
|
|
||||||
let title = '';
|
|
||||||
let message = '';
|
|
||||||
let type: 'info' | 'warning' | 'error' = 'info';
|
|
||||||
let triggerType = '';
|
|
||||||
|
|
||||||
// 1. Vencido
|
|
||||||
if (isBefore(expirationDate, now)) {
|
|
||||||
title = 'Calibração Vencida';
|
|
||||||
message = `O instrumento ${instrument.name} (${instrument.serialNumber}) está com a calibração vencida desde ${expirationDate.toLocaleDateString()}.`;
|
|
||||||
type = 'error';
|
|
||||||
triggerType = 'calibration_expired';
|
|
||||||
|
|
||||||
// Atualizar status para expired se não estiver
|
|
||||||
if (instrument.status !== 'expired') {
|
|
||||||
instrument.status = 'expired';
|
|
||||||
await instrument.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
// 2. Vence em 1 mês
|
|
||||||
else if (isBefore(expirationDate, oneMonthFromNow)) {
|
|
||||||
title = 'Calibração vence em 1 mês';
|
|
||||||
message = `A calibração do instrumento ${instrument.name} (${instrument.serialNumber}) vence em ${expirationDate.toLocaleDateString()}.`;
|
|
||||||
type = 'warning';
|
|
||||||
triggerType = 'calibration_1_month';
|
|
||||||
}
|
|
||||||
// 3. Vence em 2 meses
|
|
||||||
else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
|
||||||
title = 'Calibração vence em 2 meses';
|
|
||||||
message = `A calibração do instrumento ${instrument.name} (${instrument.serialNumber}) vence em ${expirationDate.toLocaleDateString()}.`;
|
|
||||||
type = 'info';
|
|
||||||
triggerType = 'calibration_2_months';
|
|
||||||
} else {
|
|
||||||
continue; // Não precisa notificar
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evitar spam
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
instrumentId,
|
|
||||||
triggerType
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
type,
|
|
||||||
metadata: { instrumentId, triggerType }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in checkInstrumentCalibrations:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Verificar se o estoque está abaixo do mínimo (Aggregated by Product + Color)
|
|
||||||
async checkLowStock(stockItemId: string) {
|
|
||||||
try {
|
|
||||||
const item = await StockItem.findById(stockItemId).populate('dataSheetId', 'name manufacturer');
|
|
||||||
if (!item || !item.minStock || item.minStock <= 0) return;
|
|
||||||
|
|
||||||
const orgId = item.organizationId;
|
|
||||||
if (!orgId) return;
|
|
||||||
|
|
||||||
// Aggregate total quantity for this Product + Color
|
|
||||||
const siblings = await StockItem.find({
|
|
||||||
organizationId: orgId,
|
|
||||||
dataSheetId: item.dataSheetId,
|
|
||||||
color: item.color
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalQuantity = siblings.reduce((sum, s) => sum + s.quantity, 0);
|
|
||||||
|
|
||||||
if (totalQuantity < item.minStock) {
|
|
||||||
// Check throttling
|
|
||||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
|
||||||
stockItemId: stockItemId, // Keep using specific item ID as reference or maybe composite key?
|
|
||||||
// Let's use a composite key for the trigger to avoid spamming for every batch in the group
|
|
||||||
productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`,
|
|
||||||
triggerType: 'low_stock_aggregated'
|
|
||||||
}, 3);
|
|
||||||
|
|
||||||
if (!notified) {
|
|
||||||
await this.create({
|
|
||||||
organizationId: orgId,
|
|
||||||
title: 'Estoque Baixo (Total)',
|
|
||||||
message: `O produto ${item.dataSheetId?.name} (Cor: ${item.color || 'N/A'}) atingiu o nível crítico. Total: ${totalQuantity.toFixed(1)}${item.unit}. (Mínimo: ${item.minStock}${item.unit})`,
|
|
||||||
type: 'error',
|
|
||||||
metadata: {
|
|
||||||
stockItemId,
|
|
||||||
productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`,
|
|
||||||
triggerType: 'low_stock_aggregated'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking low stock:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,55 +1,83 @@
|
|||||||
import YieldStudy from '../models/YieldStudy.js';
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
export const getAllStudies = async (organizationId?: string) => {
|
export const getAllStudies = async (organizationId?: string) => {
|
||||||
const query = organizationId ? { organizationId } : {};
|
let sql = 'SELECT * FROM gpi.yield_studies';
|
||||||
const studies = await YieldStudy.find(query).populate('dataSheetId').sort({ createdAt: -1 }).lean();
|
const params: any[] = [];
|
||||||
return studies.map(s => ({ ...s, id: s._id.toString() }));
|
if (organizationId) {
|
||||||
|
sql += ' WHERE organization_id = $1';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
sql += ' ORDER BY created_at DESC';
|
||||||
|
const res = await query(sql, params);
|
||||||
|
return res.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const createStudy = async (data: any) => {
|
||||||
export const createStudy = async (data: any & { organizationId?: string }) => {
|
const res = await query(
|
||||||
const newStudy = new YieldStudy({ ...data, organizationId: data.organizationId });
|
`INSERT INTO gpi.yield_studies (
|
||||||
const saved = await newStudy.save();
|
organization_id, data_sheet_id, name, target_dft,
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
dilution_percent, categories, total_weight, estimated_paint_volume,
|
||||||
|
estimated_reducer_volume, estimated_paint_volume_by_area,
|
||||||
|
estimated_reducer_volume_by_area, average_complexity
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.dataSheetId, data.name, data.targetDft,
|
||||||
|
data.dilutionPercent || 0, JSON.stringify(data.categories || []),
|
||||||
|
data.totalWeight, data.estimatedPaintVolume, data.estimatedReducerVolume,
|
||||||
|
data.estimatedPaintVolumeByArea, data.estimatedReducerVolumeByArea,
|
||||||
|
data.averageComplexity
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return res.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const updateStudy = async (id: string, updates: any, organizationId?: string) => {
|
export const updateStudy = async (id: string, updates: any, organizationId?: string) => {
|
||||||
// SECURITY FIX: Allow update if:
|
const check = await query('SELECT * FROM gpi.yield_studies WHERE id = $1', [id]);
|
||||||
// 1. Matches ID AND Matches Organization
|
if (check.rows.length === 0) return null;
|
||||||
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
|
const existing = check.rows[0];
|
||||||
|
|
||||||
const existing = await YieldStudy.findById(id);
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (!existing) return null;
|
|
||||||
|
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
console.warn(`Access Denied: Study ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
const fields: string[] = [];
|
||||||
updates.organizationId = organizationId;
|
const params: any[] = [id];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
const updatable = [
|
||||||
|
'name', 'dataSheetId', 'targetDft', 'dilutionPercent', 'categories',
|
||||||
|
'totalWeight', 'estimatedPaintVolume', 'estimatedReducerVolume',
|
||||||
|
'estimatedPaintVolumeByArea', 'estimatedReducerVolumeByArea', 'averageComplexity'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of updatable) {
|
||||||
|
if (updates[key] !== undefined) {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${idx++}`);
|
||||||
|
if (key === 'categories') {
|
||||||
|
params.push(JSON.stringify(updates[key]));
|
||||||
|
} else {
|
||||||
|
params.push(updates[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await YieldStudy.findOneAndUpdate({ _id: id }, updates, { new: true }).lean();
|
if (fields.length === 0) return existing;
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
const sql = `UPDATE gpi.yield_studies SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1 RETURNING *`;
|
||||||
}
|
const res = await query(sql, params);
|
||||||
return null;
|
return res.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteStudy = async (id: string, organizationId?: string) => {
|
export const deleteStudy = async (id: string, organizationId?: string) => {
|
||||||
// SECURITY FIX: Same logic as update - allow delete if owned OR if orphan
|
const check = await query('SELECT organization_id FROM gpi.yield_studies WHERE id = $1', [id]);
|
||||||
const existing = await YieldStudy.findById(id);
|
if (check.rows.length === 0) return false;
|
||||||
if (!existing) return false;
|
|
||||||
|
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
if (organizationId && check.rows[0].organization_id && check.rows[0].organization_id !== organizationId) {
|
||||||
console.warn(`Access Denied: Delete Study ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await YieldStudy.findByIdAndDelete(id);
|
await query('DELETE FROM gpi.yield_studies WHERE id = $1', [id]);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
6
src/server/types/express.d.ts
vendored
6
src/server/types/express.d.ts
vendored
@@ -1,10 +1,10 @@
|
|||||||
import { IUser } from '../models/User.js';
|
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
appUser?: IUser;
|
appUser?: IAppUser;
|
||||||
userId?: string;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export {};
|
||||||
|
|||||||
@@ -1,125 +1,144 @@
|
|||||||
|
export type UserRole = 'guest' | 'user' | 'admin';
|
||||||
|
export type OrgRole = 'guest' | 'user' | 'admin';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
external_id: string; // ex-clerk_id
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
is_banned: boolean;
|
||||||
|
last_seen_at: Date;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
is_banned: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserOrganization {
|
||||||
|
user_id: string;
|
||||||
|
organization_id: string;
|
||||||
|
role: OrgRole;
|
||||||
|
is_banned: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
client: string;
|
client: string;
|
||||||
startDate?: Date | string | null;
|
start_date?: Date;
|
||||||
endDate?: Date | string | null;
|
end_date?: Date;
|
||||||
technician?: string | null;
|
technician?: string;
|
||||||
environment?: string | null;
|
environment?: string;
|
||||||
createdAt: Date | string;
|
weight_kg?: number;
|
||||||
updatedAt: Date | string;
|
status: 'active' | 'archived';
|
||||||
parts?: Part[];
|
created_at: Date;
|
||||||
paintingSchemes?: PaintingScheme[];
|
updated_at: Date;
|
||||||
applicationRecords?: ApplicationRecord[];
|
|
||||||
inspections?: Inspection[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Part {
|
export interface Part {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
project_id: string;
|
||||||
description: string;
|
organization_id: string;
|
||||||
dimensions?: string | null;
|
|
||||||
weight?: number | null;
|
|
||||||
type?: string | null;
|
|
||||||
area?: number | null;
|
|
||||||
complexity?: number | null;
|
|
||||||
quantity: number;
|
|
||||||
notes?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaintingScheme {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
type?: string | null;
|
description?: string;
|
||||||
solidsVolume?: number | null;
|
quantity: number;
|
||||||
yieldTheoretical?: number | null;
|
weight_kg?: number;
|
||||||
epsMin?: number | null;
|
drawing_number?: string;
|
||||||
epsMax?: number | null;
|
created_at: Date;
|
||||||
dilution?: number | null;
|
updated_at: Date;
|
||||||
manufacturer?: string | null;
|
|
||||||
color?: string | null;
|
|
||||||
notes?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApplicationRecord {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
coatStage: string;
|
|
||||||
pieceDescription?: string | null;
|
|
||||||
date?: Date | string | null;
|
|
||||||
operator?: string | null;
|
|
||||||
realWeight?: number | null; // Peso Real (kg)
|
|
||||||
volumeUsed?: number | null; // Volume Utilizado (L)
|
|
||||||
areaPainted?: number | null; // Área Pintada (m²)
|
|
||||||
wetThicknessAvg?: number | null; // Espessura Úmida Média (µm)
|
|
||||||
dryThicknessCalc?: number | null; // Espessura Seca Calculada (µm)
|
|
||||||
method?: string | null;
|
|
||||||
realYield?: number | null; // Rendimento Real (m²/L)
|
|
||||||
diluentUsed?: number | null; // Consumo estimado de Diluente (L)
|
|
||||||
notes?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Inspection {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
date?: Date | string | null;
|
|
||||||
inspector?: string | null;
|
|
||||||
pieceDescription?: string | null;
|
|
||||||
epsPoints?: (number | null)[];
|
|
||||||
adhesionTest?: string | null; // Teste de Aderência
|
|
||||||
appearance?: string | null; // Aspecto Visual
|
|
||||||
defects?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TechnicalDataSheet {
|
export interface TechnicalDataSheet {
|
||||||
id: string;
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
type?: string; // 'epoxi', 'pu', 'diluent', etc
|
manufacturer_code?: string;
|
||||||
fileUrl: string; // Path or URL to PDF
|
type?: string;
|
||||||
uploadDate: Date | string;
|
min_stock?: number;
|
||||||
|
typical_application?: string;
|
||||||
// Technical Properties for Analysis
|
file_id?: string;
|
||||||
solidsVolume?: number; // %
|
file_url?: string;
|
||||||
density?: number; // g/cm3
|
upload_date: Date;
|
||||||
mixingRatio?: string;
|
solids_volume?: number;
|
||||||
mixingRatioWeight?: string;
|
density?: number;
|
||||||
mixingRatioVolume?: string;
|
mixing_ratio?: string;
|
||||||
wftMin?: number;
|
mixing_ratio_weight?: string;
|
||||||
wftMax?: number;
|
mixing_ratio_volume?: string;
|
||||||
dftMin?: number;
|
wft_min?: number;
|
||||||
dftMax?: number;
|
wft_max?: number;
|
||||||
|
dft_min?: number;
|
||||||
|
dft_max?: number;
|
||||||
reducer?: string;
|
reducer?: string;
|
||||||
yieldTheoretical?: number; // m2/L
|
yield_theoretical?: number;
|
||||||
dftReference?: number; // µm (Espessura de camada seca de referência para o rendimento teórico)
|
dft_reference?: number;
|
||||||
yieldFactor?: number; // Razão m²/L por µm (Calculado como rendimento * espessura / 1 ou simplesmente sólidos * 10)
|
yield_factor?: number;
|
||||||
dilution?: number; // % sugerida
|
dilution?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PieceCategory {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
organization_id: string;
|
||||||
weight: number; // Total weight in Kg
|
recipient_id?: string;
|
||||||
historicalYield: number; // L/Kg (Historical consumption)
|
title: string;
|
||||||
historicalDft: number; // µm (Reference DFT for the historical yield)
|
message: string;
|
||||||
efficiency: number; // % (Expected application efficiency)
|
type: 'info' | 'warning' | 'error' | 'success';
|
||||||
|
is_read: boolean;
|
||||||
|
is_archived: boolean;
|
||||||
|
archived_by: string[];
|
||||||
|
deleted_by: string[];
|
||||||
|
metadata?: any;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YieldStudy {
|
export interface StockItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
organization_id: string;
|
||||||
dataSheetId: string; // Ficha Técnica selecionada
|
created_by?: string;
|
||||||
targetDft: number; // µm (Desired dry thickness)
|
data_sheet_id: string;
|
||||||
dilutionPercent: number; // % (Expected dilution)
|
rr_number: string;
|
||||||
categories: PieceCategory[];
|
batch_number: string;
|
||||||
createdAt: Date | string;
|
color?: string;
|
||||||
updatedAt: Date | string;
|
invoice_number?: string;
|
||||||
|
received_by?: string;
|
||||||
// Results (Calculated)
|
quantity: number;
|
||||||
totalWeight: number;
|
unit: string;
|
||||||
estimatedPaintVolume: number; // Liters
|
min_stock: number;
|
||||||
estimatedReducerVolume: number; // Liters
|
expiration_date?: Date;
|
||||||
averageComplexity: number;
|
entry_date: Date;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Instrument {
|
||||||
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model_name?: string;
|
||||||
|
serial_number: string;
|
||||||
|
calibration_date?: Date;
|
||||||
|
calibration_expiration_date?: Date;
|
||||||
|
certificate_url?: string;
|
||||||
|
status: 'active' | 'inactive' | 'maintenance' | 'expired';
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user