Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3563b9513 | |||
| e88d145df7 | |||
| 8c247d8afd | |||
| ede8c21c8d | |||
| cf90973671 | |||
| 2271946f1e | |||
| c92d7d7f88 | |||
| 9d34821c83 | |||
| e4f465f7c9 | |||
| 47f2f4c13f | |||
| 6898297935 |
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
FROM node:20-slim
|
FROM node:22-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build:client
|
RUN npm run build
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["npx", "tsx", "src/server/index.ts"]
|
CMD ["node", "dist/server/src/server/index.js"]
|
||||||
|
|||||||
-55
@@ -1,55 +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 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-clerk-user-id', 'x-organization-id', 'x-organization-name']
|
|
||||||
}));
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Global Middleware
|
|
||||||
app.use(extractUser);
|
|
||||||
|
|
||||||
// Static Uploads
|
|
||||||
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
app.use('/api/users', userRoutes);
|
|
||||||
app.use('/api/projects', projectRoutes);
|
|
||||||
app.use('/api/parts', partRoutes);
|
|
||||||
app.use('/api/painting-schemes', paintingSchemeRoutes);
|
|
||||||
app.use('/api/application-records', applicationRecordRoutes);
|
|
||||||
app.use('/api/inspections', inspectionRoutes);
|
|
||||||
app.use('/api', analysisRoutes);
|
|
||||||
app.use('/api/datasheets', dataSheetRoutes);
|
|
||||||
app.use('/api/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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { query } from './src/server/config/database.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log("Creating gpi.files...");
|
||||||
|
await query("CREATE TABLE IF NOT EXISTS gpi.files (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), filename TEXT NOT NULL, content_type TEXT NOT NULL, data BYTEA NOT NULL, size INTEGER NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());");
|
||||||
|
|
||||||
|
console.log("Creating gpi.stock_audit_logs...");
|
||||||
|
await query("CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID, stock_item_id UUID, movement_id UUID, movement_number INTEGER, user_id TEXT, user_name TEXT, action TEXT, details TEXT, old_values JSONB, new_values JSONB, timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW());");
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||||
|
QyNTUxOQAAACAp2sxQE/eO30etME9tLKJjguVPUg3W8k3j2H7F/kBVogAAAJAeNjMSHjYz
|
||||||
|
EgAAAAtzc2gtZWQyNTUxOQAAACAp2sxQE/eO30etME9tLKJjguVPUg3W8k3j2H7F/kBVog
|
||||||
|
AAAEDLm78AwM6lbNoz7iVUh1xvlphZzNhitquW4jHyR7lIhCnazFAT947fR60wT20somOC
|
||||||
|
5U9SDdbyTePYfsX+QFWiAAAAC2FudGlncmF2aXR5AQI=
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnazFAT947fR60wT20somOC5U9SDdbyTePYfsX+QFWi antigravity
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<link rel="apple-touch-icon" href="/pwa-192x192.png">
|
<link rel="apple-touch-icon" href="/pwa-192x192.png">
|
||||||
<title>SteelPaint</title>
|
<title>GPI - JWT VERSION 1.6</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
Generated
+161
-47
@@ -9,8 +9,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
|
||||||
"@types/mongoose": "^5.11.96",
|
"@types/mongoose": "^5.11.96",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vercel/speed-insights": "^1.3.1",
|
"@vercel/speed-insights": "^1.3.1",
|
||||||
@@ -23,13 +21,13 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"enhanced-resolve": "^5.18.4",
|
"enhanced-resolve": "^5.18.4",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"framer-motion": "^12.36.0",
|
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mongodb": "^7.0.0",
|
"mongodb": "^7.0.0",
|
||||||
"mongoose": "^9.1.5",
|
"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",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -44,10 +42,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@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",
|
"@vercel/node": "^5.5.28",
|
||||||
@@ -3341,6 +3342,7 @@
|
|||||||
"version": "2.4.6",
|
"version": "2.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
@@ -3453,6 +3455,7 @@
|
|||||||
"version": "9.0.10",
|
"version": "9.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/ms": "*",
|
"@types/ms": "*",
|
||||||
@@ -3470,6 +3473,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/multer": {
|
"node_modules/@types/multer": {
|
||||||
@@ -3484,11 +3488,24 @@
|
|||||||
"version": "24.10.9",
|
"version": "24.10.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
|
||||||
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -3501,7 +3518,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.9",
|
"version": "19.2.9",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -3517,7 +3534,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/react/node_modules/csstype": {
|
"node_modules/@types/react/node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/resolve": {
|
"node_modules/@types/resolve": {
|
||||||
@@ -6666,33 +6683,6 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/framer-motion": {
|
|
||||||
"version": "12.36.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.36.0.tgz",
|
|
||||||
"integrity": "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"motion-dom": "^12.36.0",
|
|
||||||
"motion-utils": "^12.36.0",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@emotion/is-prop-valid": "*",
|
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@emotion/is-prop-valid": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -8523,21 +8513,6 @@
|
|||||||
"url": "https://opencollective.com/mongoose"
|
"url": "https://opencollective.com/mongoose"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-dom": {
|
|
||||||
"version": "12.36.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.36.0.tgz",
|
|
||||||
"integrity": "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"motion-utils": "^12.36.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/motion-utils": {
|
|
||||||
"version": "12.36.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
|
||||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/mpath": {
|
"node_modules/mpath": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -9098,6 +9073,95 @@
|
|||||||
"ms": "^2.1.1"
|
"ms": "^2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.12.0",
|
||||||
|
"pg-pool": "^3.13.0",
|
||||||
|
"pg-protocol": "^1.13.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||||
|
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||||
|
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
@@ -9156,6 +9220,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -10216,6 +10319,15 @@
|
|||||||
"memory-pager": "^1.0.2"
|
"memory-pager": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -10728,6 +10840,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
|
"devOptional": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
@@ -10953,6 +11066,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
|
|||||||
+4
-9
@@ -14,11 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
|
||||||
"@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,13 +24,11 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"enhanced-resolve": "^5.18.4",
|
"enhanced-resolve": "^5.18.4",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"framer-motion": "^12.36.0",
|
|
||||||
"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",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -42,20 +36,21 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@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",
|
||||||
|
|||||||
+92
-89
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
// App v1.5 - JWT Migration Final Check
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
import { useAuth } from './context/useAuth';
|
|
||||||
import { SystemSettingsProvider } from './context/SystemSettingsContext';
|
import { SystemSettingsProvider } from './context/SystemSettingsContext';
|
||||||
import { NotificationProvider } from './contexts/NotificationContext';
|
import { NotificationProvider } from './contexts/NotificationContext';
|
||||||
import { Layout } from './components/Layout';
|
import { Layout } from './components/Layout';
|
||||||
@@ -18,108 +17,112 @@ import { DeveloperDashboard } from './pages/DeveloperDashboard';
|
|||||||
import { CalculatorDashboard } from './pages/CalculatorDashboard';
|
import { CalculatorDashboard } from './pages/CalculatorDashboard';
|
||||||
import { StockDashboard } from './pages/StockDashboard';
|
import { StockDashboard } from './pages/StockDashboard';
|
||||||
import { GuestDashboard } from './pages/GuestDashboard';
|
import { GuestDashboard } from './pages/GuestDashboard';
|
||||||
import Login from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import { OrganizationSelector } from './pages/OrganizationSelector';
|
||||||
import InstrumentList from './pages/InstrumentList';
|
import InstrumentList from './pages/InstrumentList';
|
||||||
|
|
||||||
const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { isDeveloper, isLoading } = useAuth();
|
const { isDeveloper, isLoading } = useAuth();
|
||||||
|
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
if (!isDeveloper()) return <Navigate to="/" replace />;
|
if (!isDeveloper()) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppRoutes: React.FC = () => {
|
const AppContent: React.FC = () => {
|
||||||
const { isSignedIn, isLoading } = useAuth();
|
const { appUser, isLoading } = useAuth();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) return <div className="flex h-screen items-center justify-center">Carregando...</div>;
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#0f172a] flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSignedIn) {
|
// AppUser exists but hasn't selected an org yet (if your business logic requires orgs)
|
||||||
return (
|
if (appUser && !appUser.organizationId) {
|
||||||
<Routes>
|
return <OrganizationSelector />;
|
||||||
<Route path="/login" element={<Login />} />
|
}
|
||||||
<Route path="/register" element={<Register />} />
|
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<ProjectList />} />
|
<Route path="/" element={<ProjectList />} />
|
||||||
<Route path="/login" element={<Navigate to="/" replace />} />
|
<Route path="/guest-dashboard" element={<GuestDashboard />} />
|
||||||
<Route path="/register" element={<Navigate to="/" replace />} />
|
<Route path="/projects" element={<ProjectList />} />
|
||||||
<Route path="/guest-dashboard" element={<GuestDashboard />} />
|
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||||
<Route path="/projects" element={<ProjectList />} />
|
<Route path="/schemes" element={<SchemesList />} />
|
||||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
<Route path="/inspections" element={<InspectionsList />} />
|
||||||
<Route path="/schemes" element={<SchemesList />} />
|
<Route path="/library" element={
|
||||||
<Route path="/inspections" element={<InspectionsList />} />
|
<ProtectedRoute allowedRoles={['user', 'admin']}>
|
||||||
<Route path="/library" element={
|
<DataSheetLibrary />
|
||||||
<ProtectedRoute allowedRoles={['user', 'admin']}>
|
</ProtectedRoute>
|
||||||
<DataSheetLibrary />
|
} />
|
||||||
</ProtectedRoute>
|
<Route path="/instruments" element={
|
||||||
} />
|
<ProtectedRoute allowedRoles={['user', 'admin', 'guest']}>
|
||||||
<Route path="/instruments" element={
|
<InstrumentList />
|
||||||
<ProtectedRoute allowedRoles={['user', 'admin', 'guest']}>
|
</ProtectedRoute>
|
||||||
<InstrumentList />
|
} />
|
||||||
</ProtectedRoute>
|
<Route path="/yield-study" element={
|
||||||
} />
|
<ProtectedRoute allowedRoles={['user', 'admin']}>
|
||||||
<Route path="/yield-study" element={
|
<YieldStudyDashboard />
|
||||||
<ProtectedRoute allowedRoles={['user', 'admin']}>
|
</ProtectedRoute>
|
||||||
<YieldStudyDashboard />
|
} />
|
||||||
</ProtectedRoute>
|
<Route path="/calculators" element={<CalculatorDashboard />} />
|
||||||
} />
|
<Route
|
||||||
<Route path="/calculators" element={<CalculatorDashboard />} />
|
path="/admin"
|
||||||
<Route
|
element={
|
||||||
path="/admin"
|
<ProtectedRoute allowedRoles={['admin']}>
|
||||||
element={
|
<AdminDashboard />
|
||||||
<ProtectedRoute allowedRoles={['admin']}>
|
</ProtectedRoute>
|
||||||
<AdminDashboard />
|
}
|
||||||
</ProtectedRoute>
|
/>
|
||||||
}
|
<Route
|
||||||
/>
|
path="/stock"
|
||||||
<Route
|
element={
|
||||||
path="/stock"
|
<ProtectedRoute allowedRoles={['user', 'admin', 'guest']}>
|
||||||
element={
|
<StockDashboard />
|
||||||
<ProtectedRoute allowedRoles={['user', 'admin', 'guest']}>
|
</ProtectedRoute>
|
||||||
<StockDashboard />
|
}
|
||||||
</ProtectedRoute>
|
/>
|
||||||
}
|
<Route
|
||||||
/>
|
path="/developer"
|
||||||
<Route
|
element={
|
||||||
path="/developer"
|
<DeveloperRoute>
|
||||||
element={
|
<DeveloperDashboard />
|
||||||
<DeveloperRoute>
|
</DeveloperRoute>
|
||||||
<DeveloperDashboard />
|
}
|
||||||
</DeveloperRoute>
|
/>
|
||||||
}
|
</Routes>
|
||||||
/>
|
</Layout>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
);
|
||||||
</Routes>
|
};
|
||||||
</Layout>
|
|
||||||
);
|
const MainRouter: React.FC = () => {
|
||||||
|
const { appUser, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="flex h-screen items-center justify-center">Verificando sessão...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
{!appUser ? (
|
||||||
|
<Login />
|
||||||
|
) : (
|
||||||
|
<AppContent />
|
||||||
|
)}
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<ToastProvider>
|
||||||
<ToastProvider>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<SystemSettingsProvider>
|
||||||
<SystemSettingsProvider>
|
<NotificationProvider>
|
||||||
<NotificationProvider>
|
<MainRouter />
|
||||||
<AppRoutes />
|
</NotificationProvider>
|
||||||
</NotificationProvider>
|
</SystemSettingsProvider>
|
||||||
</SystemSettingsProvider>
|
</AuthProvider>
|
||||||
</AuthProvider>
|
</ToastProvider>
|
||||||
</ToastProvider>
|
|
||||||
</Router>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import React, { useState } from 'react';
|
|||||||
import NotificationBell from './NotificationBell';
|
import NotificationBell from './NotificationBell';
|
||||||
import { TeamPresence } from './TeamPresence';
|
import { TeamPresence } from './TeamPresence';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Menu, X, FolderOpen, Layers, ClipboardCheck, LogOut, TrendingUp, Sun, Moon, HelpCircle, Shield, Wrench, Terminal, LayoutDashboard, Package, Thermometer, User as UserIcon } from 'lucide-react';
|
import { Menu, X, FolderOpen, Layers, ClipboardCheck, LogOut, TrendingUp, Sun, Moon, HelpCircle, Shield, Wrench, Terminal, LayoutDashboard, Package, Thermometer } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { TechnicalManual } from './TechnicalManual';
|
|
||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
|
import { TechnicalManual } from './TechnicalManual';
|
||||||
// import { useSystemSettings } from '../context/SystemSettingsContext';
|
// import { useSystemSettings } from '../context/SystemSettingsContext';
|
||||||
import { setApiOrganizationId } from '../services/api';
|
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -24,15 +22,6 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
const { isAdmin, isUser, isDeveloper, appUser, logout } = useAuth();
|
const { isAdmin, isUser, isDeveloper, appUser, logout } = useAuth();
|
||||||
// const { settings } = useSystemSettings();
|
// const { settings } = useSystemSettings();
|
||||||
|
|
||||||
// Sync Organization ID with API client
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (appUser?.organizationId) {
|
|
||||||
setApiOrganizationId(appUser.organizationId);
|
|
||||||
} else {
|
|
||||||
setApiOrganizationId(null);
|
|
||||||
}
|
|
||||||
}, [appUser?.organizationId]);
|
|
||||||
|
|
||||||
// Helper to get role display name
|
// Helper to get role display name
|
||||||
const getRoleDisplay = () => {
|
const getRoleDisplay = () => {
|
||||||
switch (appUser?.role) {
|
switch (appUser?.role) {
|
||||||
@@ -113,13 +102,13 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 mb-2">
|
<div className="px-6 mb-2">
|
||||||
<div className="w-full flex items-center gap-3 p-2 rounded-xl border border-border/50 bg-surface-hover/50 text-text-main opacity-80 cursor-default" title="Apenas visualização">
|
<div className="w-full flex items-center gap-3 p-2 rounded-xl border border-border/50 bg-surface-hover/50 text-text-main opacity-80 cursor-default" title="Organização">
|
||||||
<div className="w-8 h-8 rounded-lg bg-surface-soft flex items-center justify-center font-bold text-xs uppercase">
|
<div className="w-8 h-8 rounded-lg bg-surface-soft flex items-center justify-center font-bold text-xs">
|
||||||
{appUser?.organizationId ? 'ORG' : 'GPI'}
|
ORG
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-semibold truncate">{appUser?.organizationId || 'Padrão'}</p>
|
<p className="text-sm font-semibold truncate">Organização Matriz</p>
|
||||||
<p className="text-[10px] text-text-muted uppercase tracking-wider">Organização</p>
|
<p className="text-[10px] text-text-muted uppercase tracking-wider">Conta</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,7 +212,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={() => logout()}
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold text-text-secondary hover:text-error hover:bg-error/5 transition-all w-full"
|
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold text-text-secondary hover:text-error hover:bg-error/5 transition-all w-full"
|
||||||
>
|
>
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
@@ -234,12 +223,12 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<div className="w-px h-6 bg-border/50 mx-1"></div>
|
<div className="w-px h-6 bg-border/50 mx-1"></div>
|
||||||
<div className="w-8 h-8 rounded-full bg-surface-soft flex items-center justify-center">
|
<div className="w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center font-bold text-xs">
|
||||||
<UserIcon size={16} className="text-text-muted" />
|
{appUser?.name?.substring(0, 2).toUpperCase() || 'US'}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[10px] text-text-main font-bold truncate max-w-[100px]">{appUser?.name || 'Usuário'}</span>
|
<span className="text-[10px] text-text-main font-bold truncate max-w-[100px]">{appUser?.name || 'Usuário'}</span>
|
||||||
<span className="text-[8px] text-text-muted">v3.0.0</span>
|
<span className="text-[8px] text-text-muted">v2.1.0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ import { usePresence } from '../hooks/usePresence';
|
|||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
import { SendMessageModal } from './SendMessageModal';
|
import { SendMessageModal } from './SendMessageModal';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { clsx } from 'clsx';
|
|
||||||
|
|
||||||
interface OrganizationMember {
|
interface OrganizationMember {
|
||||||
_id: string;
|
_id: string;
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
userId: string;
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,21 +23,23 @@ interface PendingMessage {
|
|||||||
|
|
||||||
export const TeamPresence: React.FC = () => {
|
export const TeamPresence: React.FC = () => {
|
||||||
const { activeUsers } = usePresence();
|
const { activeUsers } = usePresence();
|
||||||
const { appUser, isSignedIn } = useAuth();
|
const { appUser } = useAuth();
|
||||||
const [allMembers, setAllMembers] = React.useState<OrganizationMember[]>([]);
|
const [allMembers, setAllMembers] = React.useState<OrganizationMember[]>([]);
|
||||||
const [pendingMessages, setPendingMessages] = React.useState<PendingMessage[]>([]);
|
const [pendingMessages, setPendingMessages] = React.useState<PendingMessage[]>([]);
|
||||||
const [selectedUser, setSelectedUser] = React.useState<{ id: string; name: string } | null>(null);
|
const [selectedUser, setSelectedUser] = React.useState<{ id: string; name: string } | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Fetch all members
|
||||||
const fetchMembers = React.useCallback(async () => {
|
const fetchMembers = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<any[]>('/users');
|
const response = await api.get<OrganizationMember[]>('/users');
|
||||||
setAllMembers(response.data.map(m => ({ ...m, id: m._id || m.id })));
|
setAllMembers(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching members:', error);
|
console.error('Error fetching members:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch pending messages
|
||||||
const fetchPendingMessages = React.useCallback(async () => {
|
const fetchPendingMessages = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<PendingMessage[]>('/messages/pending');
|
const response = await api.get<PendingMessage[]>('/messages/pending');
|
||||||
@@ -49,33 +50,35 @@ export const TeamPresence: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isSignedIn) {
|
if (appUser) {
|
||||||
fetchMembers();
|
fetchMembers();
|
||||||
fetchPendingMessages();
|
fetchPendingMessages();
|
||||||
const intervalMembers = setInterval(fetchMembers, 60000);
|
const memberInterval = setInterval(fetchMembers, 60000);
|
||||||
const intervalMessages = setInterval(fetchPendingMessages, 30000);
|
const messageInterval = setInterval(fetchPendingMessages, 30000);
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(intervalMembers);
|
clearInterval(memberInterval);
|
||||||
clearInterval(intervalMessages);
|
clearInterval(messageInterval);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [isSignedIn, fetchMembers, fetchPendingMessages]);
|
}, [appUser, fetchMembers, fetchPendingMessages]);
|
||||||
|
|
||||||
if (allMembers.length === 0) return null;
|
if (allMembers.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Create a Set of active user emails or IDs (depends on what usePresence returns)
|
// Create a Set of active user IDs for fast lookup
|
||||||
// Assuming usePresence returns objects with email or id
|
const activeUserEmails = new Set(activeUsers.map(u => u.email));
|
||||||
const activeUserIdentifiers = new Set(activeUsers.map(u => u.email || u.id));
|
|
||||||
|
|
||||||
|
// Create a map of pending messages by recipient ID
|
||||||
const pendingMessagesByRecipient = new Map(
|
const pendingMessagesByRecipient = new Map(
|
||||||
pendingMessages.map(msg => [msg.toUser?.email, msg])
|
pendingMessages.map(msg => [msg.toUser?.email, msg])
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMemberClick = (member: OrganizationMember) => {
|
const handleMemberClick = (member: OrganizationMember) => {
|
||||||
if (member.email === appUser?.email) {
|
if (member.email === appUser?.email) {
|
||||||
return;
|
return; // Don't allow messaging yourself
|
||||||
}
|
}
|
||||||
setSelectedUser({ id: member.id, name: member.name });
|
setSelectedUser({ id: member.email, name: member.name });
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,7 +87,7 @@ export const TeamPresence: React.FC = () => {
|
|||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMessageSent = () => {
|
const handleMessageSent = async () => {
|
||||||
fetchPendingMessages();
|
fetchPendingMessages();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,45 +106,48 @@ export const TeamPresence: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{allMembers.map((member) => {
|
{allMembers.map((member) => {
|
||||||
const isOnline = activeUserIdentifiers.has(member.email) || activeUserIdentifiers.has(member.id);
|
const isOnline = activeUserEmails.has(member.email);
|
||||||
const isCurrentUser = member.email === appUser?.email;
|
const isCurrentUser = member.email === appUser?.email;
|
||||||
const hasPendingMessage = pendingMessagesByRecipient.has(member.email);
|
const hasPendingMessage = pendingMessagesByRecipient.has(member.email);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.id}
|
key={member._id}
|
||||||
className="relative group"
|
className="relative group"
|
||||||
onClick={() => !isCurrentUser && handleMemberClick(member)}
|
onClick={() => !isCurrentUser && handleMemberClick(member)}
|
||||||
>
|
>
|
||||||
<div className={clsx(`
|
<div className={`
|
||||||
w-8 h-8 rounded-full border-2 text-xs font-bold flex items-center justify-center uppercase shadow-sm
|
w-8 h-8 rounded-full border-2 text-xs font-bold flex items-center justify-center uppercase shadow-sm
|
||||||
transition-all duration-300
|
transition-all duration-300
|
||||||
`,
|
${isOnline
|
||||||
isOnline
|
|
||||||
? 'bg-primary/20 border-primary text-primary ring-2 ring-primary/20 shadow-primary/20'
|
? 'bg-primary/20 border-primary text-primary ring-2 ring-primary/20 shadow-primary/20'
|
||||||
: 'bg-surface-soft border-border/30 text-text-muted/40 grayscale opacity-40',
|
: 'bg-surface-soft border-border/30 text-text-muted/40 grayscale opacity-40'
|
||||||
isCurrentUser ? 'ring-2 ring-amber-500 cursor-default' : 'cursor-pointer hover:scale-110',
|
}
|
||||||
hasPendingMessage && 'ring-2 ring-blue-500'
|
${isCurrentUser ? 'ring-2 ring-amber-500 cursor-default' : 'cursor-pointer hover:scale-110'}
|
||||||
)}>
|
${hasPendingMessage ? 'ring-2 ring-blue-500' : ''}
|
||||||
{member.name?.charAt(0) || member.email.charAt(0)}
|
`}>
|
||||||
|
{member.name.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Online indicator */}
|
||||||
{isOnline && (
|
{isOnline && (
|
||||||
<span className="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-surface animate-pulse" />
|
<span className="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-surface animate-pulse" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pending message indicator */}
|
||||||
{hasPendingMessage && !isCurrentUser && (
|
{hasPendingMessage && !isCurrentUser && (
|
||||||
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-blue-500 ring-2 ring-surface" title="Mensagem pendente" />
|
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-blue-500 ring-2 ring-surface" title="Mensagem pendente" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1.5 bg-surface border border-border/40 shadow-xl rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1.5 bg-surface border border-border/40 shadow-xl rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<div className="font-bold text-text-main flex items-center gap-2">
|
<div className="font-bold text-text-main flex items-center gap-2">
|
||||||
{member.name || 'Sem nome'}
|
{member.name}
|
||||||
{isCurrentUser && <span className="text-amber-500 text-[10px]">(Você)</span>}
|
{isCurrentUser && <span className="text-amber-500 text-[10px]">(Você)</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-text-muted text-[10px] mt-0.5">{member.email}</div>
|
<div className="text-text-muted text-[10px] mt-0.5">{member.email}</div>
|
||||||
<div className={clsx("text-[10px] mt-1 font-semibold", isOnline ? 'text-green-500' : 'text-text-muted')}>
|
<div className={`text-[10px] mt-1 font-semibold ${isOnline ? 'text-green-500' : 'text-text-muted'}`}>
|
||||||
{isOnline ? '🟢 Online' : '⚫ Offline'}
|
{isOnline ? '🟢 Online' : '⚫ Offline'}
|
||||||
</div>
|
</div>
|
||||||
{!isCurrentUser && (
|
{!isCurrentUser && (
|
||||||
@@ -149,6 +155,11 @@ export const TeamPresence: React.FC = () => {
|
|||||||
💬 Clique para enviar mensagem
|
💬 Clique para enviar mensagem
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hasPendingMessage && (
|
||||||
|
<div className="text-blue-400 text-[10px] mt-1 font-semibold">
|
||||||
|
✉️ Mensagem pendente
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,13 +168,14 @@ export const TeamPresence: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Message Modal */}
|
||||||
{selectedUser && (
|
{selectedUser && (
|
||||||
<SendMessageModal
|
<SendMessageModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={handleModalClose}
|
onClose={handleModalClose}
|
||||||
recipientId={selectedUser.id}
|
recipientId={selectedUser.id}
|
||||||
recipientName={selectedUser.name}
|
recipientName={selectedUser.name}
|
||||||
existingMessage={getExistingMessage(allMembers.find(m => m.id === selectedUser.id)!)}
|
existingMessage={getExistingMessage(allMembers.find(m => m.email === selectedUser.id)!)}
|
||||||
onMessageSent={handleMessageSent}
|
onMessageSent={handleMessageSent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { Download, Upload, AlertTriangle, CheckCircle, Database, FileJson, Info, RefreshCw } from 'lucide-react';
|
import { Download, Upload, AlertTriangle, CheckCircle, Database, FileJson, Info, RefreshCw } from 'lucide-react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useOrganization } from '@clerk/clerk-react';
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
|
||||||
interface BackupStats {
|
interface BackupStats {
|
||||||
projects: number;
|
projects: number;
|
||||||
@@ -28,7 +28,7 @@ interface BackupValidation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const BackupRestore: React.FC = () => {
|
export const BackupRestore: React.FC = () => {
|
||||||
const { organization } = useOrganization();
|
const { appUser } = useAuth();
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [validationResult, setValidationResult] = useState<BackupValidation | null>(null);
|
const [validationResult, setValidationResult] = useState<BackupValidation | null>(null);
|
||||||
@@ -36,7 +36,7 @@ export const BackupRestore: React.FC = () => {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!organization) return;
|
if (!appUser) return;
|
||||||
|
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
@@ -52,7 +52,7 @@ export const BackupRestore: React.FC = () => {
|
|||||||
|
|
||||||
// Nome do arquivo com timestamp
|
// Nome do arquivo com timestamp
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||||
link.download = `backup_${organization.name}_${timestamp}.json`;
|
link.download = `backup_gpi_${timestamp}.json`;
|
||||||
|
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
@@ -148,8 +148,8 @@ export const BackupRestore: React.FC = () => {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-bold text-text-main mb-2">Backup e Restauração de Dados</h3>
|
<h3 className="text-lg font-bold text-text-main mb-2">Backup e Restauração de Dados</h3>
|
||||||
<p className="text-sm text-text-muted leading-relaxed">
|
<p className="text-sm text-text-muted leading-relaxed">
|
||||||
Use esta ferramenta para criar cópias de segurança de todos os dados da organização ou restaurar dados de um backup anterior.
|
Use esta ferramenta para criar cópias de segurança de todos os dados do sistema ou restaurar dados de um backup anterior.
|
||||||
<strong className="text-amber-500"> Os backups são específicos para cada organização e não podem ser restaurados em outras organizações.</strong>
|
<strong className="text-amber-500"> Os backups são específicos para cada instalação e podem não ser compatíveis entre versões diferentes se houver mudanças estruturais.</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +191,7 @@ export const BackupRestore: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{isExporting ? (
|
{isExporting ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw size={20} className="animate-spin" />
|
<RefreshCw size={20} className="animate-spin" />
|
||||||
Gerando Backup...
|
Gerando Backup...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -248,18 +248,18 @@ export const BackupRestore: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{validationResult && (
|
{validationResult && (
|
||||||
<div className={`p-4 rounded-xl border ${validationResult.valid && validationResult.isValidOrganization
|
<div className={`p-4 rounded-xl border ${validationResult.valid
|
||||||
? 'bg-green-500/10 border-green-500/30'
|
? 'bg-green-500/10 border-green-500/30'
|
||||||
: 'bg-red-500/10 border-red-500/30'
|
: 'bg-red-500/10 border-red-500/30'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{validationResult.valid && validationResult.isValidOrganization ? (
|
{validationResult.valid ? (
|
||||||
<CheckCircle size={20} className="text-green-400 flex-shrink-0 mt-0.5" />
|
<CheckCircle size={20} className="text-green-400 flex-shrink-0 mt-0.5" />
|
||||||
) : (
|
) : (
|
||||||
<AlertTriangle size={20} className="text-red-400 flex-shrink-0 mt-0.5" />
|
<AlertTriangle size={20} className="text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<p className={`text-sm font-bold ${validationResult.valid && validationResult.isValidOrganization
|
<p className={`text-sm font-bold ${validationResult.valid
|
||||||
? 'text-green-400'
|
? 'text-green-400'
|
||||||
: 'text-red-400'
|
: 'text-red-400'
|
||||||
}`}>
|
}`}>
|
||||||
@@ -289,7 +289,7 @@ export const BackupRestore: React.FC = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={!validationResult?.valid || !validationResult?.isValidOrganization || isImporting}
|
disabled={!validationResult?.valid || isImporting}
|
||||||
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-red-500 hover:bg-red-600 text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-red-500/20"
|
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-red-500 hover:bg-red-600 text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-red-500/20"
|
||||||
>
|
>
|
||||||
{isImporting ? (
|
{isImporting ? (
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useOrganization } from '@clerk/clerk-react';
|
|
||||||
import { Plus, Pencil, Trash2, Box, RefreshCw } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Box, RefreshCw } from 'lucide-react';
|
||||||
import { Button } from '../Button';
|
import { Button } from '../Button';
|
||||||
import { Modal } from '../Modal';
|
import { Modal } from '../Modal';
|
||||||
import { Input } from '../Input';
|
import { Input } from '../Input';
|
||||||
import * as geometryService from '../../services/geometryTypeService';
|
import * as geometryService from '../../services/geometryTypeService';
|
||||||
import type { GeometryType } from '../../types';
|
import type { GeometryType } from '../../types';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
|
||||||
export const GeometrySettings: React.FC = () => {
|
export const GeometrySettings: React.FC = () => {
|
||||||
const { organization } = useOrganization();
|
const { appUser } = useAuth();
|
||||||
const [types, setTypes] = useState<GeometryType[]>([]);
|
const [types, setTypes] = useState<GeometryType[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -20,13 +20,7 @@ export const GeometrySettings: React.FC = () => {
|
|||||||
efficiencyLoss: '20'
|
efficiencyLoss: '20'
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchTypes = React.useCallback(async () => {
|
||||||
if (organization?.id) {
|
|
||||||
fetchTypes();
|
|
||||||
}
|
|
||||||
}, [organization?.id]);
|
|
||||||
|
|
||||||
const fetchTypes = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await geometryService.getAllTypes();
|
const response = await geometryService.getAllTypes();
|
||||||
@@ -36,12 +30,18 @@ export const GeometrySettings: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appUser) {
|
||||||
|
fetchTypes();
|
||||||
|
}
|
||||||
|
}, [appUser, fetchTypes]);
|
||||||
|
|
||||||
const handleOpenModal = (item?: GeometryType) => {
|
const handleOpenModal = (item?: GeometryType) => {
|
||||||
if (item) {
|
if (item) {
|
||||||
setEditingItem(item);
|
setEditingItem(item);
|
||||||
setForm({ name: item.name, efficiencyLoss: item.efficiencyLoss.toString() });
|
setForm({ name: item.name, efficiencyLoss: (item.efficiencyLoss ?? 0).toString() });
|
||||||
} else {
|
} else {
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setForm({ name: '', efficiencyLoss: '20' });
|
setForm({ name: '', efficiencyLoss: '20' });
|
||||||
|
|||||||
@@ -1,62 +1,90 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
import type { AppUser } from '../types';
|
import type { AppUser } from '../types';
|
||||||
import { AuthContext } from './AuthContextType';
|
import { setApiToken, setApiOrganizationId, getBaseUrl } from '../services/api';
|
||||||
import api, { getBaseUrl, setApiOrganizationId } from '../services/api';
|
|
||||||
|
|
||||||
const API_URL = getBaseUrl();
|
const API_URL = getBaseUrl();
|
||||||
|
|
||||||
interface AuthProviderProps {
|
export interface AuthContextType {
|
||||||
children: React.ReactNode;
|
appUser: AppUser | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSignedIn: boolean;
|
||||||
|
error: string | null;
|
||||||
|
token: string | null;
|
||||||
|
login: (token: string, user: AppUser) => void;
|
||||||
|
logout: () => void;
|
||||||
|
isAdmin: () => boolean;
|
||||||
|
isUser: () => boolean;
|
||||||
|
isGuest: () => boolean;
|
||||||
|
isDeveloper: () => boolean;
|
||||||
|
canEdit: () => boolean;
|
||||||
|
refetchUser: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [appUser, setAppUser] = useState<AppUser | null>(null);
|
const [appUser, setAppUser] = useState<AppUser | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(localStorage.getItem('jwt_token'));
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
// Initial load: se tem token, setar no interceptor e buscar dados do usuário
|
||||||
localStorage.removeItem('gpi_token');
|
useEffect(() => {
|
||||||
setAppUser(null);
|
if (token) {
|
||||||
setApiOrganizationId(null);
|
setApiToken(token);
|
||||||
window.location.href = '/login';
|
refetchUser();
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const login = useCallback((newToken: string, user: AppUser) => {
|
||||||
|
localStorage.setItem('jwt_token', newToken);
|
||||||
|
setToken(newToken);
|
||||||
|
setAppUser(user);
|
||||||
|
setApiToken(newToken);
|
||||||
|
|
||||||
|
// Se a organização existir, setar o header
|
||||||
|
if (user.organizationId) {
|
||||||
|
setApiOrganizationId(user.organizationId);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchMe = useCallback(async () => {
|
const logout = useCallback(() => {
|
||||||
const token = localStorage.getItem('gpi_token');
|
localStorage.removeItem('jwt_token');
|
||||||
if (!token) {
|
setToken(null);
|
||||||
setAppUser(null);
|
setAppUser(null);
|
||||||
setIsLoading(false);
|
setApiToken(null);
|
||||||
return;
|
setApiOrganizationId(null);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
const refetchUser = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
const response = await fetch(`${API_URL}/auth/me`, {
|
||||||
const response = await api.get('/auth/me');
|
headers: {
|
||||||
const userData = response.data;
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
setAppUser({
|
|
||||||
...userData,
|
|
||||||
id: userData._id || userData.id
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userData.organizationId) {
|
if (response.ok) {
|
||||||
setApiOrganizationId(userData.organizationId);
|
const userData = await response.json();
|
||||||
}
|
setAppUser(userData);
|
||||||
} catch (err: any) {
|
if (userData.organizationId) {
|
||||||
console.error('Error fetching current user:', err);
|
setApiOrganizationId(userData.organizationId);
|
||||||
if (err.response?.status === 401) {
|
}
|
||||||
logout();
|
|
||||||
} else {
|
} else {
|
||||||
setError('Erro ao carregar dados do usuário');
|
// Token inválido ou expirado
|
||||||
|
logout();
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error refetching user:', err);
|
||||||
|
setError('Falha na comunicação de autenticação.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [logout]);
|
}, [token, logout]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchMe();
|
|
||||||
}, [fetchMe]);
|
|
||||||
|
|
||||||
const isDeveloper = useCallback(() => {
|
const isDeveloper = useCallback(() => {
|
||||||
return appUser?.email === 'admtracksteel@gmail.com';
|
return appUser?.email === 'admtracksteel@gmail.com';
|
||||||
@@ -65,7 +93,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
const isAdmin = useCallback(() => appUser?.role === 'admin' || isDeveloper(), [appUser, isDeveloper]);
|
const isAdmin = useCallback(() => appUser?.role === 'admin' || isDeveloper(), [appUser, isDeveloper]);
|
||||||
const isUser = useCallback(() => appUser?.role === 'user' || isAdmin(), [appUser, isAdmin]);
|
const isUser = useCallback(() => appUser?.role === 'user' || isAdmin(), [appUser, isAdmin]);
|
||||||
const isGuest = useCallback(() => appUser?.role === 'guest' && !isDeveloper(), [appUser, isDeveloper]);
|
const isGuest = useCallback(() => appUser?.role === 'guest' && !isDeveloper(), [appUser, isDeveloper]);
|
||||||
const canEdit = useCallback(() => (appUser?.role !== 'guest' && appUser?.role !== undefined) || isDeveloper(), [appUser, isDeveloper]);
|
const canEdit = useCallback(() => (appUser?.role !== 'guest' && appUser !== null) || isDeveloper(), [appUser, isDeveloper]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
@@ -74,16 +102,26 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isSignedIn: !!appUser,
|
isSignedIn: !!appUser,
|
||||||
error,
|
error,
|
||||||
|
token,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isUser,
|
isUser,
|
||||||
isGuest,
|
isGuest,
|
||||||
isDeveloper,
|
isDeveloper,
|
||||||
canEdit,
|
canEdit,
|
||||||
refetchUser: fetchMe,
|
refetchUser,
|
||||||
logout, // Added logout to context
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createContext } from 'react';
|
|
||||||
import type { AppUser } from '../types';
|
import type { AppUser } from '../types';
|
||||||
|
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
@@ -12,7 +11,4 @@ export interface AuthContextType {
|
|||||||
isDeveloper: () => boolean;
|
isDeveloper: () => boolean;
|
||||||
canEdit: () => boolean;
|
canEdit: () => boolean;
|
||||||
refetchUser: () => Promise<void>;
|
refetchUser: () => Promise<void>;
|
||||||
logout: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
||||||
|
|||||||
@@ -1,10 +1 @@
|
|||||||
import { useContext } from 'react';
|
export { useAuth } from './AuthContext';
|
||||||
import { AuthContext } from './AuthContextType';
|
|
||||||
|
|
||||||
export const useAuth = () => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -6,56 +6,47 @@ import { NotificationContext } from './NotificationContextState';
|
|||||||
|
|
||||||
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { appUser, isSignedIn } = useAuth();
|
const { appUser, isSignedIn } = useAuth();
|
||||||
|
const orgId = appUser?.organizationId;
|
||||||
const [notifications, setNotifications] = useState<INotification[]>([]);
|
const [notifications, setNotifications] = useState<INotification[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const fetchNotifications = useCallback(async () => {
|
const fetchNotifications = useCallback(async () => {
|
||||||
if (!isSignedIn) return;
|
if (!isSignedIn || !orgId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (notifications.length === 0) setLoading(true);
|
setLoading(true);
|
||||||
const response = await api.get('/notifications');
|
const response = await api.get<INotification[]>('/notifications');
|
||||||
setNotifications(response.data);
|
setNotifications(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch notifications', error);
|
console.error('Error fetching notifications:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [isSignedIn, notifications.length]);
|
}, [isSignedIn, orgId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSignedIn && orgId) {
|
||||||
|
fetchNotifications();
|
||||||
|
const interval = setInterval(fetchNotifications, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [isSignedIn, orgId, fetchNotifications]);
|
||||||
|
|
||||||
const markAsRead = async (id: string) => {
|
const markAsRead = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api.put(`/notifications/${id}/read`);
|
await api.patch(`/notifications/${id}/read`);
|
||||||
setNotifications(prev => prev.map(n => n._id === id ? { ...n, isRead: true } : n));
|
setNotifications(prev => prev.map(n => n._id === id ? { ...n, isRead: true } : n));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to mark as read', error);
|
console.error('Error marking notification as read:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAllAsRead = async () => {
|
const markAllAsRead = async () => {
|
||||||
try {
|
try {
|
||||||
await api.put('/notifications/read-all');
|
await api.post('/notifications/read-all');
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
|
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to mark all as read', error);
|
console.error('Error marking all notifications as read:', error);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAll = async () => {
|
|
||||||
try {
|
|
||||||
await api.delete('/notifications/clear-all');
|
|
||||||
setNotifications([]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear all notifications', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const archiveNotification = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await api.patch(`/notifications/${id}/archive`);
|
|
||||||
setNotifications(prev => prev.filter(n => n._id !== id));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to archive notification', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,25 +55,10 @@ export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
await api.delete(`/notifications/${id}`);
|
await api.delete(`/notifications/${id}`);
|
||||||
setNotifications(prev => prev.filter(n => n._id !== id));
|
setNotifications(prev => prev.filter(n => n._id !== id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete notification', error);
|
console.error('Error deleting notification:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Polling effect
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSignedIn) {
|
|
||||||
fetchNotifications(); // Initial fetch
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchNotifications();
|
|
||||||
}, 30000); // Poll every 30 seconds
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
} else {
|
|
||||||
setNotifications([]);
|
|
||||||
}
|
|
||||||
}, [isSignedIn, fetchNotifications]);
|
|
||||||
|
|
||||||
const unreadCount = notifications.filter(n => !n.isRead).length;
|
const unreadCount = notifications.filter(n => !n.isRead).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,12 +66,10 @@ export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
notifications,
|
notifications,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
loading,
|
loading,
|
||||||
|
fetchNotifications,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
clearAll,
|
deleteNotification
|
||||||
archiveNotification,
|
|
||||||
deleteNotification,
|
|
||||||
fetchNotifications
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</NotificationContext.Provider>
|
</NotificationContext.Provider>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useAuth } from '../context/useAuth';
|
|||||||
|
|
||||||
export interface ActiveUser {
|
export interface ActiveUser {
|
||||||
_id: string;
|
_id: string;
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
externalId: string;
|
||||||
lastSeenAt: string;
|
lastSeenAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import './index.css'
|
import './index.css';
|
||||||
import App from './App.tsx'
|
import App from './App.tsx';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<App />
|
<App />
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Shield, UserCheck, UserX, Users, Search, RefreshCw, Crown, Eye, User as UserIcon, Info, Image as ImageIcon, Box, Database } from 'lucide-react';
|
import { Shield, UserCheck, UserX, Users, Search, RefreshCw, Crown, Eye, User as UserIcon, Upload, Info, Image as ImageIcon, Box, Database } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import type { AppUser, UserRole } from '../types';
|
import type { AppUser, UserRole } from '../types';
|
||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
@@ -14,65 +14,103 @@ const roleLabels: Record<UserRole, { label: string; color: string; icon: React.R
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AdminDashboard: React.FC = () => {
|
export const AdminDashboard: React.FC = () => {
|
||||||
const { isAdmin, appUser } = useAuth();
|
const { appUser, isAdmin } = useAuth();
|
||||||
const [users, setUsers] = useState<AppUser[]>([]);
|
const [users, setUsers] = useState<AppUser[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filterRole, setFilterRole] = useState<UserRole | 'all'>('all');
|
const [filterRole, setFilterRole] = useState<UserRole | 'all'>('all');
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'organization' | 'settings' | 'stock' | 'backup'>('users');
|
const [activeTab, setActiveTab] = useState<'users' | 'organization' | 'settings' | 'stock' | 'backup'>('users');
|
||||||
|
const [logoLoading, setLogoLoading] = useState(false);
|
||||||
|
|
||||||
const fetchUsers = useCallback(async () => {
|
const fetchUsers = useCallback(async () => {
|
||||||
|
if (!appUser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await api.get('/users');
|
const response = await api.get('/users');
|
||||||
setUsers(response.data.map((u: any) => ({ ...u, id: u._id || u.id })));
|
setUsers(response.data.map((u: AppUser) => ({ ...u, id: u._id || u.id })));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching users:', error);
|
console.error('Error fetching users:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [appUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, [fetchUsers]);
|
}, [fetchUsers]);
|
||||||
|
|
||||||
const handleRoleChange = async (userId: string, newRole: UserRole) => {
|
const handleRoleChange = async (userId: string, newRole: UserRole) => {
|
||||||
|
if (!appUser) return;
|
||||||
|
|
||||||
setActionLoading(userId);
|
setActionLoading(userId);
|
||||||
try {
|
try {
|
||||||
const response = await api.patch(`/users/${userId}/role`, { role: newRole });
|
const response = await api.patch(`/users/${userId}/role`, { role: newRole });
|
||||||
const updated = response.data;
|
const updated = response.data;
|
||||||
setUsers(users.map(u => u.id === userId ? { ...updated, id: updated._id || updated.id } : u));
|
setUsers(users.map(u => u.id === userId ? { ...updated, id: updated._id || updated.id } : u));
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const err = error as { response?: { data?: { error?: string } } };
|
||||||
console.error('Error updating role:', error);
|
console.error('Error updating role:', error);
|
||||||
alert(error.response?.data?.error || 'Erro ao atualizar role');
|
alert(err.response?.data?.error || 'Erro ao atualizar role');
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleBan = async (userId: string, isBanned: boolean) => {
|
const handleToggleBan = async (userId: string, isBanned: boolean) => {
|
||||||
|
if (!appUser) return;
|
||||||
|
|
||||||
setActionLoading(userId);
|
setActionLoading(userId);
|
||||||
try {
|
try {
|
||||||
const response = await api.patch(`/users/${userId}/ban`, { isBanned });
|
const response = await api.patch(`/users/${userId}/ban`, { isBanned });
|
||||||
const updated = response.data;
|
const updated = response.data;
|
||||||
setUsers(users.map(u => u.id === userId ? { ...updated, id: updated._id || updated.id } : u));
|
setUsers(users.map(u => u.id === userId ? { ...updated, id: updated._id || updated.id } : u));
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const err = error as { response?: { data?: { error?: string } } };
|
||||||
console.error('Error toggling ban:', error);
|
console.error('Error toggling ban:', error);
|
||||||
alert(error.response?.data?.error || 'Erro ao alterar status');
|
alert(err.response?.data?.error || 'Erro ao alterar status');
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredUsers = users.filter(u => {
|
const filteredUsers = users.filter(u => {
|
||||||
const matchesSearch = (u.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = u.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
(u.email || '').toLowerCase().includes(searchTerm.toLowerCase());
|
u.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
const matchesRole = filterRole === 'all' || u.role === filterRole;
|
const matchesRole = filterRole === 'all' || u.role === filterRole;
|
||||||
return matchesSearch && matchesRole;
|
return matchesSearch && matchesRole;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validations
|
||||||
|
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml'];
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
alert('Por favor, selecione uma imagem PNG, JPG ou SVG.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 500 * 1024) {
|
||||||
|
alert('O arquivo deve ter no máximo 500KB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogoLoading(true);
|
||||||
|
try {
|
||||||
|
// Note: In the future, this should upload to our own backend
|
||||||
|
// For now, we'll keep the UI but mark it as pending backend integration
|
||||||
|
alert('Funcionalidade de upload de logo em migração para sistema nativo.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading logo:', error);
|
||||||
|
alert('Erro ao atualizar o logo.');
|
||||||
|
} finally {
|
||||||
|
setLogoLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isAdmin()) {
|
if (!isAdmin()) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||||
@@ -94,14 +132,14 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
Administração
|
Administração
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-text-muted mt-2">Gestão de usuários e configurações do sistema</p>
|
<p className="text-text-muted mt-2">Configurações globais e gerenciamento de usuários</p>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === 'users' && (
|
{activeTab === 'users' && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={fetchUsers}
|
onClick={fetchUsers}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-primary hover:bg-primary-dark text-white border border-primary-dark rounded-xl font-semibold transition-all disabled:opacity-50"
|
className="flex items-center gap-2 px-4 py-2.5 bg-surface hover:bg-surface-hover border border-border/40 rounded-xl text-text-main font-semibold transition-all disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||||
Atualizar
|
Atualizar
|
||||||
@@ -124,6 +162,18 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
Usuários
|
Usuários
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('organization')}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
|
||||||
|
activeTab === 'organization'
|
||||||
|
? "bg-surface text-primary shadow-sm border border-border/40"
|
||||||
|
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
Organização
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('settings')}
|
onClick={() => setActiveTab('settings')}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -255,14 +305,14 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
const isActionDisabled = actionLoading === u.id;
|
const isActionDisabled = actionLoading === u.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={u.id} className={clsx("hover:bg-surface-hover transition-colors", u.isBanned && "opacity-60")}>
|
<tr key={u.id} className={`hover:bg-surface-hover transition-colors ${u.isBanned ? 'opacity-60' : ''}`}>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold">
|
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold">
|
||||||
{(u.name || u.email || '?').charAt(0).toUpperCase()}
|
{u.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-text-main">{u.name || 'Sem nome'}</p>
|
<p className="font-semibold text-text-main">{u.name}</p>
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<span className="text-xs text-primary font-medium">(Você)</span>
|
<span className="text-xs text-primary font-medium">(Você)</span>
|
||||||
)}
|
)}
|
||||||
@@ -276,7 +326,7 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
onChange={(e) => handleRoleChange(u.id, e.target.value as UserRole)}
|
onChange={(e) => handleRoleChange(u.id, e.target.value as UserRole)}
|
||||||
disabled={isCurrentUser || isActionDisabled || u.isBanned}
|
disabled={isCurrentUser || isActionDisabled || u.isBanned}
|
||||||
aria-label={`Alterar role de ${u.name}`}
|
aria-label={`Alterar role de ${u.name}`}
|
||||||
className={clsx("px-3 py-1.5 rounded-lg border text-sm font-semibold transition-all bg-transparent disabled:opacity-50 disabled:cursor-not-allowed", roleInfo.color)}
|
className={`px-3 py-1.5 rounded-lg border text-sm font-semibold transition-all ${roleInfo.color} disabled:opacity-50 disabled:cursor-not-allowed bg-transparent`}
|
||||||
>
|
>
|
||||||
<option value="guest">Convidado</option>
|
<option value="guest">Convidado</option>
|
||||||
<option value="user">Usuário</option>
|
<option value="user">Usuário</option>
|
||||||
@@ -301,11 +351,10 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleToggleBan(u.id, !u.isBanned)}
|
onClick={() => handleToggleBan(u.id, !u.isBanned)}
|
||||||
disabled={isActionDisabled}
|
disabled={isActionDisabled}
|
||||||
className={clsx("px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-sm active:scale-95 disabled:opacity-50",
|
className={`px-4 py-2 rounded-xl text-sm font-semibold transition-all ${u.isBanned
|
||||||
u.isBanned
|
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
||||||
? 'bg-green-500/10 text-green-500 hover:bg-green-500/20 border border-green-500/20'
|
: 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
||||||
: 'bg-red-500/10 text-red-500 hover:bg-red-500/20 border border-red-500/20'
|
} disabled:opacity-50`}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isActionDisabled ? (
|
{isActionDisabled ? (
|
||||||
<RefreshCw size={16} className="animate-spin" />
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
@@ -326,6 +375,16 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : activeTab === 'organization' ? (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-300">
|
||||||
|
<div className="bg-surface rounded-2xl p-8 border border-border/40 text-center space-y-4">
|
||||||
|
<ImageIcon size={48} className="mx-auto text-text-muted opacity-20" />
|
||||||
|
<h2 className="text-xl font-bold text-text-main">Gestão de Identidade Visual</h2>
|
||||||
|
<p className="text-text-muted max-w-md mx-auto">
|
||||||
|
O gerenciamento nativo de logos está sendo implementado. No momento, o logo atual é gerenciado via configurações do sistema.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : activeTab === 'settings' ? (
|
) : activeTab === 'settings' ? (
|
||||||
<GeometrySettings />
|
<GeometrySettings />
|
||||||
) : activeTab === 'backup' ? (
|
) : activeTab === 'backup' ? (
|
||||||
@@ -333,13 +392,17 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="bg-surface rounded-2xl border border-border/40 p-6">
|
<div className="bg-surface rounded-2xl border border-border/40 p-6">
|
||||||
<div className="text-center py-10">
|
<div className="text-center py-10">
|
||||||
<h2 className="text-xl font-bold text-text-main">Em breve</h2>
|
<h2 className="text-xl font-bold text-text-main">Gestão de Estoque</h2>
|
||||||
<p className="text-text-muted mt-2">Novas configurações serão adicionadas aqui.</p>
|
<p className="text-text-muted mt-2">Acesse a nova página dedicada ao controle de estoque.</p>
|
||||||
|
<a
|
||||||
|
href="/stock"
|
||||||
|
className="mt-6 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
Ir para Estoque
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminDashboard;
|
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ export const DeveloperDashboard: React.FC = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{admins.map(admin => (
|
{admins.map(admin => (
|
||||||
<div key={admin.id || admin.email} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-indigo-500/20">
|
<div key={admin.userId} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-indigo-500/20">
|
||||||
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-500 font-bold text-xs">
|
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-500 font-bold text-xs">
|
||||||
{admin.name.charAt(0).toUpperCase()}
|
{admin.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
@@ -441,7 +441,7 @@ export const DeveloperDashboard: React.FC = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{commonUsers.map(user => (
|
{commonUsers.map(user => (
|
||||||
<div key={user.id || user.email} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-border/40">
|
<div key={user.userId} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-border/40">
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs",
|
"w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs",
|
||||||
user.role === 'user' ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500"
|
user.role === 'user' ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500"
|
||||||
|
|||||||
+75
-76
@@ -1,116 +1,115 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { Hammer } from "lucide-react";
|
||||||
import api from '../services/api';
|
import { useAuth } from "../context/useAuth";
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { getBaseUrl } from "../services/api";
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const API_URL = getBaseUrl();
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
export const Login = () => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [email, setEmail] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [password, setPassword] = useState("");
|
||||||
const navigate = useNavigate();
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
const { refetchUser } = useAuth();
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setErrorMsg("");
|
||||||
setError(null);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/auth/login', { email, password });
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
const { token } = response.data;
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
localStorage.setItem('gpi_token', token);
|
const data = await response.json();
|
||||||
await refetchUser();
|
|
||||||
navigate('/');
|
if (!response.ok) {
|
||||||
} catch (err: any) {
|
setErrorMsg(data.error || "Erro ao efetuar login");
|
||||||
setError(err.response?.data?.error || 'Erro ao realizar login. Verifique suas credenciais.');
|
setLoading(false);
|
||||||
} finally {
|
return;
|
||||||
setIsLoading(false);
|
}
|
||||||
|
|
||||||
|
login(data.token, data.user);
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMsg("Falha na conexão com o servidor.");
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0f172a] flex items-center justify-center p-4">
|
<div className="min-h-screen w-full flex items-center justify-center bg-surface-soft relative overflow-hidden">
|
||||||
<motion.div
|
{/* Background decorative elements */}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/10 rounded-full blur-[120px] animate-pulse" />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-primary/5 rounded-full blur-[120px]" />
|
||||||
className="max-w-md w-full"
|
|
||||||
>
|
<div className="relative z-10 w-full max-w-md px-6 flex flex-col items-center">
|
||||||
<div className="bg-slate-800/50 backdrop-blur-xl border border-slate-700/50 p-8 rounded-2xl shadow-2xl">
|
{/* Logo Area */}
|
||||||
<div className="text-center mb-8">
|
<div className="mb-8 flex flex-col items-center text-center">
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">GPI</h1>
|
<div className="w-16 h-16 rounded-2xl bg-primary flex items-center justify-center text-white font-bold text-3xl shadow-2xl shadow-primary/40 mb-4 animate-in zoom-in duration-700">
|
||||||
<p className="text-slate-400">Gestão de Pintura Industrial</p>
|
G
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-text-main tracking-tight mb-1">GPI</h1>
|
||||||
|
<p className="text-text-muted text-sm font-medium uppercase tracking-widest">Gestão de Pintura Industrial</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
{/* Custom Login Form */}
|
||||||
{error && (
|
<div className="w-full bg-surface rounded-[2rem] border border-border/40 shadow-2xl shadow-primary/5 p-8 animate-in slide-in-from-bottom-8 duration-1000">
|
||||||
<div className="bg-red-500/10 border border-red-500/50 text-red-500 p-3 rounded-lg text-sm">
|
<h2 className="text-xl font-bold text-text-main mb-6 text-center">Entrar na sua conta</h2>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
{errorMsg && (
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
<div className="mb-4 p-3 rounded-lg bg-error/10 border border-error/20 text-error text-sm text-center">
|
||||||
E-mail
|
{errorMsg}
|
||||||
</label>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-semibold text-text-secondary" htmlFor="email">Email</label>
|
||||||
<input
|
<input
|
||||||
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full bg-slate-900/50 border border-slate-700 text-white rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
className="bg-surface-soft border border-border/40 focus:ring-2 focus:ring-primary/20 focus:border-primary rounded-xl px-4 py-3 text-text-main outline-none transition-all"
|
||||||
placeholder="exemplo@gmail.com"
|
placeholder="seu@email.com"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="flex flex-col gap-2">
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
<div className="flex justify-between items-center">
|
||||||
Senha
|
<label className="text-sm font-semibold text-text-secondary" htmlFor="password">Senha</label>
|
||||||
</label>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full bg-slate-900/50 border border-slate-700 text-white rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
className="bg-surface-soft border border-border/40 focus:ring-2 focus:ring-primary/20 focus:border-primary rounded-xl px-4 py-3 text-text-main outline-none transition-all"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={loading}
|
||||||
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold py-3 rounded-lg shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98]"
|
className="mt-4 bg-primary hover:bg-primary/90 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-primary/20 disabled:opacity-70 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{loading ? "Entrando..." : "Entrar"}
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Entrando...
|
|
||||||
</span>
|
|
||||||
) : 'Entrar'}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-8 text-center text-sm">
|
|
||||||
<p className="text-slate-500">
|
|
||||||
Não tem uma conta? <Link to="/register" className="text-blue-400 hover:text-blue-300 font-medium">Contate o administrador</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 flex items-center gap-2 text-text-muted/60 text-xs font-medium">
|
||||||
<p className="text-slate-600 text-xs">
|
<Hammer size={14} />
|
||||||
© 2026 GPI - Sistema de Gestão Industrial
|
<span>© 2026 GPI - Eficiência Industrial</span>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Login;
|
|
||||||
|
|||||||
@@ -1,98 +1,19 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useUser, useOrganizationList, useOrganization } from '@clerk/clerk-react';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Building2, Users, RefreshCw, Mail } from 'lucide-react';
|
import { useAuth } from '../context/useAuth';
|
||||||
|
import { Building2, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
export const OrganizationSelector: React.FC = () => {
|
export const OrganizationSelector: React.FC = () => {
|
||||||
const { user } = useUser();
|
const { appUser, isSignedIn } = useAuth();
|
||||||
const { setActive, userMemberships, userInvitations } = useOrganizationList({
|
const navigate = useNavigate();
|
||||||
userMemberships: {
|
|
||||||
infinite: true,
|
|
||||||
},
|
|
||||||
userInvitations: {
|
|
||||||
infinite: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const { organization } = useOrganization();
|
|
||||||
const [isAcceptingInvites, setIsAcceptingInvites] = useState(false);
|
|
||||||
|
|
||||||
console.log('OrganizationSelector rendered');
|
|
||||||
console.log('Current organization:', organization);
|
|
||||||
console.log('User memberships:', userMemberships);
|
|
||||||
console.log('User memberships data:', userMemberships.data);
|
|
||||||
console.log('User invitations:', userInvitations);
|
|
||||||
console.log('User invitations data:', userInvitations.data);
|
|
||||||
|
|
||||||
// Auto-accept pending invitations
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const acceptPendingInvitations = async () => {
|
if (isSignedIn && appUser?.organizationId) {
|
||||||
if (userInvitations.data && userInvitations.data.length > 0 && !isAcceptingInvites) {
|
navigate('/');
|
||||||
console.log('Found pending invitations, auto-accepting...');
|
|
||||||
setIsAcceptingInvites(true);
|
|
||||||
|
|
||||||
for (const invitation of userInvitations.data) {
|
|
||||||
try {
|
|
||||||
console.log('Accepting invitation:', invitation);
|
|
||||||
await invitation.accept();
|
|
||||||
console.log('Invitation accepted successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error accepting invitation:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload memberships after accepting invitations
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
acceptPendingInvitations();
|
|
||||||
}, [userInvitations.data, isAcceptingInvites]);
|
|
||||||
|
|
||||||
// Auto-select if user has only one organization
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('Auto-select effect running...');
|
|
||||||
if (!organization && userMemberships.data && userMemberships.data.length === 1) {
|
|
||||||
console.log('Auto-selecting single organization...');
|
|
||||||
const membership = userMemberships.data[0];
|
|
||||||
if (setActive) {
|
|
||||||
setActive({ organization: membership.organization });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [organization, userMemberships.data, setActive]);
|
}, [isSignedIn, appUser, navigate]);
|
||||||
|
|
||||||
const handleSelectOrganization = async (orgId: string) => {
|
if (!isSignedIn) {
|
||||||
console.log('Selecting organization:', orgId);
|
|
||||||
if (setActive) {
|
|
||||||
await setActive({ organization: orgId });
|
|
||||||
}
|
|
||||||
// The auth context will automatically sync after organization changes
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading state - check if data exists or accepting invites
|
|
||||||
if (!userMemberships.data || isAcceptingInvites) {
|
|
||||||
console.log('Loading state - no data yet or accepting invites');
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
{isAcceptingInvites ? (
|
|
||||||
<>
|
|
||||||
<Mail className="w-12 h-12 text-primary animate-bounce mx-auto mb-4" />
|
|
||||||
<p className="text-text-main font-bold mb-2">Aceitando convites pendentes...</p>
|
|
||||||
<p className="text-text-muted text-sm">Por favor aguarde</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
|
|
||||||
<p className="text-text-muted">Carregando organizações...</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userMemberships.data?.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
<div className="max-w-md w-full bg-surface rounded-2xl border border-border/40 p-8 text-center">
|
<div className="max-w-md w-full bg-surface rounded-2xl border border-border/40 p-8 text-center">
|
||||||
@@ -100,84 +21,28 @@ export const OrganizationSelector: React.FC = () => {
|
|||||||
<Building2 className="w-8 h-8 text-amber-500" />
|
<Building2 className="w-8 h-8 text-amber-500" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-text-main mb-2">
|
<h1 className="text-2xl font-bold text-text-main mb-2">
|
||||||
Nenhuma Organização
|
Não Conectado
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-text-muted mb-6">
|
<p className="text-text-muted mb-6">
|
||||||
Você ainda não faz parte de nenhuma organização. Entre em contato com o administrador para receber um convite.
|
Você precisa estar logado para acessar esta página.
|
||||||
</p>
|
</p>
|
||||||
<div className="text-sm text-text-muted bg-surface-soft rounded-lg p-4">
|
<button
|
||||||
<p className="font-semibold mb-1">Conectado como:</p>
|
onClick={() => navigate('/login')}
|
||||||
<p>{user?.primaryEmailAddress?.emailAddress}</p>
|
className="w-full py-3 bg-primary text-white rounded-xl font-bold"
|
||||||
</div>
|
>
|
||||||
|
Ir para Login
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
<div className="max-w-2xl w-full">
|
<div className="text-center">
|
||||||
<div className="text-center mb-8">
|
<RefreshCw className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
|
||||||
<div className="w-16 h-16 rounded-2xl bg-primary/20 flex items-center justify-center mx-auto mb-4">
|
<p className="text-text-main font-bold mb-2">Redirecionando...</p>
|
||||||
<Building2 className="w-8 h-8 text-primary" />
|
<p className="text-text-muted text-sm">Carregando sua organização</p>
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold text-text-main mb-2">
|
|
||||||
Selecione uma Organização
|
|
||||||
</h1>
|
|
||||||
<p className="text-text-muted">
|
|
||||||
Escolha qual organização você deseja acessar
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{userMemberships.data?.map((membership) => (
|
|
||||||
<button
|
|
||||||
key={membership.organization.id}
|
|
||||||
onClick={() => handleSelectOrganization(membership.organization.id)}
|
|
||||||
className="w-full bg-surface hover:bg-surface-hover border border-border/40 rounded-2xl p-6 text-left transition-all hover:border-primary/50 hover:shadow-lg hover:shadow-primary/10 group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-14 h-14 rounded-xl bg-primary/20 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/30 transition-colors">
|
|
||||||
{membership.organization.imageUrl ? (
|
|
||||||
<img
|
|
||||||
src={membership.organization.imageUrl}
|
|
||||||
alt={membership.organization.name}
|
|
||||||
className="w-12 h-12 rounded-lg object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Building2 className="w-7 h-7 text-primary" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-bold text-text-main group-hover:text-primary transition-colors">
|
|
||||||
{membership.organization.name}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-primary/20 text-primary text-xs font-semibold">
|
|
||||||
{membership.role === 'org:admin' ? 'Administrador' :
|
|
||||||
membership.role === 'org:member' ? 'Membro' : 'Convidado'}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1 text-xs text-text-muted">
|
|
||||||
<Users className="w-3 h-3" />
|
|
||||||
{membership.organization.membersCount || 0} membros
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-primary opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
Conectado como <span className="font-semibold">{user?.primaryEmailAddress?.emailAddress}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useAuth } from '../context/useAuth';
|
|||||||
import { useToast } from '../hooks/useToast';
|
import { useToast } from '../hooks/useToast';
|
||||||
import { MobileList } from '../components/MobileList';
|
import { MobileList } from '../components/MobileList';
|
||||||
import { CreateProjectModal } from '../components/modals/CreateProjectModal';
|
import { CreateProjectModal } from '../components/modals/CreateProjectModal';
|
||||||
import { useOrganization } from '@clerk/clerk-react';
|
|
||||||
import { useSystemSettings } from '../context/SystemSettingsContext';
|
import { useSystemSettings } from '../context/SystemSettingsContext';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { ConfirmModal } from '../components/ConfirmModal';
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
@@ -52,10 +51,9 @@ export const ProjectList: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { appUser } = useAuth();
|
const { appUser } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { organization } = useOrganization();
|
|
||||||
const { settings } = useSystemSettings();
|
const { settings } = useSystemSettings();
|
||||||
|
|
||||||
const logoUrl = settings?.appLogoUrl || organization?.imageUrl;
|
const logoUrl = settings?.appLogoUrl;
|
||||||
|
|
||||||
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
|
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
|
||||||
import api from '../services/api';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
const Register: React.FC = () => {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { refetchUser } = useAuth();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.post('/auth/register', { email, password, name });
|
|
||||||
const { token } = response.data;
|
|
||||||
|
|
||||||
localStorage.setItem('gpi_token', token);
|
|
||||||
await refetchUser();
|
|
||||||
navigate('/');
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.error || 'Erro ao realizar cadastro.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#0f172a] flex items-center justify-center p-4">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="max-w-md w-full"
|
|
||||||
>
|
|
||||||
<div className="bg-slate-800/50 backdrop-blur-xl border border-slate-700/50 p-8 rounded-2xl shadow-2xl">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">GPI</h1>
|
|
||||||
<p className="text-slate-400">Novo Cadastro de Usuário</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-500/10 border border-red-500/50 text-red-500 p-3 rounded-lg text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
Nome Completo
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="w-full bg-slate-900/50 border border-slate-700 text-white rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
|
||||||
placeholder="Seu nome"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
E-mail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full bg-slate-900/50 border border-slate-700 text-white rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
|
||||||
placeholder="exemplo@gmail.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
||||||
Senha
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="w-full bg-slate-900/50 border border-slate-700 text-white rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
|
||||||
placeholder="Mínimo 6 caracteres"
|
|
||||||
minLength={6}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold py-3 rounded-lg shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Cadastrando...' : 'Criar Conta'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-8 text-center text-sm">
|
|
||||||
<p className="text-slate-500">
|
|
||||||
Já tem uma conta? <Link to="/login" className="text-blue-400 hover:text-blue-300 font-medium">Voltar para o login</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Register;
|
|
||||||
@@ -7,13 +7,11 @@ import { StockHistoryModal } from '../components/modals/StockHistoryModal';
|
|||||||
import { StockInventoryReport } from '../components/reports/StockInventoryReport';
|
import { StockInventoryReport } from '../components/reports/StockInventoryReport';
|
||||||
import { DiluentListModal } from '../components/modals/DiluentListModal';
|
import { DiluentListModal } from '../components/modals/DiluentListModal';
|
||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
import { useOrganization } from '@clerk/clerk-react';
|
|
||||||
import { useSystemSettings } from '../context/SystemSettingsContext';
|
import { useSystemSettings } from '../context/SystemSettingsContext';
|
||||||
|
|
||||||
export const StockDashboard: React.FC = () => {
|
export const StockDashboard: React.FC = () => {
|
||||||
// ... rest of component
|
// ... rest of component
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { organization } = useOrganization();
|
|
||||||
const { settings } = useSystemSettings();
|
const { settings } = useSystemSettings();
|
||||||
|
|
||||||
const [items, setItems] = useState<StockItem[]>([]);
|
const [items, setItems] = useState<StockItem[]>([]);
|
||||||
@@ -28,7 +26,7 @@ export const StockDashboard: React.FC = () => {
|
|||||||
const [activeTab, setActiveTab] = useState<'PAINT' | 'THINNER'>('PAINT');
|
const [activeTab, setActiveTab] = useState<'PAINT' | 'THINNER'>('PAINT');
|
||||||
const [showDiluentModal, setShowDiluentModal] = useState(false);
|
const [showDiluentModal, setShowDiluentModal] = useState(false);
|
||||||
|
|
||||||
const logoUrl = settings?.appLogoUrl || organization?.imageUrl;
|
const logoUrl = settings?.appLogoUrl;
|
||||||
|
|
||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
+16
-18
@@ -17,14 +17,13 @@ const api = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store the current user's clerk ID and Organization ID/Name
|
let currentToken: string | null = null;
|
||||||
let currentClerkUserId: string | null = null;
|
|
||||||
let currentOrgId: string | null = null;
|
let currentOrgId: string | null = null;
|
||||||
let currentOrgName: string | null = null;
|
let currentOrgName: string | null = null;
|
||||||
|
|
||||||
// Function to set the clerk user ID (called from AuthContext)
|
// Function to set the JWT token
|
||||||
export const setApiClerkUserId = (clerkId: string | null) => {
|
export const setApiToken = (token: string | null) => {
|
||||||
currentClerkUserId = clerkId;
|
currentToken = token;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to set the organization ID and Name (called from Layout/Context)
|
// Function to set the organization ID and Name (called from Layout/Context)
|
||||||
@@ -41,17 +40,22 @@ export const setApiOrgId = (orgId: string | null) => {
|
|||||||
// Alias for consistency
|
// Alias for consistency
|
||||||
export const setApiOrganizationId = setApiOrgId;
|
export const setApiOrganizationId = setApiOrgId;
|
||||||
|
|
||||||
// Request interceptor to add JWT token
|
// Request interceptor to add clerk user ID and Org ID headers
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = localStorage.getItem('gpi_token');
|
console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`, {
|
||||||
if (token) {
|
orgId: currentOrgId
|
||||||
config.headers['Authorization'] = `Bearer ${token}`;
|
});
|
||||||
|
if (currentToken) {
|
||||||
|
config.headers['Authorization'] = `Bearer ${currentToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentOrgId) {
|
if (currentOrgId) {
|
||||||
config.headers['x-organization-id'] = currentOrgId;
|
config.headers['x-organization-id'] = currentOrgId;
|
||||||
}
|
}
|
||||||
|
if (currentOrgName) {
|
||||||
|
// Encode to handle special characters
|
||||||
|
config.headers['x-organization-name'] = encodeURIComponent(currentOrgName);
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -59,18 +63,12 @@ api.interceptors.request.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Response interceptor to handle 401 (Unauthorized) and 403 errors
|
// Response interceptor to handle 403 errors (guest access denied)
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
|
||||||
// Token expired or invalid
|
|
||||||
localStorage.removeItem('gpi_token');
|
|
||||||
if (!window.location.pathname.includes('/login')) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (error.response?.status === 403) {
|
if (error.response?.status === 403) {
|
||||||
|
// Check if it's a guest permission error
|
||||||
const errorMessage = error.response?.data?.error || '';
|
const errorMessage = error.response?.data?.error || '';
|
||||||
if (errorMessage.includes('Convidados') || errorMessage.includes('guest') || errorMessage.includes('permissão')) {
|
if (errorMessage.includes('Convidados') || errorMessage.includes('guest') || errorMessage.includes('permissão')) {
|
||||||
triggerGuestWarning();
|
triggerGuestWarning();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const systemSettingsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateSettings: async (settings: Partial<SystemSettings>): Promise<SystemSettings> => {
|
updateSettings: async (settings: Partial<SystemSettings>): Promise<SystemSettings> => {
|
||||||
// Axios interceptors in api.ts automatically handle x-clerk-user-id and x-organization-id headers
|
// Axios interceptors in api.ts automatically handle x-auth-user-id and x-organization-id headers
|
||||||
const response = await api.put('/system-settings', settings);
|
const response = await api.put('/system-settings', settings);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -51,7 +51,7 @@ export const systemSettingsService = {
|
|||||||
|
|
||||||
export interface GlobalUser {
|
export interface GlobalUser {
|
||||||
_id: string;
|
_id: string;
|
||||||
id: string;
|
externalId: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -66,10 +66,10 @@ export interface GlobalOrganization {
|
|||||||
isBanned: boolean;
|
isBanned: boolean;
|
||||||
name?: string; // Added
|
name?: string; // Added
|
||||||
members: {
|
members: {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'admin' | 'user' | 'guest';
|
role: 'admin' | 'user' | 'guest';
|
||||||
|
userId: string;
|
||||||
isBanned: boolean;
|
isBanned: boolean;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -190,7 +190,7 @@ export type UserRole = 'guest' | 'user' | 'admin';
|
|||||||
export interface AppUser {
|
export interface AppUser {
|
||||||
id: string;
|
id: string;
|
||||||
_id?: string;
|
_id?: string;
|
||||||
clerkId: string;
|
externalId: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
|||||||
+14
-6
@@ -11,29 +11,37 @@ import yieldStudyRoutes from './routes/yieldStudyRoutes.js';
|
|||||||
import userRoutes from './routes/userRoutes.js';
|
import userRoutes from './routes/userRoutes.js';
|
||||||
import systemSettingsRoutes from './routes/systemSettingsRoutes.js';
|
import systemSettingsRoutes from './routes/systemSettingsRoutes.js';
|
||||||
import geometryTypeRoutes from './routes/geometryTypeRoutes.js';
|
import geometryTypeRoutes from './routes/geometryTypeRoutes.js';
|
||||||
|
import authRoutes from './routes/authRoutes.js';
|
||||||
|
|
||||||
import stockRoutes from './routes/stockRoutes.js';
|
import stockRoutes from './routes/stockRoutes.js';
|
||||||
import notificationRoutes from './routes/notificationRoutes.js';
|
import notificationRoutes from './routes/notificationRoutes.js';
|
||||||
import instrumentRoutes from './routes/instrumentRoutes.js';
|
import instrumentRoutes from './routes/instrumentRoutes.js';
|
||||||
import messageRoutes from './routes/messageRoutes.js';
|
import messageRoutes from './routes/messageRoutes.js';
|
||||||
import authRoutes from './routes/authRoutes.js';
|
|
||||||
import backupRoutes from './routes/backupRoutes.js';
|
import backupRoutes from './routes/backupRoutes.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
|
||||||
import { authenticateJWT } from './middleware/authMiddleware.js';
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: '*', // Be more specific in production
|
origin: '*', // Be more specific in production
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
allowedHeaders: ['Content-Type', 'Authorization']
|
allowedHeaders: ['Content-Type', 'Authorization', 'x-organization-id']
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
// JWT Authentication Middleware
|
import { extractUser } from './middleware/roleMiddleware.js';
|
||||||
app.use(authenticateJWT);
|
|
||||||
|
// LOG DE DEPURAÇÃO PARA CONEXÃO
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
import { authMiddleware } from './middleware/auth.js';
|
||||||
|
app.use(authMiddleware);
|
||||||
|
app.use(extractUser);
|
||||||
|
|
||||||
// Static Uploads
|
// Static Uploads
|
||||||
|
import fs from 'fs';
|
||||||
const uploadsPath = path.join(process.cwd(), 'uploads');
|
const uploadsPath = path.join(process.cwd(), 'uploads');
|
||||||
|
|
||||||
// Ensure uploads directory exists
|
// Ensure uploads directory exists
|
||||||
|
|||||||
@@ -1,46 +1,26 @@
|
|||||||
import mongoose from 'mongoose';
|
import pg from 'pg';
|
||||||
import { GridFSBucket } from 'mongodb';
|
const { Pool } = pg;
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
export let bucket: GridFSBucket;
|
dotenv.config();
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'supabase-db',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || 'Xz0oyb6ArGYG5uAVTVwcvJxRrMuT7EIJ',
|
||||||
|
database: process.env.DB_NAME || 'postgres',
|
||||||
|
ssl: false
|
||||||
|
});
|
||||||
|
|
||||||
export const connectDB = async () => {
|
export const connectDB = async () => {
|
||||||
try {
|
try {
|
||||||
const uri = process.env.MONGODB_URI;
|
const client = await pool.connect();
|
||||||
if (!uri) {
|
console.log('✅ Postgres (Supabase) connected successfully');
|
||||||
throw new Error('MONGODB_URI is not defined in environment variables');
|
client.release();
|
||||||
}
|
|
||||||
|
|
||||||
if (mongoose.connection.readyState >= 1) {
|
|
||||||
console.log('Using existing MongoDB connection');
|
|
||||||
if (!bucket && mongoose.connection.db) {
|
|
||||||
bucket = new GridFSBucket(mongoose.connection.db, { bucketName: 'pdfs' });
|
|
||||||
console.log('✅ GridFS Bucket re-initialized');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Connecting to MongoDB...');
|
|
||||||
if (!uri) console.error('MONGODB_URI is undefined!');
|
|
||||||
|
|
||||||
await mongoose.connect(uri, {
|
|
||||||
maxPoolSize: 10,
|
|
||||||
serverSelectionTimeoutMS: 5000,
|
|
||||||
socketTimeoutMS: 45000,
|
|
||||||
});
|
|
||||||
console.log('✅ MongoDB connected successfully');
|
|
||||||
|
|
||||||
const db = mongoose.connection.db;
|
|
||||||
if (!db) {
|
|
||||||
throw new Error('Database connection not established');
|
|
||||||
}
|
|
||||||
bucket = new GridFSBucket(db, {
|
|
||||||
bucketName: 'pdfs'
|
|
||||||
});
|
|
||||||
console.log('✅ GridFS Bucket initialized');
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ MongoDB connection error:', error);
|
console.error('❌ Postgres connection error:', error);
|
||||||
console.warn('⚠️ Server will continue running for debugging, but database features will be unavailable.');
|
|
||||||
// process.exit(1);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const query = (text: string, params?: any[]) => pool.query(text, params);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
const { Pool } = pg;
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'supabase-db',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || 'Xz0oyb6ArGYG5uAVTVwcvJxRrMuT7EIJ',
|
||||||
|
database: process.env.DB_NAME || 'postgres',
|
||||||
|
ssl: false // Internal network usually doesn't need SSL
|
||||||
|
});
|
||||||
|
|
||||||
|
export const query = (text: string, params?: any[]) => pool.query(text, params);
|
||||||
|
|
||||||
|
export default pool;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as appRecordService from '../services/applicationRecordService.js';
|
import * as appRecordService from '../services/applicationRecordService.js';
|
||||||
import { AuthRequest } from '../middleware/authMiddleware.js';
|
import '../middleware/roleMiddleware.js'; // Ensure type augmentation
|
||||||
|
|
||||||
export const createApplicationRecord = async (req: AuthRequest, res: Response) => {
|
export const createApplicationRecord = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const createdBy = req.appUser?._id?.toString();
|
const createdBy = req.appUser?.externalId;
|
||||||
const record = await appRecordService.createApplicationRecord({ ...req.body, organizationId, createdBy });
|
const record = await appRecordService.createApplicationRecord({ ...req.body, organizationId, createdBy });
|
||||||
res.status(201).json(record);
|
res.status(201).json(record);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -14,7 +14,7 @@ export const createApplicationRecord = async (req: AuthRequest, res: Response) =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getApplicationRecordsByProject = async (req: AuthRequest, res: Response) => {
|
export const getApplicationRecordsByProject = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
@@ -26,10 +26,10 @@ export const getApplicationRecordsByProject = async (req: AuthRequest, res: Resp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateApplicationRecord = async (req: AuthRequest, res: Response) => {
|
export const updateApplicationRecord = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userId = req.appUser?._id?.toString();
|
const userId = req.appUser?.externalId;
|
||||||
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
||||||
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||||
|
|
||||||
@@ -49,10 +49,10 @@ export const updateApplicationRecord = async (req: AuthRequest, res: Response) =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteApplicationRecord = async (req: AuthRequest, res: Response) => {
|
export const deleteApplicationRecord = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userId = req.appUser?._id?.toString();
|
const userId = req.appUser?.externalId;
|
||||||
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
||||||
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||||
|
|
||||||
|
|||||||
@@ -1,105 +1,127 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import User from '../models/User.js';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { query } from '../config/database.js';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod';
|
||||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
|
||||||
|
|
||||||
export const login = async (req: Request, res: Response) => {
|
export const register = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { name, email, password } = req.body;
|
||||||
|
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
res.status(400).json({ error: 'Todos os campos são obrigatórios' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRes = await query('SELECT id FROM gpi.users WHERE email = $1', [email]);
|
||||||
|
if (existingRes.rows.length > 0) {
|
||||||
|
res.status(400).json({ error: 'Email já cadastrado' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const passwordHash = await bcrypt.hash(password, salt);
|
||||||
|
|
||||||
|
// Gere um fakeAuthId para manter compatibilidade com sistemas que esperam um externalId
|
||||||
|
const fakeAuthId = `user_${uuidv4().replace(/-/g, '')}`;
|
||||||
|
|
||||||
|
const insertRes = await query(
|
||||||
|
`INSERT INTO gpi.users (name, email, clerk_id, role, is_banned, updated_at)
|
||||||
|
VALUES ($1, $2, $3, 'user', false, NOW()) RETURNING *`,
|
||||||
|
[name, email, fakeAuthId]
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
{ userId: newUser.id, externalId: newUser.clerk_id, role: newUser.role },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Usuário criado com sucesso',
|
||||||
|
token,
|
||||||
|
user: { id: newUser.id, name: newUser.name, email: newUser.email, role: newUser.role, externalId: newUser.clerk_id }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Register Error:', error);
|
||||||
|
res.status(500).json({ error: 'Erro no servidor' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const login = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return res.status(400).json({ error: 'E-mail e senha são obrigatórios' });
|
res.status(400).json({ error: 'Email e senha são obrigatórios' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findOne({ email }).select('+password');
|
const userRes = await query('SELECT * FROM gpi.users WHERE email = $1', [email]);
|
||||||
|
const user = userRes.rows[0];
|
||||||
|
|
||||||
if (!user || !user.password) {
|
|
||||||
return res.status(401).json({ error: 'Credenciais inválidas' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMatch = await bcrypt.compare(password, user.password);
|
|
||||||
|
|
||||||
if (!isMatch) {
|
|
||||||
return res.status(401).json({ error: 'Credenciais inválidas' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.isBanned) {
|
|
||||||
return res.status(403).json({ error: 'Sua conta está bloqueada' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = jwt.sign(
|
|
||||||
{ id: user._id, email: user.email, role: user.role },
|
|
||||||
JWT_SECRET,
|
|
||||||
{ expiresIn: JWT_EXPIRES_IN }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove password from user object
|
|
||||||
const userObj = user.toObject();
|
|
||||||
delete userObj.password;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
token,
|
|
||||||
user: userObj
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
res.status(500).json({ error: 'Erro interno no servidor' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const register = async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { email, password, name, organizationId } = req.body;
|
|
||||||
|
|
||||||
if (!email || !password || !name) {
|
|
||||||
return res.status(400).json({ error: 'Campos obrigatórios ausentes' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = await User.findOne({ email });
|
|
||||||
if (existingUser) {
|
|
||||||
return res.status(400).json({ error: 'E-mail já cadastrado' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
|
||||||
|
|
||||||
const user = await User.create({
|
|
||||||
email,
|
|
||||||
password: hashedPassword,
|
|
||||||
name,
|
|
||||||
organizationId,
|
|
||||||
role: 'user' // Default role
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = jwt.sign(
|
|
||||||
{ id: user._id, email: user.email, role: user.role },
|
|
||||||
JWT_SECRET,
|
|
||||||
{ expiresIn: JWT_EXPIRES_IN }
|
|
||||||
);
|
|
||||||
|
|
||||||
const userObj = user.toObject();
|
|
||||||
delete userObj.password;
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
token,
|
|
||||||
user: userObj
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Registration error:', error);
|
|
||||||
res.status(500).json({ error: 'Erro ao criar usuário' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMe = async (req: any, res: Response) => {
|
|
||||||
try {
|
|
||||||
const user = await User.findById(req.user.id);
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({ error: 'Usuário não encontrado' });
|
res.status(400).json({ error: 'Usuário não encontrado' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
res.json(user);
|
|
||||||
|
// Recuperar password_hash de algum lugar se não estiver no select principal (alguns schemas escondem)
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!isMatch) {
|
||||||
|
res.status(400).json({ error: 'Credenciais inválidas' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ userId: user.id, externalId: user.clerk_id || user.logto_id, role: user.role },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
message: 'Login realizado com sucesso',
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
externalId: user.clerk_id || user.logto_id
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Erro ao obter dados do usuário' });
|
console.error('Login Error:', error);
|
||||||
|
res.status(500).json({ error: 'Erro no servidor' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMe = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.appUser) {
|
||||||
|
res.status(401).json({ error: 'Não autorizado' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(req.appUser);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GetMe Error:', error);
|
||||||
|
res.status(500).json({ error: 'Erro no servidor' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import fs from 'fs';
|
|||||||
import * as pdfExtractionService from '../services/pdfExtractionService.js';
|
import * as pdfExtractionService from '../services/pdfExtractionService.js';
|
||||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||||
import { notificationService } from '../services/notificationService.js';
|
import { notificationService } from '../services/notificationService.js';
|
||||||
|
import * as fileService from '../services/fileService.js';
|
||||||
|
|
||||||
interface AuthRequest extends Request {
|
interface AuthRequest extends Request {
|
||||||
appUser?: IAppUser;
|
appUser?: IAppUser;
|
||||||
@@ -12,9 +13,7 @@ interface AuthRequest extends Request {
|
|||||||
export const getAllDataSheets = async (req: AuthRequest, res: Response) => {
|
export const getAllDataSheets = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
console.log('Backend: Fetching datasheets for org:', organizationId);
|
|
||||||
const sheets = await dataSheetService.getAllDataSheets(organizationId);
|
const sheets = await dataSheetService.getAllDataSheets(organizationId);
|
||||||
console.log(`Backend: Found ${sheets.length} sheets`);
|
|
||||||
res.json(sheets);
|
res.json(sheets);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -32,7 +31,6 @@ export const extractData = async (req: AuthRequest, res: Response) => {
|
|||||||
const fileBuffer = fs.readFileSync(file.path);
|
const fileBuffer = fs.readFileSync(file.path);
|
||||||
const data = await pdfExtractionService.extractDataFromPdf(fileBuffer);
|
const data = await pdfExtractionService.extractDataFromPdf(fileBuffer);
|
||||||
|
|
||||||
// Return extracted data AND the file path so we don't need to re-upload
|
|
||||||
res.json({
|
res.json({
|
||||||
...data,
|
...data,
|
||||||
tempFilePath: file.path
|
tempFilePath: file.path
|
||||||
@@ -44,7 +42,6 @@ export const extractData = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
@@ -58,31 +55,15 @@ export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
} = req.body;
|
} = req.body;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
|
||||||
// Note: New logic prefers 'file' upload which we store in DB.
|
let fileId: string | undefined = undefined;
|
||||||
// If fileUrl is provided (legacy or external link), we use that but don't store binary.
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let fileId: any = undefined;
|
|
||||||
let finalFileUrl = fileUrl || '';
|
let finalFileUrl = fileUrl || '';
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
// Read file buffer
|
|
||||||
const buffer = fs.readFileSync(file.path);
|
const buffer = fs.readFileSync(file.path);
|
||||||
|
const savedFile = await fileService.saveFile(file.originalname, file.mimetype, buffer);
|
||||||
|
fileId = savedFile.id;
|
||||||
|
finalFileUrl = savedFile.id; // Using ID as reference
|
||||||
|
|
||||||
// Save to StoredFile collection
|
|
||||||
const { default: StoredFile } = await import('../models/StoredFile.js');
|
|
||||||
const newFile = await StoredFile.create({
|
|
||||||
filename: file.originalname,
|
|
||||||
contentType: file.mimetype,
|
|
||||||
data: buffer,
|
|
||||||
size: file.size,
|
|
||||||
uploadDate: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
fileId = newFile._id;
|
|
||||||
finalFileUrl = newFile._id.toString(); // Use ID as URL reference for consistency with frontend expectations if possible, or we might need to adjust frontend to use /api/datasheets/file/:id
|
|
||||||
|
|
||||||
// Clean up temp file
|
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(file.path);
|
fs.unlinkSync(file.path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -90,16 +71,6 @@ export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fileId && !finalFileUrl) {
|
|
||||||
// Check if fileUrl allows empty. The schema says optional now, but logically a datasheet usually has a file.
|
|
||||||
// However, for simplified Diluent registration, we might not have one.
|
|
||||||
// If the user didn't send a file and didn't send a URL, and schema is optional, we can proceed.
|
|
||||||
// But let's check if we want to enforce it.
|
|
||||||
// If manufacturerCode (Diluent indicator?) is present, maybe skip check?
|
|
||||||
// Actually, I removed 'required' from schema, so I should probably relax this check too.
|
|
||||||
// return res.status(400).json({ error: 'File is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSheet = await dataSheetService.createDataSheet({
|
const newSheet = await dataSheetService.createDataSheet({
|
||||||
name,
|
name,
|
||||||
manufacturer,
|
manufacturer,
|
||||||
@@ -127,14 +98,13 @@ export const createDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
organizationId
|
organizationId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notificação de Nova Ficha Técnica
|
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
await notificationService.create({
|
await notificationService.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
title: 'Nova Ficha Técnica',
|
title: 'Nova Ficha Técnica',
|
||||||
message: `A ficha técnica "${name}" (${manufacturer}) foi adicionada à biblioteca.`,
|
message: `A ficha técnica "${name}" (${manufacturer}) foi adicionada à biblioteca.`,
|
||||||
type: 'info',
|
type: 'info',
|
||||||
metadata: { dataSheetId: newSheet._id, triggerType: 'datasheet_created' }
|
metadata: { dataSheetId: newSheet.id, triggerType: 'datasheet_created' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,10 +120,6 @@ export const deleteDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
|
||||||
// Find sheet to delete file if exists
|
|
||||||
// (Optional: Implement file deletion logic here if strict cleanup needed)
|
|
||||||
|
|
||||||
const success = await dataSheetService.deleteDataSheet(id as string, organizationId);
|
const success = await dataSheetService.deleteDataSheet(id as string, organizationId);
|
||||||
if (success) {
|
if (success) {
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
@@ -179,8 +145,7 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
notes, fileUrl
|
notes, fileUrl
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const updates: any = {
|
||||||
const updates: Record<string, any> = {
|
|
||||||
name,
|
name,
|
||||||
manufacturer,
|
manufacturer,
|
||||||
type,
|
type,
|
||||||
@@ -202,23 +167,11 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
// Read file buffer
|
|
||||||
const buffer = fs.readFileSync(file.path);
|
const buffer = fs.readFileSync(file.path);
|
||||||
|
const savedFile = await fileService.saveFile(file.originalname, file.mimetype, buffer);
|
||||||
|
updates.fileId = savedFile.id;
|
||||||
|
updates.fileUrl = savedFile.id;
|
||||||
|
|
||||||
// Save to StoredFile collection
|
|
||||||
const { default: StoredFile } = await import('../models/StoredFile.js');
|
|
||||||
const newFile = await StoredFile.create({
|
|
||||||
filename: file.originalname,
|
|
||||||
contentType: file.mimetype,
|
|
||||||
data: buffer,
|
|
||||||
size: file.size,
|
|
||||||
uploadDate: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
updates.fileId = newFile._id;
|
|
||||||
updates.fileUrl = newFile._id.toString();
|
|
||||||
|
|
||||||
// Clean up temp file
|
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(file.path);
|
fs.unlinkSync(file.path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -226,12 +179,6 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
} else if (fileUrl) {
|
} else if (fileUrl) {
|
||||||
updates.fileUrl = String(fileUrl);
|
updates.fileUrl = String(fileUrl);
|
||||||
// If fileUrl is being updated but not file, we might lose fileId reference?
|
|
||||||
// If the user sends the same fileUrl (which is the ID), it's fine.
|
|
||||||
// But if they send a new external URL, we should probably unset fileId.
|
|
||||||
// For now, let's assume if it's an external URL, fileId should remain unless explicitly cleared?
|
|
||||||
// Safer: if fileUrl is explicitly sent and doesn't match an ID format, maybe clear fileId?
|
|
||||||
// Actually, keep it simple.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSheet = await dataSheetService.updateDataSheet(id, updates, organizationId);
|
const updatedSheet = await dataSheetService.updateDataSheet(id, updates, organizationId);
|
||||||
@@ -250,15 +197,14 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
export const getFile = async (req: Request, res: Response) => {
|
export const getFile = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id_or_filename = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
|
||||||
// Check if it's a MongoDB ObjectId (24 hex chars)
|
// Check if it's a UUID
|
||||||
if (/^[0-9a-fA-F]{24}$/.test(id_or_filename)) {
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
|
||||||
const { default: StoredFile } = await import('../models/StoredFile.js');
|
const fileDoc = await fileService.getFileById(id);
|
||||||
const fileDoc = await StoredFile.findById(id_or_filename);
|
|
||||||
|
|
||||||
if (fileDoc) {
|
if (fileDoc) {
|
||||||
res.set('Content-Type', fileDoc.contentType || 'application/pdf');
|
res.set('Content-Type', fileDoc.content_type || 'application/pdf');
|
||||||
res.set('Content-Disposition', `inline; filename="${fileDoc.filename}"`);
|
res.set('Content-Disposition', `inline; filename="${fileDoc.filename}"`);
|
||||||
res.set('Access-Control-Allow-Origin', '*');
|
res.set('Access-Control-Allow-Origin', '*');
|
||||||
res.set('Cache-Control', 'public, max-age=3600');
|
res.set('Cache-Control', 'public, max-age=3600');
|
||||||
@@ -266,19 +212,7 @@ export const getFile = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to file system (legacy)
|
res.status(404).json({ error: 'File not found' });
|
||||||
const stream = dataSheetService.getFileStream(id_or_filename);
|
|
||||||
|
|
||||||
stream.on('file', (file) => {
|
|
||||||
res.set('Content-Type', 'application/pdf');
|
|
||||||
res.set('Content-Disposition', `inline; filename="${file.filename}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', () => {
|
|
||||||
res.status(404).json({ error: 'File not found' });
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.pipe(res);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error('Error getting file:', error);
|
console.error('Error getting file:', error);
|
||||||
|
|||||||
@@ -1,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,26 +1,26 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as inspectionService from '../services/inspectionService.js';
|
import * as inspectionService from '../services/inspectionService.js';
|
||||||
import { notificationService } from '../services/notificationService.js';
|
import { notificationService } from '../services/notificationService.js';
|
||||||
import '../middleware/roleMiddleware.js'; // Ensure type augmentation
|
import * as fileService from '../services/fileService.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
export const createInspection = async (req: Request, res: Response) => {
|
export const createInspection = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const createdBy = req.appUser?._id?.toString();
|
const createdBy = req.appUser?.externalId;
|
||||||
const inspection = await inspectionService.createInspection({
|
const inspection = await inspectionService.createInspection({
|
||||||
...req.body,
|
...req.body,
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy
|
createdBy
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notificação de Inspeção Reprovada
|
|
||||||
if (req.body.appearance === 'rejected' && organizationId) {
|
if (req.body.appearance === 'rejected' && organizationId) {
|
||||||
await notificationService.create({
|
await notificationService.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
title: 'Inspeção Reprovada',
|
title: 'Inspeção Reprovada',
|
||||||
message: `Uma inspeção foi reprovada na obra (ID: ${req.body.projectId}).`,
|
message: `Uma inspeção foi reprovada na obra (ID: ${req.body.projectId}).`,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
metadata: { inspectionId: inspection._id, projectId: req.body.projectId, triggerType: 'inspection_rejected' }
|
metadata: { inspectionId: inspection.id, projectId: req.body.projectId, triggerType: 'inspection_rejected' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export const getInspectionsByProject = async (req: Request, res: Response) => {
|
|||||||
export const updateInspection = async (req: Request, res: Response) => {
|
export const updateInspection = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userId = req.appUser?._id?.toString();
|
const userId = req.appUser?.externalId;
|
||||||
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
||||||
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export const updateInspection = async (req: Request, res: Response) => {
|
|||||||
export const deleteInspection = async (req: Request, res: Response) => {
|
export const deleteInspection = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userId = req.appUser?._id?.toString();
|
const userId = req.appUser?.externalId;
|
||||||
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
const userRole = req.appUser?.organizationRole || req.appUser?.role;
|
||||||
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||||
|
|
||||||
@@ -99,15 +99,21 @@ export const getAllInspections = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadPhoto = async (req: Request & { file?: any }, res: Response) => {
|
export const uploadPhoto = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the public URL for the file
|
const buffer = fs.readFileSync(req.file.path);
|
||||||
// Assuming 'uploads' is served statically at /uploads
|
const savedFile = await fileService.saveFile(req.file.originalname, req.file.mimetype, buffer);
|
||||||
const fileUrl = `/uploads/${req.file.filename}`;
|
|
||||||
|
// Clean up temp file
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
|
||||||
|
// We'll use /api/datasheets/file/:id to serve these if we use the same endpoint
|
||||||
|
// or create a new general /api/files/:id
|
||||||
|
const fileUrl = `/api/datasheets/file/${savedFile.id}`;
|
||||||
|
|
||||||
res.json({ url: fileUrl });
|
res.json({ url: fileUrl });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import Instrument from '../models/Instrument.js';
|
import * as instrumentService from '../services/instrumentService.js';
|
||||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||||
|
|
||||||
interface AuthRequest extends Request {
|
interface AuthRequest extends Request {
|
||||||
@@ -9,33 +9,10 @@ interface AuthRequest extends Request {
|
|||||||
export const createInstrument = async (req: AuthRequest, res: Response) => {
|
export const createInstrument = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const { name, type, manufacturer, modelName, serialNumber, calibrationDate, calibrationExpirationDate, certificateUrl, notes } = req.body;
|
const instrument = await instrumentService.createInstrument({
|
||||||
|
...req.body,
|
||||||
const existing = await Instrument.findOne({ organizationId, serialNumber });
|
organizationId
|
||||||
if (existing) {
|
|
||||||
return res.status(400).json({ error: 'Já existe um instrumento com este número de série.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determinar status inicial baseado na validade
|
|
||||||
let status = 'active';
|
|
||||||
if (calibrationExpirationDate && new Date(calibrationExpirationDate) < new Date()) {
|
|
||||||
status = 'expired';
|
|
||||||
}
|
|
||||||
|
|
||||||
const instrument = await Instrument.create({
|
|
||||||
organizationId,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
manufacturer,
|
|
||||||
modelName,
|
|
||||||
serialNumber,
|
|
||||||
calibrationDate,
|
|
||||||
calibrationExpirationDate,
|
|
||||||
certificateUrl,
|
|
||||||
status,
|
|
||||||
notes
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(instrument);
|
res.status(201).json(instrument);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -47,12 +24,7 @@ export const getInstruments = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const { status } = req.query;
|
const { status } = req.query;
|
||||||
|
const instruments = await instrumentService.getInstruments(organizationId, status as string);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const query: any = { organizationId };
|
|
||||||
if (status) query.status = status;
|
|
||||||
|
|
||||||
const instruments = await Instrument.find(query).sort({ name: 1 });
|
|
||||||
res.json(instruments);
|
res.json(instruments);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -64,24 +36,7 @@ export const updateInstrument = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const instrument = await instrumentService.updateInstrument(id, req.body, organizationId);
|
||||||
// Recalcular status se data de validade mudar
|
|
||||||
const updates = { ...req.body };
|
|
||||||
if (updates.calibrationExpirationDate) {
|
|
||||||
if (new Date(updates.calibrationExpirationDate) < new Date()) {
|
|
||||||
updates.status = 'expired';
|
|
||||||
} else if (updates.status === 'expired') {
|
|
||||||
// Se estava expirado e a data é futura, reativar (se o usuário não setou outro status)
|
|
||||||
updates.status = 'active';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const instrument = await Instrument.findOneAndUpdate(
|
|
||||||
{ _id: id, organizationId },
|
|
||||||
updates,
|
|
||||||
{ new: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!instrument) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
if (!instrument) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
||||||
res.json(instrument);
|
res.json(instrument);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -94,10 +49,8 @@ export const deleteInstrument = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const success = await instrumentService.deleteInstrument(id, organizationId);
|
||||||
const deleted = await Instrument.findOneAndDelete({ _id: id, organizationId });
|
if (!success) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
||||||
if (!deleted) return res.status(404).json({ error: 'Instrumento não encontrado.' });
|
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { 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';
|
|
||||||
import { AuthRequest } from '../middleware/authMiddleware.js';
|
|
||||||
|
|
||||||
// Send a message
|
// Send a message
|
||||||
export const sendMessage = async (req: AuthRequest, res: Response) => {
|
export const sendMessage = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { toUserId, message } = req.body;
|
const { toUserId, message } = req.body;
|
||||||
const fromUserId = req.appUser?._id?.toString();
|
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) {
|
||||||
@@ -22,36 +20,27 @@ export const sendMessage = async (req: AuthRequest, 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.' });
|
||||||
@@ -59,39 +48,30 @@ export const sendMessage = async (req: AuthRequest, res: Response) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get unread messages for current user
|
// Get unread messages for current user
|
||||||
export const getUnreadMessages = async (req: AuthRequest, res: Response) => {
|
export const getUnreadMessages = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const toUserId = req.appUser?._id?.toString();
|
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.' });
|
||||||
@@ -99,35 +79,22 @@ export const getUnreadMessages = async (req: AuthRequest, res: Response) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Mark message as read
|
// Mark message as read
|
||||||
export const markMessageAsRead = async (req: AuthRequest, res: Response) => {
|
export const markMessageAsRead = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.appUser?._id?.toString();
|
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.' });
|
||||||
@@ -135,37 +102,30 @@ export const markMessageAsRead = async (req: AuthRequest, res: Response) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get my pending (unread) sent messages
|
// Get my pending (unread) sent messages
|
||||||
export const getMyPendingMessages = async (req: AuthRequest, res: Response) => {
|
export const getMyPendingMessages = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const fromUserId = req.appUser?._id?.toString();
|
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.' });
|
||||||
@@ -173,32 +133,21 @@ export const getMyPendingMessages = async (req: AuthRequest, res: Response) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Delete a message (only if unread and sender is the current user)
|
// Delete a message (only if unread and sender is the current user)
|
||||||
export const deleteMessage = async (req: AuthRequest, res: Response) => {
|
export const deleteMessage = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.appUser?._id?.toString();
|
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);
|
||||||
@@ -206,37 +155,40 @@ export const deleteMessage = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Recipient deletes/archives a message
|
// Recipient archives a message
|
||||||
export const archiveMessage = async (req: AuthRequest, res: Response) => {
|
export const archiveMessage = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.appUser?._id?.toString();
|
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.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recipientDeleteMessage = async (req: AuthRequest, res: Response) => {
|
export const recipientDeleteMessage = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.appUser?._id?.toString();
|
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,12 +1,14 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { notificationService } from '../services/notificationService.js';
|
import { notificationService } from '../services/notificationService.js';
|
||||||
import { AuthRequest } from '../middleware/authMiddleware.js';
|
|
||||||
|
|
||||||
export const notificationController = {
|
export const notificationController = {
|
||||||
getUserNotifications: async (req: AuthRequest, res: Response) => {
|
getUserNotifications: async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
const userId = req.appUser?._id?.toString() || '';
|
const userId = req.headers['x-user-id'] as string; // Assumindo que temos o ID do usuário (externalId ou email)
|
||||||
|
|
||||||
|
// Se não tiver userId no header (ainda não implementado auth full), tentar pegar do query ou usar um fallback
|
||||||
|
// Nota: Idealmente o middleware de auth popula req.user. Vamos assumir que passamos x-user-id no frontend por enquanto.
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
return res.status(400).json({ error: 'Organization ID is required' });
|
return res.status(400).json({ error: 'Organization ID is required' });
|
||||||
@@ -24,7 +26,7 @@ export const notificationController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
markAsRead: async (req: AuthRequest, res: Response) => {
|
markAsRead: async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const notification = await notificationService.markAsRead(id as string);
|
const notification = await notificationService.markAsRead(id as string);
|
||||||
@@ -35,10 +37,10 @@ export const notificationController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
markAllAsRead: async (req: AuthRequest, res: Response) => {
|
markAllAsRead: async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
const userId = req.appUser?._id?.toString() || '';
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
return res.status(400).json({ error: 'Organization ID is required' });
|
return res.status(400).json({ error: 'Organization ID is required' });
|
||||||
@@ -52,10 +54,10 @@ export const notificationController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAll: async (req: AuthRequest, res: Response) => {
|
clearAll: async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
const userId = req.appUser?._id?.toString() || '';
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
return res.status(400).json({ error: 'Organization ID is required' });
|
return res.status(400).json({ error: 'Organization ID is required' });
|
||||||
@@ -69,10 +71,10 @@ export const notificationController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
archive: async (req: AuthRequest, res: Response) => {
|
archive: async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.appUser?._id?.toString() || '';
|
const userId = req.headers['x-user-id'] as string;
|
||||||
const notification = await notificationService.archive(id as string, userId);
|
const notification = await notificationService.archive(id as string, userId);
|
||||||
res.json(notification);
|
res.json(notification);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -81,10 +83,10 @@ export const notificationController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (req: AuthRequest, res: Response) => {
|
delete: async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.appUser?._id?.toString() || '';
|
const userId = req.headers['x-user-id'] as string;
|
||||||
await notificationService.softDelete(id as string, userId);
|
await notificationService.softDelete(id as string, userId);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ interface AuthRequest extends Request {
|
|||||||
|
|
||||||
export const createProject = async (req: AuthRequest, res: Response) => {
|
export const createProject = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
console.log('Backend creating project. Body:', req.body);
|
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const project = await projectService.createProject({ ...req.body, organizationId });
|
const project = await projectService.createProject({ ...req.body, organizationId });
|
||||||
console.log('Project created successfully:', project._id);
|
|
||||||
res.status(201).json(project);
|
res.status(201).json(project);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -74,16 +72,13 @@ export const updateProject = async (req: AuthRequest, res: Response) => {
|
|||||||
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
|
||||||
const project = await projectService.updateProject(req.params.id as string, req.body, organizationId, isGlobalAdmin);
|
const project = await projectService.updateProject(req.params.id as string, req.body, organizationId, isGlobalAdmin);
|
||||||
|
|
||||||
// Notificação se Peso mudar (Exemplo simplificado, idealmente compararíamos com valor anterior)
|
|
||||||
// Como o update retorna o objeto atualizado, podemos assumir que se o body tem weightKg, houve intenção de mudar.
|
|
||||||
// Para ser mais preciso, deveríamos buscar o antigo antes, mas para MVP vamos notificar se houver o campo no body.
|
|
||||||
if (req.body.weightKg !== undefined && organizationId) {
|
if (req.body.weightKg !== undefined && organizationId) {
|
||||||
await notificationService.create({
|
await notificationService.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
title: 'Atualização de Obra',
|
title: 'Atualização de Obra',
|
||||||
message: `O peso da obra "${project.name}" foi atualizado para ${project.weightKg}kg.`,
|
message: `O peso da obra "${project.name}" foi atualizado para ${project.weightKg}kg.`,
|
||||||
type: 'info',
|
type: 'info',
|
||||||
metadata: { projectId: project._id, triggerType: 'project_update' }
|
metadata: { projectId: project.id, triggerType: 'project_update' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import StockItem from '../models/StockItem.js';
|
import * as stockItemService from '../services/stockItemService.js';
|
||||||
import StockMovement from '../models/StockMovement.js';
|
|
||||||
|
|
||||||
import { IAppUser } from '../middleware/roleMiddleware.js';
|
import { IAppUser } from '../middleware/roleMiddleware.js';
|
||||||
import { notificationService } from '../services/notificationService.js';
|
import { notificationService } from '../services/notificationService.js';
|
||||||
|
|
||||||
@@ -14,80 +12,26 @@ export const createStockItem = async (req: AuthRequest, res: Response) => {
|
|||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const {
|
const {
|
||||||
dataSheetId,
|
dataSheetId, rrNumber, batchNumber, quantity, unit,
|
||||||
rrNumber,
|
expirationDate, notes, color, invoiceNumber, receivedBy, minStock
|
||||||
batchNumber,
|
|
||||||
quantity,
|
|
||||||
unit,
|
|
||||||
expirationDate,
|
|
||||||
notes,
|
|
||||||
color,
|
|
||||||
invoiceNumber,
|
|
||||||
receivedBy,
|
|
||||||
minStock
|
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) {
|
if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) {
|
||||||
return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' });
|
return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate RR within Org
|
const savedItem = await stockItemService.createStockItem({
|
||||||
const existing = await StockItem.findOne({ organizationId, rrNumber });
|
...req.body,
|
||||||
if (existing) {
|
|
||||||
return res.status(400).json({ error: `Já existe um item com o RR ${rrNumber}.` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Min Stock Inheritance Logic ---
|
|
||||||
let finalMinStock = Number(minStock) || 0;
|
|
||||||
|
|
||||||
// If user didn't provide a specific minStock (or provided 0), try to inherit from existing group
|
|
||||||
if (finalMinStock === 0) {
|
|
||||||
const existingGroupItem = await StockItem.findOne({
|
|
||||||
organizationId,
|
|
||||||
dataSheetId,
|
|
||||||
color
|
|
||||||
}).sort({ updatedAt: -1 }); // Get latest active config
|
|
||||||
|
|
||||||
if (existingGroupItem && existingGroupItem.minStock > 0) {
|
|
||||||
finalMinStock = existingGroupItem.minStock;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If user DID provide a minStock, update all existing items in that group to match?
|
|
||||||
// User requested: "a regra de estoque minimo definido no cadastro precisa estar clonado para novos cadastros"
|
|
||||||
// And "soma dessas 'mesmas' tintas sejam comparadas com o estoque minimo cadastrado a elas"
|
|
||||||
// This implies the rule is a Property of the Group. So create/update should enforce consistency.
|
|
||||||
if (finalMinStock > 0) {
|
|
||||||
await StockItem.updateMany(
|
|
||||||
{ organizationId, dataSheetId, color },
|
|
||||||
{ $set: { minStock: finalMinStock } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newItem = new StockItem({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?._id,
|
createdBy: req.appUser?.externalId,
|
||||||
dataSheetId,
|
|
||||||
rrNumber,
|
|
||||||
batchNumber,
|
|
||||||
quantity: Number(quantity),
|
quantity: Number(quantity),
|
||||||
unit,
|
minStock: Number(minStock) || 0
|
||||||
minStock: finalMinStock,
|
|
||||||
expirationDate,
|
|
||||||
notes,
|
|
||||||
color,
|
|
||||||
invoiceNumber,
|
|
||||||
receivedBy
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedItem = await newItem.save();
|
await stockItemService.createStockMovement({
|
||||||
|
|
||||||
// Create Initial Movement (ENTRY)
|
|
||||||
await StockMovement.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?._id,
|
createdBy: req.appUser?.externalId,
|
||||||
stockItemId: savedItem._id,
|
stockItemId: savedItem.id,
|
||||||
movementNumber: 1,
|
movementNumber: 1,
|
||||||
type: 'ENTRY',
|
type: 'ENTRY',
|
||||||
quantity: Number(quantity),
|
quantity: Number(quantity),
|
||||||
@@ -95,24 +39,19 @@ export const createStockItem = async (req: AuthRequest, res: Response) => {
|
|||||||
notes: 'Abertura de Lote / Entrada Inicial'
|
notes: 'Abertura de Lote / Entrada Inicial'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notificação de Recebimento
|
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
await notificationService.create({
|
await notificationService.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
title: 'Recebimento de Material',
|
title: 'Recebimento de Material',
|
||||||
message: `Recebido: ${quantity}${unit} de ${savedItem.rrNumber} (Lote: ${batchNumber}).`,
|
message: `Recebido: ${quantity}${unit} de ${savedItem.rr_number} (Lote: ${batchNumber}).`,
|
||||||
type: 'info',
|
type: 'info',
|
||||||
metadata: { stockItemId: savedItem._id, triggerType: 'stock_received' }
|
metadata: { stockItemId: savedItem.id, triggerType: 'stock_received' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Low Stock immediately
|
|
||||||
await notificationService.checkLowStock(savedItem._id.toString());
|
|
||||||
|
|
||||||
res.status(201).json(savedItem);
|
res.status(201).json(savedItem);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error('Error creating stock item:', error);
|
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -121,46 +60,16 @@ export const updateStockItem = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
// Only allow updating metadata, NOT quantity directly (quantity must be via adjustments)
|
const { quantity, ...otherData } = req.body;
|
||||||
// Adjusting logic: Admin might need to fix typo in quantity without movement record?
|
|
||||||
// Better enforcing movements. If quantity changes, user should use "Adjustment".
|
|
||||||
// Here we create a general update for details like Notes, Dates, etc.
|
|
||||||
|
|
||||||
const { quantity, ...otherData } = req.body; // Separate quantity
|
|
||||||
|
|
||||||
if (quantity !== undefined) {
|
if (quantity !== undefined) {
|
||||||
return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' });
|
return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if Min Stock is being updated
|
const updated = await stockItemService.updateStockItem(id, organizationId!, otherData);
|
||||||
if (otherData.minStock !== undefined) {
|
|
||||||
const item = await StockItem.findOne({ _id: id, organizationId });
|
|
||||||
if (item) {
|
|
||||||
// Propagate to all siblings (same Product + Color)
|
|
||||||
await StockItem.updateMany(
|
|
||||||
{
|
|
||||||
organizationId,
|
|
||||||
dataSheetId: item.dataSheetId,
|
|
||||||
color: item.color
|
|
||||||
},
|
|
||||||
{ $set: { minStock: otherData.minStock } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await StockItem.findOneAndUpdate(
|
|
||||||
{ _id: id, organizationId },
|
|
||||||
otherData,
|
|
||||||
{ new: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!updated) return res.status(404).json({ error: 'Item não encontrado.' });
|
if (!updated) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
|
|
||||||
// Check Low Stock (in case minStock changed)
|
|
||||||
await notificationService.checkLowStock(updated._id.toString());
|
|
||||||
|
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
@@ -172,43 +81,32 @@ export const adjustStock = async (req: AuthRequest, res: Response) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const { quantityDelta, reason } = req.body; // quantityDelta: +10 or -5
|
const { quantityDelta, reason } = req.body;
|
||||||
|
|
||||||
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
|
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
|
||||||
if (!quantityDelta || isNaN(quantityDelta)) return res.status(400).json({ error: 'Quantidade inválida.' });
|
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: id, organizationId });
|
const item = await stockItemService.getStockItemById(id, organizationId!);
|
||||||
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
|
|
||||||
// Calculate new quantity
|
|
||||||
const newQuantity = Number(item.quantity) + Number(quantityDelta);
|
const newQuantity = Number(item.quantity) + Number(quantityDelta);
|
||||||
if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' });
|
if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' });
|
||||||
|
|
||||||
item.quantity = newQuantity;
|
await stockItemService.updateStockItemQuantity(id, newQuantity);
|
||||||
await item.save();
|
|
||||||
|
|
||||||
// Calculate next movement number
|
const lastNum = await stockItemService.getLatestMovementNumber(id);
|
||||||
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
|
|
||||||
const count = await StockMovement.countDocuments({ stockItemId: item._id });
|
|
||||||
const movementNumber = (lastMov?.movementNumber || count) + 1;
|
|
||||||
|
|
||||||
// Register Movement
|
await stockItemService.createStockMovement({
|
||||||
await StockMovement.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?._id,
|
createdBy: req.appUser?.externalId,
|
||||||
stockItemId: item._id,
|
stockItemId: id,
|
||||||
movementNumber,
|
movementNumber: lastNum + 1,
|
||||||
type: 'ADJUSTMENT',
|
type: 'ADJUSTMENT',
|
||||||
quantity: Number(quantityDelta),
|
quantity: Number(quantityDelta),
|
||||||
responsible: userName,
|
responsible: userName,
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check Low Stock
|
res.json({ ...item, quantity: newQuantity });
|
||||||
await notificationService.checkLowStock(item._id.toString());
|
|
||||||
|
|
||||||
res.json(item);
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
@@ -222,40 +120,29 @@ export const consumeStock = async (req: AuthRequest, res: Response) => {
|
|||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const { quantityConsumed, requester, date } = req.body;
|
const { quantityConsumed, requester, date } = req.body;
|
||||||
|
|
||||||
if (!requester) return res.status(400).json({ error: 'Solicitante é obrigatório.' });
|
const item = await stockItemService.getStockItemById(id, organizationId!);
|
||||||
if (!quantityConsumed || Number(quantityConsumed) <= 0) return res.status(400).json({ error: 'Quantidade deve ser maior que zero.' });
|
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: id, organizationId });
|
|
||||||
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
|
|
||||||
if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' });
|
if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' });
|
||||||
|
|
||||||
item.quantity -= Number(quantityConsumed);
|
const newQuantity = item.quantity - Number(quantityConsumed);
|
||||||
await item.save();
|
await stockItemService.updateStockItemQuantity(id, newQuantity);
|
||||||
|
|
||||||
// Calculate next movement number
|
const lastNum = await stockItemService.getLatestMovementNumber(id);
|
||||||
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
|
|
||||||
const count = await StockMovement.countDocuments({ stockItemId: item._id });
|
|
||||||
const movementNumber = (lastMov?.movementNumber || count) + 1;
|
|
||||||
|
|
||||||
// Register Movement (Negative quantity for consumption)
|
await stockItemService.createStockMovement({
|
||||||
await StockMovement.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
createdBy: req.appUser?._id,
|
createdBy: req.appUser?.externalId,
|
||||||
stockItemId: item._id,
|
stockItemId: id,
|
||||||
movementNumber,
|
movementNumber: lastNum + 1,
|
||||||
type: 'CONSUMPTION',
|
type: 'CONSUMPTION',
|
||||||
quantity: -Number(quantityConsumed), // Negative
|
quantity: -Number(quantityConsumed),
|
||||||
responsible: userName,
|
responsible: userName,
|
||||||
requester,
|
requester,
|
||||||
date: date || new Date()
|
date: date || new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check Low Stock
|
res.json({ ...item, quantity: newQuantity });
|
||||||
await notificationService.checkLowStock(item._id.toString());
|
|
||||||
|
|
||||||
res.json(item);
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
@@ -266,17 +153,8 @@ export const deleteStockItem = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const success = await stockItemService.deleteStockItem(id, organizationId!);
|
||||||
// Optional: Block delete if there are movements other than ENTRY?
|
if (!success) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
// For simplicity allow Admin to nuke it.
|
|
||||||
|
|
||||||
const deleted = await StockItem.findOneAndDelete({ _id: id, organizationId });
|
|
||||||
if (!deleted) return res.status(404).json({ error: 'Item não encontrado.' });
|
|
||||||
|
|
||||||
// Cleanup movements & logs
|
|
||||||
await StockMovement.deleteMany({ stockItemId: id });
|
|
||||||
await StockAuditLog.deleteMany({ stockItemId: id });
|
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -288,16 +166,7 @@ export const getStockItems = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const { dataSheetId } = req.query;
|
const { dataSheetId } = req.query;
|
||||||
|
const items = await stockItemService.getStockItems(organizationId!, dataSheetId as string);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const query: any = { organizationId };
|
|
||||||
if (dataSheetId) query.dataSheetId = dataSheetId;
|
|
||||||
|
|
||||||
// Sort by Expiration Date ASC (First to expire first)
|
|
||||||
const items = await StockItem.find(query)
|
|
||||||
.populate('dataSheetId', 'name manufacturer type')
|
|
||||||
.sort({ expirationDate: 1 });
|
|
||||||
|
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -309,12 +178,8 @@ export const getStockItemById = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const item = await stockItemService.getStockItemById(id, organizationId!);
|
||||||
const item = await StockItem.findOne({ _id: id, organizationId })
|
|
||||||
.populate('dataSheetId', 'name manufacturer type');
|
|
||||||
|
|
||||||
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
|
||||||
|
|
||||||
res.json(item);
|
res.json(item);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -324,12 +189,9 @@ export const getStockItemById = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
export const getStockMovements = async (req: AuthRequest, res: Response) => {
|
export const getStockMovements = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params; // StockItem ID
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const movements = await stockItemService.getStockMovements(id, organizationId!);
|
||||||
const movements = await StockMovement.find({ stockItemId: id, organizationId })
|
|
||||||
.sort({ date: -1 });
|
|
||||||
|
|
||||||
res.json(movements);
|
res.json(movements);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -337,91 +199,41 @@ export const getStockMovements = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// CRUD & Auditing for Movements
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
import StockAuditLog from '../models/StockAuditLog.js';
|
|
||||||
|
|
||||||
export const updateStockMovement = async (req: AuthRequest, res: Response) => {
|
export const updateStockMovement = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params; // Movement ID
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const userId = req.appUser?._id || 'system';
|
const userId = req.appUser?.externalId || 'system';
|
||||||
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
const oldMovement = await stockItemService.getStockMovementById(id, organizationId!);
|
||||||
return res.status(403).json({ error: 'Apenas administradores podem editar movimentações.' });
|
if (!oldMovement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
||||||
}
|
|
||||||
|
|
||||||
const { date, quantity, notes } = req.body;
|
const item = await stockItemService.getStockItemById(oldMovement.stock_item_id, organizationId!);
|
||||||
|
const quantityDiff = Number(req.body.quantity) - Number(oldMovement.quantity);
|
||||||
|
|
||||||
const movement = await StockMovement.findOne({ _id: id, organizationId });
|
|
||||||
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
|
|
||||||
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
|
|
||||||
|
|
||||||
// Calculate Delta
|
|
||||||
// If quantity changed, we need to adjust the item balance
|
|
||||||
// Note: 'quantity' in movement is signed (+ for entry, - for consumption)
|
|
||||||
// If the user edits a Consumption (-10) to (-15), the val passed in body might be absolute or signed?
|
|
||||||
// Let's assume the frontend sends the SIGNED value consistent with the movement type?
|
|
||||||
// Actually best to stick to specific logic:
|
|
||||||
// If movement type is ENTRY/ADJUSTMENT, quantity is usually positive (unless neg adjustment).
|
|
||||||
// If CONSUMPTION, quantity is stored negative.
|
|
||||||
// Let's expect the frontend to send the 'raw' new value.
|
|
||||||
// Be careful: if frontend sends positive 10 for a consumption, we must flip it?
|
|
||||||
// Let's assume frontend sends the value exactly as it should be stored.
|
|
||||||
|
|
||||||
// HOWEVER, it's safer if we check type.
|
|
||||||
const newQuantitySigned = Number(quantity);
|
|
||||||
|
|
||||||
// Validation: Consumption should generally be negative, Entry positive.
|
|
||||||
// But for flexibility let's just trust the arithmetic diff for now,
|
|
||||||
// but warn if sign flips unexpectedly?
|
|
||||||
|
|
||||||
const oldQuantity = Number(movement.quantity);
|
|
||||||
const quantityDiff = newQuantitySigned - oldQuantity;
|
|
||||||
|
|
||||||
// Update Item
|
|
||||||
const newStockLevel = Number(item.quantity) + quantityDiff;
|
const newStockLevel = Number(item.quantity) + quantityDiff;
|
||||||
if (newStockLevel < 0) {
|
if (newStockLevel < 0) return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
|
||||||
return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
item.quantity = newStockLevel;
|
await stockItemService.updateStockItemQuantity(item.id, newStockLevel);
|
||||||
await item.save();
|
|
||||||
|
|
||||||
// Audit Log
|
await stockItemService.createAuditLog({
|
||||||
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
|
|
||||||
const typeLabel = typeMap[movement.type] || movement.type;
|
|
||||||
|
|
||||||
await StockAuditLog.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
stockItemId: item._id,
|
stockItemId: item.id,
|
||||||
movementId: movement._id,
|
movementId: id,
|
||||||
movementNumber: movement.movementNumber,
|
movementNumber: oldMovement.movement_number,
|
||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`,
|
details: `Edição de Movimentação: Qtd ${oldMovement.quantity} -> ${req.body.quantity}`,
|
||||||
oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes },
|
oldValues: oldMovement,
|
||||||
newValues: { date, quantity: newQuantitySigned, notes }
|
newValues: req.body
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Movement
|
const updated = await stockItemService.updateStockMovement(id, organizationId!, req.body);
|
||||||
movement.quantity = newQuantitySigned;
|
res.json(updated);
|
||||||
if (date) movement.date = date;
|
|
||||||
if (notes !== undefined) movement.notes = notes;
|
|
||||||
await movement.save();
|
|
||||||
|
|
||||||
res.json(movement);
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error('Error updating movement:', error);
|
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -431,69 +243,43 @@ export const deleteStockMovement = async (req: AuthRequest, res: Response) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
|
||||||
const userId = req.appUser?._id || 'system';
|
const userId = req.appUser?.externalId || 'system';
|
||||||
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
const movement = await stockItemService.getStockMovementById(id, organizationId!);
|
||||||
return res.status(403).json({ error: 'Apenas administradores podem excluir movimentações.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const movement = await StockMovement.findOne({ _id: id, organizationId });
|
|
||||||
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
|
||||||
|
|
||||||
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
|
const item = await stockItemService.getStockItemById(movement.stock_item_id, organizationId!);
|
||||||
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
|
const newStockLevel = Number(item.quantity) - Number(movement.quantity);
|
||||||
|
|
||||||
// Reverse the effect
|
if (newStockLevel < 0) return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
|
||||||
// If we delete an Entry (+10), we MUST subtract 10 from Item.
|
|
||||||
// If we delete a Consumption (-10), we MUST add 10 (subtract -10) to Item.
|
|
||||||
// So: Item.quantity -= movement.quantity
|
|
||||||
|
|
||||||
const reverseQty = Number(movement.quantity);
|
await stockItemService.updateStockItemQuantity(item.id, newStockLevel);
|
||||||
const newStockLevel = Number(item.quantity) - reverseQty;
|
|
||||||
|
|
||||||
if (newStockLevel < 0) {
|
await stockItemService.createAuditLog({
|
||||||
return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
item.quantity = newStockLevel;
|
|
||||||
await item.save();
|
|
||||||
|
|
||||||
// Audit Log
|
|
||||||
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
|
|
||||||
const typeLabel = typeMap[movement.type] || movement.type;
|
|
||||||
|
|
||||||
await StockAuditLog.create({
|
|
||||||
organizationId,
|
organizationId,
|
||||||
stockItemId: item._id,
|
stockItemId: item.id,
|
||||||
movementId: movement._id,
|
movementId: id,
|
||||||
movementNumber: movement.movementNumber,
|
movementNumber: movement.movement_number,
|
||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
action: 'DELETE',
|
action: 'DELETE',
|
||||||
details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`,
|
details: `Exclusão de Movimentação: Qtd ${movement.quantity}`,
|
||||||
oldValues: movement.toObject()
|
oldValues: movement
|
||||||
});
|
});
|
||||||
|
|
||||||
await StockMovement.deleteOne({ _id: id });
|
await stockItemService.deleteStockMovement(id, organizationId!);
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error('Error deleting movement:', error);
|
|
||||||
res.status(500).json({ error: message });
|
res.status(500).json({ error: message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
|
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params; // StockItem ID
|
const { id } = req.params;
|
||||||
const organizationId = req.appUser?.organizationId;
|
const organizationId = req.appUser?.organizationId;
|
||||||
|
const logs = await stockItemService.getStockAuditLogs(id, organizationId!);
|
||||||
const logs = await StockAuditLog.find({ stockItemId: id, organizationId })
|
|
||||||
.sort({ timestamp: -1 });
|
|
||||||
|
|
||||||
res.json(logs);
|
res.json(logs);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
import { 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';
|
||||||
import { AuthRequest } from '../middleware/authMiddleware.js';
|
|
||||||
|
|
||||||
export const getSettings = async (req: AuthRequest, 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);
|
||||||
@@ -28,45 +24,38 @@ export const getSettings = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateSettings = async (req: AuthRequest, res: Response) => {
|
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: AuthRequest, 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) {
|
||||||
@@ -75,16 +64,12 @@ export const serveLogo = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadLogo = async (req: AuthRequest & { file?: any }, res: Response) => {
|
export const uploadLogo = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
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);
|
||||||
@@ -92,71 +77,39 @@ export const uploadLogo = async (req: AuthRequest & { file?: any }, res: Respons
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global Admin Functions
|
export const getGlobalUsers = async (req: Request, res: Response) => {
|
||||||
export const getGlobalUsers = async (req: AuthRequest, 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.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGlobalOrganizations = async (req: AuthRequest, 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"
|
||||||
id: '$userId',
|
FROM gpi.organizations o
|
||||||
name: '$name',
|
LEFT JOIN gpi.user_organizations uo ON o.id = uo.organization_id
|
||||||
email: '$email',
|
GROUP BY o.id, o.name, o.is_banned
|
||||||
role: '$role',
|
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: 'organizationId', // We should rename clerkId in Organization model too
|
|
||||||
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.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleOrganizationBan = async (req: AuthRequest, res: Response) => {
|
export const toggleOrganizationBan = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { organizationId, isBanned } = req.body;
|
const { organizationId, isBanned } = req.body;
|
||||||
|
|
||||||
@@ -164,15 +117,12 @@ export const toggleOrganizationBan = async (req: AuthRequest, 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 *',
|
||||||
{ organizationId: 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,9 +1,33 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import User, { IUser } from '../models/User.js';
|
import * as userService from '../services/userService.js';
|
||||||
import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js';
|
|
||||||
import { AuthRequest } from '../middleware/authMiddleware.js';
|
interface AuthRequest extends Request {
|
||||||
|
appUser?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncUser = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { externalId, email, name, organizationId, clerkRole } = req.body;
|
||||||
|
|
||||||
|
if (!externalId || !email || !name) {
|
||||||
|
return res.status(400).json({ error: 'externalId, email e name são obrigatórios.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userService.syncUser({
|
||||||
|
externalId,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
organizationId,
|
||||||
|
clerkRole
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing user:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao sincronizar usuário: ' + (error instanceof Error ? error.message : String(error)) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Get current user data with organization context
|
|
||||||
export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
@@ -11,31 +35,19 @@ export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
|
const user = await userService.getCurrentUser(req.appUser.clerk_id || req.appUser.logto_id || req.appUser.externalId, organizationId);
|
||||||
|
|
||||||
if (organizationId) {
|
if (!user) {
|
||||||
const member = await OrganizationMember.findOne({
|
return res.status(404).json({ error: 'Usuário não encontrado.' });
|
||||||
userId: req.appUser._id.toString(),
|
|
||||||
organizationId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (member) {
|
|
||||||
return res.json({
|
|
||||||
...req.appUser.toObject(),
|
|
||||||
role: member.role,
|
|
||||||
isBanned: member.isBanned,
|
|
||||||
organizationId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(req.appUser);
|
res.json(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting current user:', error);
|
console.error('Error getting current user:', error);
|
||||||
res.status(500).json({ error: 'Erro ao buscar usuário.' });
|
res.status(500).json({ error: 'Erro ao buscar usuário.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get all users for the current organization (admin only)
|
|
||||||
export const getAllUsers = async (req: Request, res: Response) => {
|
export const getAllUsers = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
@@ -44,7 +56,7 @@ export const getAllUsers = async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await OrganizationMember.find({ organizationId }).sort({ createdAt: -1 });
|
const members = await userService.getAllUsersInOrg(organizationId);
|
||||||
res.json(members);
|
res.json(members);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting users:', error);
|
console.error('Error getting users:', error);
|
||||||
@@ -52,7 +64,6 @@ export const getAllUsers = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update user role within organization (admin only)
|
|
||||||
export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -63,34 +74,18 @@ export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['guest', 'user', 'admin'].includes(role)) {
|
const member = await userService.updateUserRole(id, organizationId, role);
|
||||||
return res.status(400).json({ error: 'Role inválido. Use: guest, user ou admin.' });
|
if (!member) {
|
||||||
}
|
|
||||||
|
|
||||||
const member = await OrganizationMember.findById(id);
|
|
||||||
if (!member || member.organizationId !== organizationId) {
|
|
||||||
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
|
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent removing the last admin
|
|
||||||
if (member.role === 'admin' && role !== 'admin') {
|
|
||||||
const adminCount = await OrganizationMember.countDocuments({ organizationId, role: 'admin' });
|
|
||||||
if (adminCount <= 1) {
|
|
||||||
return res.status(400).json({ error: 'Não é possível remover o último administrador.' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
member.role = role as OrgRole;
|
|
||||||
await member.save();
|
|
||||||
|
|
||||||
res.json(member);
|
res.json(member);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating role:', error);
|
console.error('Error updating role:', error);
|
||||||
res.status(500).json({ error: 'Erro ao atualizar role.' });
|
res.status(500).json({ error: 'Erro ao alterar role.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ban or unban user within organization (admin only)
|
|
||||||
export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -101,24 +96,11 @@ export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await OrganizationMember.findById(id);
|
const member = await userService.toggleBanUser(id, organizationId, isBanned);
|
||||||
if (!member || member.organizationId !== organizationId) {
|
if (!member) {
|
||||||
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
|
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent banning yourself
|
|
||||||
if (req.appUser && member.userId === req.appUser._id.toString()) {
|
|
||||||
return res.status(400).json({ error: 'Você não pode banir a si mesmo.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent banning another admin
|
|
||||||
if (member.role === 'admin' && isBanned) {
|
|
||||||
return res.status(400).json({ error: 'Não é possível banir um administrador.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
member.isBanned = isBanned;
|
|
||||||
await member.save();
|
|
||||||
|
|
||||||
res.json(member);
|
res.json(member);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling ban:', error);
|
console.error('Error toggling ban:', error);
|
||||||
@@ -126,14 +108,12 @@ export const toggleBanUser = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update current user's lastSeenAt timestamp
|
|
||||||
export const heartbeat = async (req: AuthRequest, res: Response) => {
|
export const heartbeat = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.appUser) {
|
if (!req.appUser) {
|
||||||
return res.status(401).json({ error: 'Não autenticado.' });
|
return res.status(401).json({ error: 'Não autenticado.' });
|
||||||
}
|
}
|
||||||
|
await userService.heartbeat(req.appUser.id);
|
||||||
await User.findByIdAndUpdate(req.appUser._id, { lastSeenAt: new Date() });
|
|
||||||
res.status(200).send();
|
res.status(200).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Heartbeat error:', error);
|
console.error('Heartbeat error:', error);
|
||||||
@@ -141,26 +121,16 @@ export const heartbeat = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get active users in the same organization (seen in last 2 mins)
|
|
||||||
export const getActiveUsers = async (req: AuthRequest, res: Response) => {
|
export const getActiveUsers = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const organizationId = req.headers['x-organization-id'] as string;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
const currentUserId = req.appUser?._id;
|
const currentUserId = req.appUser?.id;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
return res.status(400).json([]);
|
return res.status(400).json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await OrganizationMember.find({ organizationId });
|
const activeUsers = await userService.getActiveUsers(organizationId, currentUserId);
|
||||||
const userIds = members.map(m => m.userId);
|
|
||||||
|
|
||||||
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
|
|
||||||
|
|
||||||
const activeUsers = await User.find({
|
|
||||||
_id: { $in: userIds, $ne: currentUserId },
|
|
||||||
lastSeenAt: { $gte: twoMinutesAgo }
|
|
||||||
}).select('name email lastSeenAt');
|
|
||||||
|
|
||||||
res.json(activeUsers);
|
res.json(activeUsers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting active users:', error);
|
console.error('Error getting active users:', error);
|
||||||
@@ -168,7 +138,6 @@ export const getActiveUsers = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete organization member
|
|
||||||
export const deleteUser = async (req: Request, res: Response) => {
|
export const deleteUser = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -178,20 +147,15 @@ export const deleteUser = async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Organização não selecionada.' });
|
return res.status(400).json({ error: 'Organização não selecionada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await OrganizationMember.findOneAndDelete({ _id: id, organizationId });
|
const success = await userService.deleteUserFromOrg(id, organizationId);
|
||||||
|
|
||||||
if (!result) {
|
if (!success) {
|
||||||
return res.status(404).json({ error: 'Membro não encontrado.' });
|
return res.status(404).json({ error: 'Membro não encontrado.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ message: 'Membro removido com sucesso.', deletedMember: result });
|
res.json({ message: 'Membro removido com sucesso.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting user:', error);
|
console.error('Error deleting user:', error);
|
||||||
res.status(500).json({ error: 'Erro ao remover membro.' });
|
res.status(500).json({ error: 'Erro ao remover membro.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Placeholder for sync (not needed with custom auth but keeping to avoid breaks if called)
|
|
||||||
export const syncUser = async (req: Request, res: Response) => {
|
|
||||||
res.json({ message: 'Sync no longer required with JWT auth.' });
|
|
||||||
};
|
|
||||||
|
|||||||
+6
-14
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod';
|
||||||
|
|
||||||
|
export const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
// Se não houver token autêntico JWT, prossegue limpo
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||||
|
|
||||||
|
// Injeta o externalId no header para que o extractUser (roleMiddleware)
|
||||||
|
// continue seu trabalho de carregar o usuário do banco instanciado e popular req.appUser
|
||||||
|
req.headers['x-auth-user-id'] = decoded.externalId;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth Middleware Error:', error);
|
||||||
|
res.status(401).json({ error: 'Token inválido ou expirado' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import User from '../models/User.js';
|
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
|
||||||
|
|
||||||
export interface AuthRequest extends Request {
|
|
||||||
user?: any;
|
|
||||||
appUser?: any; // For backward compatibility
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authenticateJWT = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
||||||
try {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
||||||
// Keep guest access if allowed by specific routes
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.split(' ')[1];
|
|
||||||
const decoded: any = jwt.verify(token, JWT_SECRET);
|
|
||||||
|
|
||||||
const user = await User.findById(decoded.id);
|
|
||||||
|
|
||||||
if (!user || user.isBanned) {
|
|
||||||
return res.status(401).json({ error: 'Sessão inválida ou usuário bloqueado' });
|
|
||||||
}
|
|
||||||
|
|
||||||
req.user = user;
|
|
||||||
req.appUser = user; // Map to appUser for existing controllers
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('JWT validation error:', error);
|
|
||||||
return res.status(401).json({ error: 'Token inválido' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,38 +1,107 @@
|
|||||||
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
|
export const extractUser = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
declare module 'express-serve-static-core' {
|
try {
|
||||||
interface Request {
|
const externalId = req.headers['x-auth-user-id'] as string;
|
||||||
appUser?: IAppUser;
|
const organizationId = req.headers['x-organization-id'] as string;
|
||||||
}
|
|
||||||
}
|
if (!externalId) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.is_banned) {
|
||||||
|
return res.status(403).json({ error: 'Conta bloqueada. Entre em contato com o administrador.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUser: IAppUser = {
|
||||||
|
id: user.id,
|
||||||
|
externalId: user.clerk_id || user.logto_id || externalId,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
isBanned: user.is_banned,
|
||||||
|
organizationId: organizationId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
// Verificar organização
|
||||||
|
const orgRes = await query(
|
||||||
|
'SELECT * FROM gpi.organizations WHERE id = $1 OR clerk_id = $1 OR logto_id = $1',
|
||||||
|
[organizationId]
|
||||||
|
);
|
||||||
|
const org = orgRes.rows[0];
|
||||||
|
|
||||||
|
if (org) {
|
||||||
|
if (org.is_banned) {
|
||||||
|
return res.status(403).json({ error: 'Acesso bloqueado: Esta organização está suspensa.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar papel na organização
|
||||||
|
const memberRes = await query(
|
||||||
|
'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2',
|
||||||
|
[user.id, org.id]
|
||||||
|
);
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.appUser = appUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error extracting user:', error);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require specific roles for a route
|
|
||||||
* @param allowedRoles Array of roles that can access the route
|
|
||||||
*/
|
|
||||||
export const requireRole = (allowedRoles: OrgRole[]) => {
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to global role if organizationRole is not set
|
|
||||||
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
|
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
|
||||||
|
|
||||||
if (!allowedRoles.includes(effectiveRole as OrgRole)) {
|
if (!allowedRoles.includes(effectiveRole as OrgRole)) {
|
||||||
@@ -43,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; // Clerk User ID
|
|
||||||
projectId: mongoose.Types.ObjectId;
|
|
||||||
type: 'painting' | 'surface_treatment';
|
|
||||||
|
|
||||||
// Common
|
|
||||||
date?: Date | null;
|
|
||||||
inspector?: string | null;
|
|
||||||
appearance?: 'approved' | 'rejected' | 'notes' | null; // Unified status
|
|
||||||
defects?: string | null; // Observations
|
|
||||||
photos?: string[]; // URLs
|
|
||||||
partTemperature?: number | null;
|
|
||||||
weightKg?: number | null;
|
|
||||||
|
|
||||||
// Painting Specific
|
|
||||||
pieceDescription?: string | null;
|
|
||||||
epsPoints?: (number | null)[];
|
|
||||||
adhesionTest?: string | null;
|
|
||||||
|
|
||||||
// Surface Treatment Specific
|
|
||||||
batch?: string | null; // Lote
|
|
||||||
treatmentExecutor?: string | null;
|
|
||||||
treatmentType?: string | null; // Jateamento, Mecânica...
|
|
||||||
cleaningDegree?: string | null; // Sa 2.5, St 3...
|
|
||||||
roughnessReadings?: (number | null)[]; // 5 measurements
|
|
||||||
flashRust?: string | null;
|
|
||||||
temperature?: number | null;
|
|
||||||
relativeHumidity?: number | null;
|
|
||||||
period?: 'morning' | 'afternoon' | 'night' | null;
|
|
||||||
applicationRecordId?: mongoose.Types.ObjectId; // Link to specific painting batch
|
|
||||||
stockItemId?: mongoose.Types.ObjectId; // Link to Stock Item (Paint used)
|
|
||||||
instrumentId?: mongoose.Types.ObjectId; // Link to Instrument used
|
|
||||||
}
|
|
||||||
|
|
||||||
const InspectionSchema: Schema = new Schema({
|
|
||||||
organizationId: { type: String, index: true },
|
|
||||||
createdBy: { type: String, index: true },
|
|
||||||
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
|
|
||||||
applicationRecordId: { type: Schema.Types.ObjectId, ref: 'ApplicationRecord' },
|
|
||||||
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem' },
|
|
||||||
instrumentId: { type: Schema.Types.ObjectId, ref: 'Instrument' },
|
|
||||||
type: { type: String, enum: ['painting', 'surface_treatment'], default: 'painting', index: true },
|
|
||||||
|
|
||||||
// Common
|
|
||||||
date: { type: Date },
|
|
||||||
inspector: { type: String },
|
|
||||||
appearance: { type: String }, // approved, rejected, notes
|
|
||||||
defects: { type: String },
|
|
||||||
photos: [{ type: String }],
|
|
||||||
partTemperature: { type: Number },
|
|
||||||
weightKg: { type: Number },
|
|
||||||
|
|
||||||
// Painting
|
|
||||||
pieceDescription: { type: String },
|
|
||||||
epsPoints: [{ type: Number }],
|
|
||||||
adhesionTest: { type: String },
|
|
||||||
|
|
||||||
// Surface Treatment
|
|
||||||
batch: { type: String },
|
|
||||||
treatmentExecutor: { type: String },
|
|
||||||
treatmentType: { type: String },
|
|
||||||
cleaningDegree: { type: String },
|
|
||||||
roughnessReadings: [{ type: Number }],
|
|
||||||
flashRust: { type: String },
|
|
||||||
temperature: { type: Number },
|
|
||||||
relativeHumidity: { type: Number },
|
|
||||||
period: { type: String },
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
export default mongoose.models.Inspection || mongoose.model<IInspection>('Inspection', InspectionSchema);
|
|
||||||
@@ -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; // ID do remetente
|
|
||||||
toUserId: string; // ID 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 {
|
|
||||||
organizationId: string;
|
|
||||||
name?: string;
|
|
||||||
isBanned: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OrganizationSchema: Schema = new Schema({
|
|
||||||
organizationId: { 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,60 +0,0 @@
|
|||||||
import mongoose, { Schema, Document } from 'mongoose';
|
|
||||||
|
|
||||||
export type UserRole = 'guest' | 'user' | 'admin';
|
|
||||||
|
|
||||||
export interface IUser extends Document {
|
|
||||||
clerkId?: string;
|
|
||||||
email: string;
|
|
||||||
password?: string;
|
|
||||||
name: string;
|
|
||||||
role: UserRole;
|
|
||||||
isBanned: boolean;
|
|
||||||
organizationId?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
lastSeenAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserSchema: Schema = new Schema({
|
|
||||||
clerkId: {
|
|
||||||
type: String,
|
|
||||||
unique: true,
|
|
||||||
sparse: true,
|
|
||||||
index: true
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
type: String,
|
|
||||||
select: false // Password shouldn't be returned by default
|
|
||||||
},
|
|
||||||
organizationId: {
|
|
||||||
type: String,
|
|
||||||
index: true
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
unique: true,
|
|
||||||
index: 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,11 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import express from 'express';
|
||||||
import * as authController from '../controllers/authController.js';
|
import { login, register, getMe } from '../controllers/authController.js';
|
||||||
import { authenticateJWT } from '../middleware/authMiddleware.js';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post('/login', authController.login);
|
router.post('/login', login);
|
||||||
router.post('/register', authController.register);
|
router.post('/register', register);
|
||||||
router.get('/me', authenticateJWT, authController.getMe);
|
router.get('/me', getMe);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { syncUser, getCurrentUser, getAllUsers, updateUserRole, toggleBanUser, heartbeat, getActiveUsers, deleteUser } from '../controllers/userController.js';
|
import { syncUser, getCurrentUser, getAllUsers, updateUserRole, toggleBanUser, heartbeat, getActiveUsers, deleteUser } from '../controllers/userController.js';
|
||||||
import { requireAdmin, requireUser } from '../middleware/roleMiddleware.js';
|
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Sync user (placeholder)
|
// Sync user from Auth (public - called on login)
|
||||||
router.post('/sync', syncUser);
|
router.post('/sync', syncUser);
|
||||||
|
|
||||||
// Get current user
|
// Get current user (requires extractUser middleware)
|
||||||
router.get('/me', requireUser, getCurrentUser);
|
router.get('/me', extractUser, getCurrentUser);
|
||||||
|
|
||||||
// Heartbeat & Presence
|
// Heartbeat & Presence
|
||||||
router.post('/heartbeat', requireUser, heartbeat);
|
router.post('/heartbeat', extractUser, heartbeat);
|
||||||
router.get('/active', requireUser, getActiveUsers);
|
router.get('/active', extractUser, getActiveUsers);
|
||||||
|
|
||||||
// Admin-only routes
|
// Admin-only routes
|
||||||
router.get('/', requireUser, requireAdmin, getAllUsers);
|
router.get('/', extractUser, requireAdmin, getAllUsers);
|
||||||
router.patch('/:id/role', requireUser, requireAdmin, updateUserRole);
|
router.patch('/:id/role', extractUser, requireAdmin, updateUserRole);
|
||||||
router.patch('/:id/ban', requireUser, requireAdmin, toggleBanUser);
|
router.patch('/:id/ban', extractUser, requireAdmin, toggleBanUser);
|
||||||
router.delete('/:id', requireUser, requireAdmin, deleteUser);
|
router.delete('/:id', extractUser, requireAdmin, deleteUser);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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,53 +0,0 @@
|
|||||||
import mongoose from 'mongoose';
|
|
||||||
import bcrypt from 'bcryptjs';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
dotenv.config({ path: path.join(process.cwd(), '.env') });
|
|
||||||
|
|
||||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0';
|
|
||||||
|
|
||||||
const UserSchema = new mongoose.Schema({
|
|
||||||
email: { type: String, required: true, unique: true },
|
|
||||||
password: { type: String, required: true },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
role: { type: String, enum: ['guest', 'user', 'admin'], default: 'guest' },
|
|
||||||
isBanned: { type: Boolean, default: false }
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
async function migrateAdmin() {
|
|
||||||
try {
|
|
||||||
await mongoose.connect(MONGODB_URI);
|
|
||||||
console.log('✅ Conectado ao MongoDB');
|
|
||||||
|
|
||||||
const User = mongoose.models.User || mongoose.model('User', UserSchema);
|
|
||||||
|
|
||||||
const adminEmail = 'admtracksteel@gmail.com';
|
|
||||||
const defaultPassword = 'admin_gpi_2026'; // SENHA TEMPORÁRIA
|
|
||||||
const hashedPassword = await bcrypt.hash(defaultPassword, 12);
|
|
||||||
|
|
||||||
const admin = await User.findOneAndUpdate(
|
|
||||||
{ email: adminEmail },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
password: hashedPassword,
|
|
||||||
role: 'admin',
|
|
||||||
name: 'Administrador Global',
|
|
||||||
isBanned: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ upsert: true, new: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ Administrador ${adminEmail} migrado com sucesso!`);
|
|
||||||
console.log(`🔑 Senha temporária definida: ${defaultPassword}`);
|
|
||||||
console.log(`⚠️ Por favor, altere sua senha após o primeiro login.`);
|
|
||||||
|
|
||||||
await mongoose.disconnect();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erro na migração:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
migrateAdmin();
|
|
||||||
@@ -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,48 +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({
|
|
||||||
clerkId: String,
|
|
||||||
email: String,
|
|
||||||
name: String,
|
|
||||||
role: { type: String, enum: ['guest', 'user', 'admin'], default: 'guest' },
|
|
||||||
isBanned: { type: Boolean, default: false }
|
|
||||||
}, { timestamps: true });
|
|
||||||
|
|
||||||
async function fixAdmin() {
|
|
||||||
await mongoose.connect(MONGODB_URI);
|
|
||||||
console.log('✅ Conectado ao MongoDB');
|
|
||||||
|
|
||||||
const User = mongoose.model('User', UserSchema);
|
|
||||||
|
|
||||||
// Resetar m.reifonas para guest
|
|
||||||
await User.updateOne(
|
|
||||||
{ email: 'm.reifonas@gmail.com' },
|
|
||||||
{ $set: { role: 'guest' } }
|
|
||||||
);
|
|
||||||
console.log('✅ m.reifonas@gmail.com resetado para guest');
|
|
||||||
|
|
||||||
// Atualizar admtracksteel para admin (o com clerkId real)
|
|
||||||
const result = await User.updateOne(
|
|
||||||
{ email: 'admtracksteel@gmail.com', clerkId: { $ne: 'pending' } },
|
|
||||||
{ $set: { role: 'admin' } }
|
|
||||||
);
|
|
||||||
console.log('✅ admtracksteel@gmail.com atualizado para admin', result.modifiedCount > 0 ? '(sucesso)' : '(não encontrado)');
|
|
||||||
|
|
||||||
// Listar todos os usuários
|
|
||||||
const users = await User.find({});
|
|
||||||
console.log('\n📋 Usuários atualizados:');
|
|
||||||
users.forEach((u, i) => {
|
|
||||||
console.log(` ${i + 1}. ${u.email} | role: ${u.role}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await mongoose.disconnect();
|
|
||||||
console.log('\n✅ Desconectado do MongoDB');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
fixAdmin().catch(err => {
|
|
||||||
console.error('❌ Erro:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,72 +1,162 @@
|
|||||||
import ApplicationRecord from '../models/ApplicationRecord.js';
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
interface ApplicationRecordItem {
|
||||||
export const createApplicationRecord = async (data: any & { organizationId?: string, createdBy?: string }) => {
|
partId: string;
|
||||||
const newRecord = new ApplicationRecord({
|
quantity: number;
|
||||||
...data,
|
}
|
||||||
date: data.date ? new Date(data.date) : null,
|
|
||||||
organizationId: data.organizationId,
|
interface ApplicationRecordData {
|
||||||
createdBy: data.createdBy
|
organizationId?: string;
|
||||||
});
|
createdBy?: string;
|
||||||
const saved = await newRecord.save();
|
projectId: string;
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
coatStage: string;
|
||||||
|
pieceDescription?: string;
|
||||||
|
date?: Date;
|
||||||
|
operator?: string;
|
||||||
|
realWeight?: number;
|
||||||
|
volumeUsed?: number;
|
||||||
|
areaPainted?: number;
|
||||||
|
wetThicknessAvg?: number;
|
||||||
|
dryThicknessCalc?: number;
|
||||||
|
method?: string;
|
||||||
|
diluentUsed?: number;
|
||||||
|
notes?: string;
|
||||||
|
items?: ApplicationRecordItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createApplicationRecord = async (data: ApplicationRecordData) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.application_records (
|
||||||
|
organization_id, created_by, project_id, coat_stage, piece_description,
|
||||||
|
date, operator, real_weight, volume_used, area_painted,
|
||||||
|
wet_thickness_avg, dry_thickness_calc, method, diluent_used, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.createdBy, data.projectId, data.coatStage, data.pieceDescription,
|
||||||
|
data.date, data.operator, data.realWeight, data.volumeUsed, data.areaPainted,
|
||||||
|
data.wetThicknessAvg, data.dryThicknessCalc, data.method, data.diluentUsed, data.notes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const record = result.rows[0];
|
||||||
|
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
for (const item of data.items) {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO gpi.application_record_items (application_record_id, part_id, quantity) VALUES ($1, $2, $3)',
|
||||||
|
[record.id, item.partId, item.quantity]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getApplicationRecordsByProject = async (projectId: string, organizationId?: string) => {
|
export const getApplicationRecordsByProject = async (projectId: string, organizationId?: string) => {
|
||||||
const query = { projectId, ...(organizationId ? { organizationId } : {}) };
|
let sql = 'SELECT * FROM gpi.application_records WHERE project_id = $1';
|
||||||
const records = await ApplicationRecord.find(query).sort({ date: -1 }).lean();
|
const params: any[] = [projectId];
|
||||||
return records.map(r => ({ ...r, id: r._id.toString() }));
|
|
||||||
|
if (organizationId) {
|
||||||
|
sql += ' AND organization_id = $2';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY date DESC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
// Fetch items for each record
|
||||||
|
const records = [];
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const itemsResult = await query(
|
||||||
|
'SELECT part_id as "partId", quantity FROM gpi.application_record_items WHERE application_record_id = $1',
|
||||||
|
[row.id]
|
||||||
|
);
|
||||||
|
records.push({ ...row, items: itemsResult.rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const updateApplicationRecord = async (
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
id: string,
|
||||||
export const updateApplicationRecord = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
data: Partial<ApplicationRecordData>,
|
||||||
const existing = await ApplicationRecord.findById(id);
|
organizationId?: string,
|
||||||
if (!existing) return null;
|
userId?: string,
|
||||||
|
userRole?: string,
|
||||||
|
isDeveloper: boolean = false
|
||||||
|
) => {
|
||||||
|
const checkResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]);
|
||||||
|
if (checkResult.rows.length === 0) return null;
|
||||||
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
// Organization Check
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role/Ownership check
|
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
|
||||||
console.warn(`Permission Denied: User ${userId} tried to update record ${id} created by ${existing.createdBy}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
...data,
|
|
||||||
date: data.date ? new Date(data.date) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
|
||||||
updateData.organizationId = organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await ApplicationRecord.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
|
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
|
if (!isPowerUser && existing.created_by && existing.created_by !== userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
const updatableFields = [
|
||||||
|
'coatStage', 'pieceDescription', 'date', 'operator', 'realWeight',
|
||||||
|
'volumeUsed', 'areaPainted', 'wetThicknessAvg', 'dryThicknessCalc',
|
||||||
|
'method', 'diluentUsed', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of updatableFields) {
|
||||||
|
if ((data as any)[key] !== undefined) {
|
||||||
|
// Convert camelCase to snake_case
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${paramIdx++}`);
|
||||||
|
params.push((data as any)[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length > 0) {
|
||||||
|
await query(
|
||||||
|
`UPDATE gpi.application_records SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle items update (replace all)
|
||||||
|
if (data.items) {
|
||||||
|
await query('DELETE FROM gpi.application_record_items WHERE application_record_id = $1', [id]);
|
||||||
|
for (const item of data.items) {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO gpi.application_record_items (application_record_id, part_id, quantity) VALUES ($1, $2, $3)',
|
||||||
|
[id, item.partId, item.quantity]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]);
|
||||||
|
const itemsFinal = await query('SELECT part_id as "partId", quantity FROM gpi.application_record_items WHERE application_record_id = $1', [id]);
|
||||||
|
|
||||||
|
return { ...finalResult.rows[0], items: itemsFinal.rows };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteApplicationRecord = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
export const deleteApplicationRecord = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
||||||
const existing = await ApplicationRecord.findById(id);
|
const checkResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]);
|
||||||
if (!existing) return false;
|
if (checkResult.rows.length === 0) return false;
|
||||||
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
// Organization Check
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Role/Ownership check
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
if (!isPowerUser && existing.created_by && existing.created_by !== userId) {
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await ApplicationRecord.deleteOne({ _id: id });
|
await query('DELETE FROM gpi.application_records WHERE id = $1', [id]);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,174 +1,133 @@
|
|||||||
import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
|
import { query } from '../config/database.js';
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { bucket } from '../config/database.js';
|
|
||||||
import { ObjectId } from 'mongodb';
|
|
||||||
|
|
||||||
export const saveFileToGridFS = (localPath: string, filename: string): Promise<string> => {
|
interface DataSheetData {
|
||||||
return new Promise((resolve, reject) => {
|
organizationId?: string;
|
||||||
const uploadStream = bucket.openUploadStream(filename);
|
name: string;
|
||||||
const readStream = fs.createReadStream(localPath);
|
manufacturer?: string;
|
||||||
|
manufacturerCode?: string;
|
||||||
readStream.pipe(uploadStream)
|
type?: string;
|
||||||
.on('error', reject)
|
minStock?: number;
|
||||||
.on('finish', () => {
|
typicalApplication?: string;
|
||||||
// Remove local file after upload
|
fileId?: string;
|
||||||
fs.unlink(localPath, (err) => {
|
fileUrl?: string;
|
||||||
if (err) console.error('Failed to delete local temp file:', err);
|
solidsVolume?: number;
|
||||||
});
|
density?: number;
|
||||||
resolve(uploadStream.id.toString());
|
mixingRatio?: string;
|
||||||
});
|
mixingRatioWeight?: string;
|
||||||
});
|
mixingRatioVolume?: string;
|
||||||
};
|
wftMin?: number;
|
||||||
|
wftMax?: number;
|
||||||
export const deleteFileFromGridFS = async (fileId: string) => {
|
dftMin?: number;
|
||||||
try {
|
dftMax?: number;
|
||||||
await bucket.delete(new ObjectId(fileId));
|
reducer?: string;
|
||||||
return true;
|
yieldTheoretical?: number;
|
||||||
} catch (err) {
|
dftReference?: number;
|
||||||
console.error('Failed to delete file from GridFS:', err);
|
yieldFactor?: number;
|
||||||
return false;
|
dilution?: number;
|
||||||
}
|
notes?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const getFileStream = (fileId: string) => {
|
|
||||||
if (!ObjectId.isValid(fileId)) {
|
|
||||||
throw new Error('Invalid file ID format');
|
|
||||||
}
|
|
||||||
return bucket.openDownloadStream(new ObjectId(fileId));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllDataSheets = async (organizationId?: string) => {
|
export const getAllDataSheets = async (organizationId?: string) => {
|
||||||
const query = organizationId
|
let sql = 'SELECT * FROM gpi.technical_data_sheets';
|
||||||
? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }
|
const params: any[] = [];
|
||||||
: {};
|
|
||||||
const sheets = await TechnicalDataSheet.find(query).sort({ uploadDate: -1 }).lean();
|
if (organizationId) {
|
||||||
return sheets.map(s => ({ ...s, id: s._id.toString() }));
|
sql += ' WHERE (organization_id = $1 OR organization_id IS NULL)';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const matchSheets = async (query: string, organizationId?: string) => {
|
export const matchSheets = async (searchTerm: string, organizationId?: string) => {
|
||||||
const orgFilter = organizationId
|
let sql = `
|
||||||
? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }
|
SELECT * FROM gpi.technical_data_sheets
|
||||||
: {};
|
WHERE (name ILIKE $1 OR manufacturer ILIKE $1 OR type ILIKE $1)`;
|
||||||
|
const params: any[] = [`%${searchTerm}%`];
|
||||||
|
|
||||||
const filter = {
|
if (organizationId) {
|
||||||
...orgFilter,
|
sql += ' AND (organization_id = $2 OR organization_id IS NULL)';
|
||||||
$or: [
|
params.push(organizationId);
|
||||||
{ name: { $regex: query, $options: 'i' } },
|
}
|
||||||
{ manufacturer: { $regex: query, $options: 'i' } },
|
|
||||||
{ type: { $regex: query, $options: 'i' } }
|
const result = await query(sql, params);
|
||||||
]
|
return result.rows;
|
||||||
};
|
|
||||||
const sheets = await TechnicalDataSheet.find(filter).lean();
|
|
||||||
return sheets.map(s => ({ ...s, id: s._id.toString() }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const createDataSheet = async (data: DataSheetData) => {
|
||||||
export const createDataSheet = async (data: any & { organizationId?: string }) => {
|
const result = await query(
|
||||||
let fileId = data.fileUrl;
|
`INSERT INTO gpi.technical_data_sheets (
|
||||||
|
organization_id, name, manufacturer, manufacturer_code, type,
|
||||||
|
min_stock, typical_application, file_id, file_url, solids_volume,
|
||||||
|
density, mixing_ratio, mixing_ratio_weight, mixing_ratio_volume,
|
||||||
|
wft_min, wft_max, dft_min, dft_max, reducer, yield_theoretical,
|
||||||
|
dft_reference, yield_factor, dilution, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.name, data.manufacturer, data.manufacturerCode, data.type,
|
||||||
|
data.minStock, data.typicalApplication, data.fileId, data.fileUrl, data.solidsVolume,
|
||||||
|
data.density, data.mixingRatio, data.mixingRatioWeight, data.mixingRatioVolume,
|
||||||
|
data.wftMin, data.wftMax, data.dftMin, data.dftMax, data.reducer, data.yieldTheoretical,
|
||||||
|
data.dftReference, data.yieldFactor, data.dilution, data.notes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
// If fileUrl is a local path (exists on disk), move to GridFS
|
export const updateDataSheet = async (id: string, data: Partial<DataSheetData>, organizationId?: string) => {
|
||||||
if (data.fileUrl && fs.existsSync(data.fileUrl)) {
|
const checkResult = await query('SELECT * FROM gpi.technical_data_sheets WHERE id = $1', [id]);
|
||||||
fileId = await saveFileToGridFS(data.fileUrl, data.name + '.pdf');
|
if (checkResult.rows.length === 0) return null;
|
||||||
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
const updatableFields = [
|
||||||
|
'name', 'manufacturer', 'manufacturerCode', 'type', 'minStock',
|
||||||
|
'typicalApplication', 'fileId', 'fileUrl', 'solidsVolume', 'density',
|
||||||
|
'mixingRatio', 'mixingRatioWeight', 'mixingRatioVolume', 'wftMin',
|
||||||
|
'wftMax', 'dftMin', 'dftMax', 'reducer', 'yieldTheoretical',
|
||||||
|
'dftReference', 'yieldFactor', 'dilution', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of updatableFields) {
|
||||||
|
if ((data as any)[key] !== undefined) {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${paramIdx++}`);
|
||||||
|
params.push((data as any)[key]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newSheet = new TechnicalDataSheet({
|
if (fields.length === 0) return existing;
|
||||||
...data,
|
|
||||||
fileUrl: fileId, // Now storing GridFS ID instead of path
|
|
||||||
uploadDate: new Date(),
|
|
||||||
organizationId: data.organizationId
|
|
||||||
});
|
|
||||||
|
|
||||||
const saved = await newSheet.save();
|
const updateSql = `
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
UPDATE gpi.technical_data_sheets
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`;
|
||||||
|
|
||||||
|
const result = await query(updateSql, params);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteDataSheet = async (id: string, organizationId?: string) => {
|
export const deleteDataSheet = async (id: string, organizationId?: string) => {
|
||||||
// Find first to check permissions
|
const checkResult = await query('SELECT * FROM gpi.technical_data_sheets WHERE id = $1', [id]);
|
||||||
const sheet = await TechnicalDataSheet.findById(id);
|
if (checkResult.rows.length === 0) return false;
|
||||||
if (!sheet) return false;
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
// Permission Check:
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
// If current user is in an Org, and Sheet is in a DIFFERENT Org, deny.
|
return false;
|
||||||
// Explicitly allow if Sheet has NO Org (Legacy/Global).
|
}
|
||||||
if (organizationId && sheet.organizationId && sheet.organizationId !== organizationId) {
|
|
||||||
console.warn(`[Delete DataSheet] Access Denied. User Org: ${organizationId}, Sheet Org: ${sheet.organizationId}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete from GridFS if not a full URL
|
await query('DELETE FROM gpi.technical_data_sheets WHERE id = $1', [id]);
|
||||||
if (sheet.fileUrl && !sheet.fileUrl.startsWith('http')) {
|
return true;
|
||||||
await deleteFileFromGridFS(sheet.fileUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
await TechnicalDataSheet.findByIdAndDelete(id);
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const updateDataSheet = async (id: string, updates: any, organizationId?: string) => {
|
|
||||||
// SECURITY FIX: Allow update if:
|
|
||||||
// 1. Matches ID AND Matches Organization
|
|
||||||
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
|
|
||||||
|
|
||||||
const oldSheet = await TechnicalDataSheet.findById(id);
|
|
||||||
if (!oldSheet) return null;
|
|
||||||
|
|
||||||
if (organizationId && oldSheet.organizationId && oldSheet.organizationId !== organizationId) {
|
|
||||||
console.warn(`Access Denied: Sheet ${id} belongs to ${oldSheet.organizationId}, user is ${organizationId}`);
|
|
||||||
return null; // Return null effectively hides it or acts as fail
|
|
||||||
}
|
|
||||||
|
|
||||||
// If new file is uploaded (path exists locally)
|
|
||||||
if (updates.fileUrl && updates.fileUrl !== oldSheet.fileUrl && fs.existsSync(updates.fileUrl)) {
|
|
||||||
// Upload new file
|
|
||||||
const newFileId = await saveFileToGridFS(updates.fileUrl, (updates.name || oldSheet.name) + '.pdf');
|
|
||||||
|
|
||||||
// Delete old file from GridFS
|
|
||||||
if (oldSheet.fileUrl && !oldSheet.fileUrl.startsWith('http')) {
|
|
||||||
await deleteFileFromGridFS(oldSheet.fileUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
updates.fileUrl = newFileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organizationId && !oldSheet.organizationId) {
|
|
||||||
updates.organizationId = organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await TechnicalDataSheet.findOneAndUpdate({ _id: id }, updates, { new: true }).lean();
|
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const migrateFilesToGridFS = async () => {
|
|
||||||
try {
|
|
||||||
const sheets = await TechnicalDataSheet.find({ fileUrl: { $regex: /^uploads\// } });
|
|
||||||
console.log(`[MIGRATION] Found ${sheets.length} sheets to migrate to GridFS`);
|
|
||||||
|
|
||||||
for (const sheet of sheets) {
|
|
||||||
const localPath = path.join(process.cwd(), sheet.fileUrl);
|
|
||||||
if (fs.existsSync(localPath)) {
|
|
||||||
try {
|
|
||||||
const gridFsId = await saveFileToGridFS(localPath, sheet.name + '.pdf');
|
|
||||||
sheet.fileUrl = gridFsId;
|
|
||||||
await sheet.save();
|
|
||||||
console.log(`[MIGRATION] Successfully migrated: ${sheet.name}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[MIGRATION] Error migrating ${sheet.name}:`, err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`[MIGRATION] File not found for ${sheet.name}: ${localPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MIGRATION] Migration failed:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
|
export const saveFile = async (filename: string, contentType: string, data: Buffer) => {
|
||||||
|
const result = await query(
|
||||||
|
'INSERT INTO gpi.files (filename, content_type, data, size) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||||
|
[filename, contentType, data, data.length]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileById = async (id: string) => {
|
||||||
|
const result = await query('SELECT * FROM gpi.files WHERE id = $1', [id]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFile = async (id: string) => {
|
||||||
|
await query('DELETE FROM gpi.files WHERE id = $1', [id]);
|
||||||
|
};
|
||||||
@@ -1,81 +1,155 @@
|
|||||||
import Inspection from '../models/Inspection.js';
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
interface InspectionData {
|
||||||
export const createInspection = async (data: any & { organizationId?: string, createdBy?: string }) => {
|
projectId: string;
|
||||||
const newInspection = new Inspection({
|
partId: string;
|
||||||
...data,
|
type: string;
|
||||||
date: data.date ? new Date(data.date) : null,
|
status: string;
|
||||||
organizationId: data.organizationId,
|
notes?: string;
|
||||||
createdBy: data.createdBy
|
inspectorId?: string;
|
||||||
});
|
organizationId?: string;
|
||||||
const saved = await newInspection.save();
|
createdBy?: string;
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
}
|
||||||
|
|
||||||
|
export const createInspection = async (data: InspectionData) => {
|
||||||
|
const {
|
||||||
|
projectId,
|
||||||
|
partId,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
notes,
|
||||||
|
inspectorId,
|
||||||
|
organizationId
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.inspections (
|
||||||
|
project_id,
|
||||||
|
part_id,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
notes,
|
||||||
|
inspector_id,
|
||||||
|
organization_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[projectId, partId, type, status, notes, inspectorId, organizationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInspectionsByProject = async (projectId: string, organizationId?: string) => {
|
export const getInspectionsByProject = async (projectId: string, organizationId?: string) => {
|
||||||
const query = { projectId, ...(organizationId ? { organizationId } : {}) };
|
let sql = 'SELECT * FROM gpi.inspections WHERE project_id = $1';
|
||||||
const inspections = await Inspection.find(query).sort({ date: -1 }).lean();
|
const params: any[] = [projectId];
|
||||||
return inspections.map(i => ({ ...i, id: i._id.toString() }));
|
|
||||||
|
if (organizationId) {
|
||||||
|
sql += ' AND organization_id = $2';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const updateInspection = async (
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
id: string,
|
||||||
export const updateInspection = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
data: Partial<InspectionData>,
|
||||||
const existing = await Inspection.findById(id);
|
organizationId?: string,
|
||||||
if (!existing) return null;
|
userId?: string,
|
||||||
|
userRole?: string,
|
||||||
|
isDeveloper: boolean = false
|
||||||
|
) => {
|
||||||
|
// First check if exists and permissions
|
||||||
|
const checkSql = 'SELECT * FROM gpi.inspections WHERE id = $1';
|
||||||
|
const checkResult = await query(checkSql, [id]);
|
||||||
|
|
||||||
// Organization Check
|
if (checkResult.rows.length === 0) return null;
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
const existing = checkResult.rows[0];
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role/Ownership check
|
// Organization Check
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
|
||||||
console.warn(`Permission Denied: User ${userId} tried to update inspection ${id} created by ${existing.createdBy}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
...data,
|
|
||||||
date: data.date ? new Date(data.date) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
|
||||||
updateData.organizationId = organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await Inspection.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
|
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role/Ownership check (using inspector_id as owner for now as created_by isn't in original schema but in model)
|
||||||
|
// Wait, let's check the schema again. There is no created_by in inspections table in schema.sql.
|
||||||
|
// There is inspector_id.
|
||||||
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
|
if (!isPowerUser && existing.inspector_id && existing.inspector_id !== userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
if (data.type) {
|
||||||
|
fields.push(`type = $${paramIdx++}`);
|
||||||
|
params.push(data.type);
|
||||||
|
}
|
||||||
|
if (data.status) {
|
||||||
|
fields.push(`status = $${paramIdx++}`);
|
||||||
|
params.push(data.status);
|
||||||
|
}
|
||||||
|
if (data.notes !== undefined) {
|
||||||
|
fields.push(`notes = $${paramIdx++}`);
|
||||||
|
params.push(data.notes);
|
||||||
|
}
|
||||||
|
if (data.inspectorId) {
|
||||||
|
fields.push(`inspector_id = $${paramIdx++}`);
|
||||||
|
params.push(data.inspectorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return existing;
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE gpi.inspections
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`;
|
||||||
|
|
||||||
|
const result = await query(updateSql, params);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteInspection = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
|
export const deleteInspection = async (
|
||||||
const existing = await Inspection.findById(id);
|
id: string,
|
||||||
if (!existing) return false;
|
organizationId?: string,
|
||||||
|
userId?: string,
|
||||||
|
userRole?: string,
|
||||||
|
isDeveloper: boolean = false
|
||||||
|
) => {
|
||||||
|
const checkSql = 'SELECT * FROM gpi.inspections WHERE id = $1';
|
||||||
|
const checkResult = await query(checkSql, [id]);
|
||||||
|
|
||||||
// Organization Check
|
if (checkResult.rows.length === 0) return false;
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
const existing = checkResult.rows[0];
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role/Ownership check
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
const isPowerUser = userRole === 'admin' || isDeveloper;
|
return false;
|
||||||
if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Inspection.deleteOne({ _id: id });
|
const isPowerUser = userRole === 'admin' || isDeveloper;
|
||||||
return true;
|
if (!isPowerUser && existing.inspector_id && existing.inspector_id !== userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await query('DELETE FROM gpi.inspections WHERE id = $1', [id]);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllInspections = async (organizationId?: string) => {
|
export const getAllInspections = async (organizationId?: string) => {
|
||||||
const query = organizationId ? { organizationId } : {};
|
let sql = 'SELECT * FROM gpi.inspections';
|
||||||
const inspections = await Inspection.find(query).lean();
|
const params: any[] = [];
|
||||||
return inspections.map(i => ({ ...i, id: i._id.toString() }));
|
|
||||||
|
if (organizationId) {
|
||||||
|
sql += ' WHERE organization_id = $1';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
|
interface InstrumentData {
|
||||||
|
organizationId: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
modelName?: string;
|
||||||
|
serialNumber: string;
|
||||||
|
calibrationDate?: Date;
|
||||||
|
calibrationExpirationDate?: Date;
|
||||||
|
certificateUrl?: string;
|
||||||
|
status?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInstrument = async (data: InstrumentData) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.instruments (
|
||||||
|
organization_id, name, type, manufacturer, model_name,
|
||||||
|
serial_number, calibration_date, calibration_expiration_date,
|
||||||
|
certificate_url, status, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.name, data.type, data.manufacturer, data.modelName,
|
||||||
|
data.serialNumber, data.calibrationDate, data.calibrationExpirationDate,
|
||||||
|
data.certificateUrl, data.status || 'active', data.notes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInstruments = async (organizationId: string, status?: string) => {
|
||||||
|
let sql = 'SELECT * FROM gpi.instruments WHERE organization_id = $1';
|
||||||
|
const params: any[] = [organizationId];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
sql += ' AND status = $2';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY name ASC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateInstrument = async (id: string, organizationId: string, data: Partial<InstrumentData>) => {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id, organizationId];
|
||||||
|
let paramIdx = 3;
|
||||||
|
|
||||||
|
const updatableFields = [
|
||||||
|
'name', 'type', 'manufacturer', 'modelName', 'serialNumber',
|
||||||
|
'calibrationDate', 'calibrationExpirationDate', 'certificateUrl',
|
||||||
|
'status', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of updatableFields) {
|
||||||
|
if ((data as any)[key] !== undefined) {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${paramIdx++}`);
|
||||||
|
params.push((data as any)[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
const res = await query('SELECT * FROM gpi.instruments WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
return res.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE gpi.instruments
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND organization_id = $2
|
||||||
|
RETURNING *`;
|
||||||
|
|
||||||
|
const result = await query(updateSql, params);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteInstrument = async (id: string, organizationId: string) => {
|
||||||
|
const result = await query('DELETE FROM gpi.instruments WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkSerialNumberExists = async (organizationId: string, serialNumber: string) => {
|
||||||
|
const result = await query('SELECT id FROM gpi.instruments WHERE organization_id = $1 AND serial_number = $2', [organizationId, serialNumber]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
};
|
||||||
@@ -1,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,72 +1,94 @@
|
|||||||
import PaintingScheme from '../models/PaintingScheme.js';
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
interface PaintingSchemeData {
|
||||||
export const createPaintingScheme = async (data: any & { organizationId?: string }) => {
|
organizationId?: string;
|
||||||
const newScheme = new PaintingScheme({ ...data, organizationId: data.organizationId });
|
projectId?: string;
|
||||||
const saved = await newScheme.save();
|
name: string;
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPaintingScheme = async (data: PaintingSchemeData) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.painting_schemes (organization_id, project_id, name, description)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[data.organizationId, data.projectId, data.name, data.description]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPaintingSchemesByProject = async (projectId: string, organizationId?: string) => {
|
export const getPaintingSchemesByProject = async (projectId: string, organizationId?: string) => {
|
||||||
const query = { projectId, ...(organizationId ? { organizationId } : {}) };
|
let sql = 'SELECT * FROM gpi.painting_schemes WHERE project_id = $1';
|
||||||
const schemes = await PaintingScheme.find(query).lean();
|
const params: any[] = [projectId];
|
||||||
return schemes.map(s => ({ ...s, id: s._id.toString() }));
|
|
||||||
|
if (organizationId) {
|
||||||
|
sql += ' AND organization_id = $2';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const updatePaintingScheme = async (id: string, data: Partial<PaintingSchemeData>, organizationId?: string) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const checkResult = await query('SELECT * FROM gpi.painting_schemes WHERE id = $1', [id]);
|
||||||
export const updatePaintingScheme = async (id: string, data: any, organizationId?: string) => {
|
if (checkResult.rows.length === 0) return null;
|
||||||
// SECURITY FIX: Allow update if:
|
const existing = checkResult.rows[0];
|
||||||
// 1. Matches ID AND Matches Organization
|
|
||||||
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
|
|
||||||
|
|
||||||
let query: any = { _id: id };
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
|
|
||||||
// First, check if the record exists and what is its state
|
|
||||||
const existing = await PaintingScheme.findById(id);
|
|
||||||
|
|
||||||
if (!existing) return null;
|
|
||||||
|
|
||||||
// Check ownership
|
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
// Exists but belongs to ANOTHER organization -> Deny
|
|
||||||
console.warn(`Access Denied: Scheme ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return null; // Return null effectively hides it or acts as fail
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we passed the check, we perform the update.
|
|
||||||
// Ensure we "adopt" the record if it didn't have an orgId
|
|
||||||
if (organizationId && !data.organizationId) {
|
|
||||||
data.organizationId = organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await PaintingScheme.findOneAndUpdate({ _id: id }, data, { new: true }).lean();
|
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
if (data.name) {
|
||||||
|
fields.push(`name = $${paramIdx++}`);
|
||||||
|
params.push(data.name);
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
fields.push(`description = $${paramIdx++}`);
|
||||||
|
params.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.projectId) {
|
||||||
|
fields.push(`project_id = $${paramIdx++}`);
|
||||||
|
params.push(data.projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return existing;
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE gpi.painting_schemes
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`;
|
||||||
|
|
||||||
|
const result = await query(updateSql, params);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deletePaintingScheme = async (id: string, organizationId?: string) => {
|
export const deletePaintingScheme = async (id: string, organizationId?: string) => {
|
||||||
// Find first to check permissions
|
const checkResult = await query('SELECT * FROM gpi.painting_schemes WHERE id = $1', [id]);
|
||||||
const existing = await PaintingScheme.findById(id);
|
if (checkResult.rows.length === 0) return;
|
||||||
if (!existing) return;
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
// Permissions:
|
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
// If user has org, and item has OTHER org, deny.
|
return;
|
||||||
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
}
|
||||||
console.warn(`[Delete PaintingScheme] Access Denied. User Org: ${organizationId}, Scheme Org: ${existing.organizationId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await PaintingScheme.findByIdAndDelete(id);
|
await query('DELETE FROM gpi.painting_schemes WHERE id = $1', [id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllSchemes = async (organizationId?: string) => {
|
export const getAllSchemes = async (organizationId?: string) => {
|
||||||
const query = organizationId ? { organizationId } : {};
|
let sql = 'SELECT * FROM gpi.painting_schemes';
|
||||||
const schemes = await PaintingScheme.find(query).lean();
|
const params: any[] = [];
|
||||||
return schemes.map(s => ({ ...s, id: s._id.toString() }));
|
|
||||||
|
if (organizationId) {
|
||||||
|
sql += ' WHERE organization_id = $1';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,114 @@
|
|||||||
import Part from '../models/Part.js';
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
interface PartData {
|
||||||
export const createPart = async (data: any & { organizationId?: string }) => {
|
projectId: string;
|
||||||
const newPart = new Part({ ...data, organizationId: data.organizationId });
|
organizationId?: string;
|
||||||
const saved = await newPart.save();
|
name: string;
|
||||||
return { ...saved.toObject(), id: saved._id.toString() };
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
weightKg?: number;
|
||||||
|
drawingNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPart = async (data: PartData) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.parts (
|
||||||
|
project_id, organization_id, name, description, quantity, weight_kg, drawing_number
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.projectId,
|
||||||
|
data.organizationId,
|
||||||
|
data.name,
|
||||||
|
data.description,
|
||||||
|
data.quantity || 1,
|
||||||
|
data.weightKg,
|
||||||
|
data.drawingNumber
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPartsByProject = async (projectId: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const getPartsByProject = async (projectId: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const query = isGlobalAdmin
|
let sql = 'SELECT * FROM gpi.parts WHERE project_id = $1';
|
||||||
? { projectId }
|
const params: any[] = [projectId];
|
||||||
: { projectId, $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
|
|
||||||
const parts = await Part.find(query).lean();
|
if (!isGlobalAdmin && organizationId) {
|
||||||
return parts.map(p => ({ ...p, id: p._id.toString() }));
|
sql += ' AND (organization_id = $2 OR organization_id IS NULL)';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export const updatePart = async (id: string, data: Partial<PartData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const checkResult = await query('SELECT * FROM gpi.parts WHERE id = $1', [id]);
|
||||||
export const updatePart = async (id: string, data: any, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
if (checkResult.rows.length === 0) return null;
|
||||||
const existing = await Part.findById(id);
|
const existing = checkResult.rows[0];
|
||||||
if (!existing) return null;
|
|
||||||
|
|
||||||
if (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
console.warn(`Access Denied: Part ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organizationId && !existing.organizationId) {
|
|
||||||
data.organizationId = organizationId; // Adopt
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await Part.findOneAndUpdate({ _id: id }, data, { new: true }).lean();
|
|
||||||
if (updated) {
|
|
||||||
return { ...updated, id: updated._id.toString() };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
if (data.name) {
|
||||||
|
fields.push(`name = $${paramIdx++}`);
|
||||||
|
params.push(data.name);
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
fields.push(`description = $${paramIdx++}`);
|
||||||
|
params.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.quantity !== undefined) {
|
||||||
|
fields.push(`quantity = $${paramIdx++}`);
|
||||||
|
params.push(data.quantity);
|
||||||
|
}
|
||||||
|
if (data.weightKg !== undefined) {
|
||||||
|
fields.push(`weight_kg = $${paramIdx++}`);
|
||||||
|
params.push(data.weightKg);
|
||||||
|
}
|
||||||
|
if (data.drawingNumber !== undefined) {
|
||||||
|
fields.push(`drawing_number = $${paramIdx++}`);
|
||||||
|
params.push(data.drawingNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return existing;
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE gpi.parts
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`;
|
||||||
|
|
||||||
|
const result = await query(updateSql, params);
|
||||||
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deletePart = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const deletePart = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const part = await Part.findById(id);
|
const checkResult = await query('SELECT * FROM gpi.parts WHERE id = $1', [id]);
|
||||||
if (!part) return;
|
if (checkResult.rows.length === 0) return;
|
||||||
|
const existing = checkResult.rows[0];
|
||||||
|
|
||||||
if (!isGlobalAdmin && organizationId && part.organizationId && part.organizationId !== organizationId) {
|
if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) {
|
||||||
throw new Error('Sem permissão para excluir esta peça');
|
throw new Error('Sem permissão para excluir esta peça');
|
||||||
}
|
}
|
||||||
|
|
||||||
await Part.findByIdAndDelete(id);
|
await query('DELETE FROM gpi.parts WHERE id = $1', [id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllParts = async (organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const getAllParts = async (organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const query = isGlobalAdmin
|
let sql = 'SELECT * FROM gpi.parts';
|
||||||
? {}
|
const params: any[] = [];
|
||||||
: { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
|
|
||||||
const parts = await Part.find(query).lean();
|
if (!isGlobalAdmin && organizationId) {
|
||||||
return parts.map(p => ({ ...p, id: p._id.toString() }));
|
sql += ' WHERE (organization_id = $1 OR organization_id IS NULL)';
|
||||||
|
params.push(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import Project from '../models/Project.js';
|
import { query } from '../config/database.js';
|
||||||
import Part from '../models/Part.js';
|
|
||||||
import PaintingScheme from '../models/PaintingScheme.js';
|
|
||||||
import ApplicationRecord from '../models/ApplicationRecord.js';
|
|
||||||
import Inspection from '../models/Inspection.js';
|
|
||||||
|
|
||||||
interface ProjectData {
|
interface ProjectData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -12,210 +8,97 @@ interface ProjectData {
|
|||||||
technician?: string;
|
technician?: string;
|
||||||
environment?: string;
|
environment?: string;
|
||||||
weightKg?: number;
|
weightKg?: number;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createProject = async (data: ProjectData & { organizationId?: string }) => {
|
export const createProject = async (data: ProjectData & { organizationId?: string }) => {
|
||||||
const newProject = new Project({
|
const res = await query(
|
||||||
name: data.name,
|
'INSERT INTO gpi.projects (organization_id, name, client, start_date, end_date, technician, environment, weight_kg) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *',
|
||||||
client: data.client,
|
[data.organizationId, data.name, data.client, data.startDate, data.endDate, data.technician, data.environment, data.weightKg]
|
||||||
startDate: data.startDate ? new Date(data.startDate) : null,
|
);
|
||||||
endDate: data.endDate ? new Date(data.endDate) : null,
|
return res.rows[0];
|
||||||
technician: data.technician,
|
|
||||||
environment: data.environment,
|
|
||||||
organizationId: data.organizationId,
|
|
||||||
weightKg: data.weightKg
|
|
||||||
});
|
|
||||||
return await newProject.save();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDashboardProjects = async (organizationId?: string) => {
|
export const getDashboardProjects = async (organizationId?: string) => {
|
||||||
const matchStage = organizationId ? { organizationId } : {};
|
const sql = `
|
||||||
|
SELECT p.*,
|
||||||
const projects = await Project.aggregate([
|
(SELECT json_agg(s) FROM gpi.painting_schemes s WHERE s.project_id = p.id) as schemes,
|
||||||
{ $match: matchStage },
|
(SELECT SUM(weight_kg) FROM gpi.inspections i WHERE i.project_id = p.id) as painted_weight
|
||||||
{ $sort: { name: 1 } },
|
FROM gpi.projects p
|
||||||
{
|
WHERE (p.organization_id = $1 OR $1 IS NULL) AND p.status = 'active'
|
||||||
$lookup: {
|
ORDER BY p.name ASC
|
||||||
from: 'paintingschemes',
|
`;
|
||||||
localField: '_id',
|
const res = await query(sql, [organizationId || null]);
|
||||||
foreignField: 'projectId',
|
return res.rows;
|
||||||
as: 'paintingSchemes'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$lookup: {
|
|
||||||
from: 'inspections',
|
|
||||||
localField: '_id',
|
|
||||||
foreignField: 'projectId',
|
|
||||||
as: 'inspections'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$project: {
|
|
||||||
_id: 1,
|
|
||||||
name: 1,
|
|
||||||
client: 1,
|
|
||||||
technician: 1,
|
|
||||||
weightKg: 1,
|
|
||||||
createdAt: 1,
|
|
||||||
schemes: {
|
|
||||||
$map: {
|
|
||||||
input: "$paintingSchemes",
|
|
||||||
as: "scheme",
|
|
||||||
in: {
|
|
||||||
id: { $toString: "$$scheme._id" },
|
|
||||||
name: "$$scheme.name",
|
|
||||||
type: "$$scheme.type",
|
|
||||||
coat: "$$scheme.coat",
|
|
||||||
color: "$$scheme.color",
|
|
||||||
colorHex: "$$scheme.colorHex",
|
|
||||||
thinnerSymbol: "$$scheme.thinnerSymbol",
|
|
||||||
epsMin: "$$scheme.epsMin",
|
|
||||||
epsMax: "$$scheme.epsMax"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
paintedWeight: { $sum: "$inspections.weightKg" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
return projects.map(p => ({ ...p, id: p._id.toString() }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllProjects = async (organizationId?: string, isGlobalAdmin: boolean = false, status: string = 'active') => {
|
export const getAllProjects = async (organizationId?: string, isGlobalAdmin: boolean = false, status: string = 'active') => {
|
||||||
const statusQuery = status === 'active'
|
let sql = 'SELECT * FROM gpi.projects WHERE status = $1';
|
||||||
? { status: { $ne: 'archived' } }
|
const params: any[] = [status];
|
||||||
: { status: 'archived' };
|
|
||||||
|
|
||||||
const matchQuery: Record<string, unknown> = isGlobalAdmin
|
if (!isGlobalAdmin) {
|
||||||
? { ...statusQuery }
|
sql += ' AND (organization_id = $2 OR organization_id IS NULL)';
|
||||||
: {
|
params.push(organizationId);
|
||||||
...statusQuery,
|
|
||||||
$or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }]
|
|
||||||
};
|
|
||||||
|
|
||||||
const projects = await Project.aggregate([
|
|
||||||
{ $match: matchQuery },
|
|
||||||
{ $sort: { name: 1 } },
|
|
||||||
{
|
|
||||||
$lookup: {
|
|
||||||
from: 'paintingschemes',
|
|
||||||
localField: '_id',
|
|
||||||
foreignField: 'projectId',
|
|
||||||
as: 'paintingSchemes'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$addFields: {
|
|
||||||
id: { $toString: "$_id" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
return projects;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const archiveProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
|
||||||
const project = await Project.findById(id);
|
|
||||||
if (!project) throw new Error('Projeto não encontrado');
|
|
||||||
|
|
||||||
// Check ownership
|
|
||||||
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
|
|
||||||
throw new Error('Sem permissão para arquivar este projeto');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newStatus = project.status === 'active' ? 'archived' : 'active';
|
sql += ' ORDER BY name ASC';
|
||||||
const updated = await Project.findByIdAndUpdate(id, { status: newStatus }, { new: true }).lean();
|
const res = await query(sql, params);
|
||||||
return updated;
|
return res.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProjectById = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const getProjectById = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const project = await Project.findById(id).lean();
|
const res = await query('SELECT * FROM gpi.projects WHERE id = $1', [id]);
|
||||||
|
const project = res.rows[0];
|
||||||
|
|
||||||
if (!project) throw new Error('Projeto não encontrado');
|
if (!project) throw new Error('Projeto não encontrado');
|
||||||
|
|
||||||
// Security check: Allow if global admin OR matches organization OR project has no organization
|
if (!isGlobalAdmin && organizationId && project.organization_id && project.organization_id !== organizationId) {
|
||||||
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
|
|
||||||
throw new Error('Acesso negado a este projeto');
|
throw new Error('Acesso negado a este projeto');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [parts, schemes, records, inspections] = await Promise.all([
|
const [parts, schemes, records, inspections] = await Promise.all([
|
||||||
Part.find({ projectId: id }).lean(),
|
query('SELECT * FROM gpi.parts WHERE project_id = $1', [id]),
|
||||||
PaintingScheme.find({ projectId: id }).populate('paintId thinnerId').lean(),
|
query('SELECT * FROM gpi.painting_schemes WHERE project_id = $1', [id]),
|
||||||
ApplicationRecord.find({ projectId: id }).lean(),
|
query('SELECT * FROM gpi.application_records WHERE project_id = $1', [id]),
|
||||||
Inspection.find({ projectId: id })
|
query('SELECT * FROM gpi.inspections WHERE project_id = $1', [id])
|
||||||
.populate({
|
|
||||||
path: 'stockItemId',
|
|
||||||
select: 'batchNumber dataSheetId',
|
|
||||||
populate: { path: 'dataSheetId', select: 'name' }
|
|
||||||
})
|
|
||||||
.lean()
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
id: project._id.toString(),
|
parts: parts.rows,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
paintingSchemes: schemes.rows,
|
||||||
parts: parts.map((p: any) => ({ ...p, id: p._id.toString() })),
|
applicationRecords: records.rows,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
inspections: inspections.rows
|
||||||
paintingSchemes: schemes.map((s: any) => ({ ...s, id: s._id.toString() })),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
applicationRecords: records.map((r: any) => ({ ...r, id: r._id.toString() })),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
inspections: inspections.map((i: any) => ({ ...i, id: i._id.toString() }))
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProject = async (id: string, data: Partial<ProjectData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const updateProject = async (id: string, data: Partial<ProjectData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const existing = await Project.findById(id);
|
const fields: string[] = [];
|
||||||
if (!existing) return null;
|
const params: any[] = [id];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
// Check ownership
|
const updatableFields = ['name', 'client', 'startDate', 'endDate', 'technician', 'environment', 'weightKg', 'status'];
|
||||||
if (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) {
|
|
||||||
console.warn(`Access Denied: Project ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
|
for (const key of updatableFields) {
|
||||||
return null;
|
if ((data as any)[key] !== undefined) {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${paramIdx++}`);
|
||||||
|
params.push((data as any)[key]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Partial<ProjectData> & { updatedAt: Date, organizationId?: string } = {
|
if (fields.length === 0) return null;
|
||||||
...data,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
startDate: data.startDate ? new Date(data.startDate) : undefined,
|
|
||||||
endDate: data.endDate ? new Date(data.endDate) : undefined,
|
|
||||||
weightKg: data.weightKg,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Adopt if needed
|
const sql = `UPDATE gpi.projects SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1 RETURNING *`;
|
||||||
if (organizationId && !existing.organizationId) {
|
const res = await query(sql, params);
|
||||||
updateData.organizationId = organizationId;
|
return res.rows[0];
|
||||||
}
|
};
|
||||||
|
|
||||||
const updated = await Project.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
|
export const archiveProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
if (updated) {
|
const res = await query("UPDATE gpi.projects SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *", [id]);
|
||||||
return { ...updated, id: updated._id.toString() };
|
return res.rows[0];
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
export const deleteProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
|
||||||
const project = await Project.findById(id);
|
await query('DELETE FROM gpi.projects WHERE id = $1', [id]);
|
||||||
if (!project) return;
|
|
||||||
|
|
||||||
// Check ownership
|
|
||||||
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
|
|
||||||
throw new Error('Sem permissão para excluir este projeto');
|
|
||||||
}
|
|
||||||
|
|
||||||
await Project.findByIdAndDelete(id);
|
|
||||||
|
|
||||||
// Also cleanup related data
|
|
||||||
await Promise.all([
|
|
||||||
Part.deleteMany({ projectId: id }),
|
|
||||||
PaintingScheme.deleteMany({ projectId: id }),
|
|
||||||
ApplicationRecord.deleteMany({ projectId: id }),
|
|
||||||
Inspection.deleteMany({ projectId: id })
|
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
|
export const createStockItem = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.stock_items (
|
||||||
|
organization_id, created_by, data_sheet_id, rr_number, batch_number,
|
||||||
|
color, invoice_number, received_by, quantity, unit, min_stock,
|
||||||
|
expiration_date, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.createdBy, data.dataSheetId, data.rrNumber, data.batchNumber,
|
||||||
|
data.color, data.invoiceNumber, data.receivedBy, data.quantity, data.unit, data.minStock,
|
||||||
|
data.expirationDate, data.notes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createStockMovement = async (data: any) => {
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO gpi.stock_movements (
|
||||||
|
organization_id, created_by, stock_item_id, movement_number, type,
|
||||||
|
quantity, date, responsible, reason, requester, notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.createdBy, data.stockItemId, data.movementNumber, data.type,
|
||||||
|
data.quantity, data.date || new Date(), data.responsible, data.reason, data.requester, data.notes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockItems = async (organizationId: string, dataSheetId?: string) => {
|
||||||
|
let sql = `
|
||||||
|
SELECT si.*, ds.name as "dataSheetName", ds.manufacturer as "dataSheetManufacturer"
|
||||||
|
FROM gpi.stock_items si
|
||||||
|
LEFT JOIN gpi.technical_data_sheets ds ON si.data_sheet_id = ds.id
|
||||||
|
WHERE si.organization_id = $1`;
|
||||||
|
const params: any[] = [organizationId];
|
||||||
|
|
||||||
|
if (dataSheetId) {
|
||||||
|
sql += ' AND si.data_sheet_id = $2';
|
||||||
|
params.push(dataSheetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY si.expiration_date ASC';
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockItemById = async (id: string, organizationId: string) => {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT si.*, ds.name as "dataSheetName", ds.manufacturer as "dataSheetManufacturer"
|
||||||
|
FROM gpi.stock_items si
|
||||||
|
LEFT JOIN gpi.technical_data_sheets ds ON si.data_sheet_id = ds.id
|
||||||
|
WHERE si.id = $1 AND si.organization_id = $2`,
|
||||||
|
[id, organizationId]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateStockItem = async (id: string, organizationId: string, data: any) => {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [id, organizationId];
|
||||||
|
let idx = 3;
|
||||||
|
|
||||||
|
for (const key in data) {
|
||||||
|
if (data[idx] !== undefined && key !== 'id' && key !== 'organizationId') {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`);
|
||||||
|
fields.push(`${dbKey} = $${idx++}`);
|
||||||
|
params.push(data[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual loop just in case
|
||||||
|
const updatable = ['notes', 'expirationDate', 'minStock', 'invoiceNumber', 'receivedBy', 'batchNumber', 'rrNumber', 'color'];
|
||||||
|
const finalFields = [];
|
||||||
|
const finalParams = [id, organizationId];
|
||||||
|
let pIdx = 3;
|
||||||
|
|
||||||
|
for (const key of updatable) {
|
||||||
|
if (data[key] !== undefined) {
|
||||||
|
const dbKey = key.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`);
|
||||||
|
finalFields.push(`${dbKey} = $${pIdx++}`);
|
||||||
|
finalParams.push(data[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalFields.length === 0) return null;
|
||||||
|
|
||||||
|
const sql = `UPDATE gpi.stock_items SET ${finalFields.join(', ')}, updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING *`;
|
||||||
|
const res = await query(sql, finalParams);
|
||||||
|
return res.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateStockItemQuantity = async (id: string, newQuantity: number) => {
|
||||||
|
await query('UPDATE gpi.stock_items SET quantity = $1, updated_at = NOW() WHERE id = $2', [newQuantity, id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteStockItem = async (id: string, organizationId: string) => {
|
||||||
|
await query('DELETE FROM gpi.stock_movements WHERE stock_item_id = $1', [id]);
|
||||||
|
await query('DELETE FROM gpi.stock_audit_logs WHERE stock_item_id = $1', [id]);
|
||||||
|
const res = await query('DELETE FROM gpi.stock_items WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
return res.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockMovements = async (stockItemId: string, organizationId: string) => {
|
||||||
|
const result = await query(
|
||||||
|
'SELECT * FROM gpi.stock_movements WHERE stock_item_id = $1 AND organization_id = $2 ORDER BY date DESC',
|
||||||
|
[stockItemId, organizationId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLatestMovementNumber = async (stockItemId: string) => {
|
||||||
|
const result = await query(
|
||||||
|
'SELECT MAX(movement_number) as max FROM gpi.stock_movements WHERE stock_item_id = $1',
|
||||||
|
[stockItemId]
|
||||||
|
);
|
||||||
|
return result.rows[0].max || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockMovementById = async (id: string, organizationId: string) => {
|
||||||
|
const res = await query('SELECT * FROM gpi.stock_movements WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
return res.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateStockMovement = async (id: string, organizationId: string, data: any) => {
|
||||||
|
const res = await query(
|
||||||
|
'UPDATE gpi.stock_movements SET quantity = COALESCE($1, quantity), date = COALESCE($2, date), notes = COALESCE($3, notes), updated_at = NOW() WHERE id = $4 AND organization_id = $5 RETURNING *',
|
||||||
|
[data.quantity, data.date, data.notes, id, organizationId]
|
||||||
|
);
|
||||||
|
return res.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteStockMovement = async (id: string, organizationId: string) => {
|
||||||
|
const res = await query('DELETE FROM gpi.stock_movements WHERE id = $1 AND organization_id = $2', [id, organizationId]);
|
||||||
|
return res.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAuditLog = async (data: any) => {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO gpi.stock_audit_logs (
|
||||||
|
organization_id, stock_item_id, movement_id, movement_number,
|
||||||
|
user_id, user_name, action, details, old_values, new_values
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
|
[
|
||||||
|
data.organizationId, data.stockItemId, data.movementId, data.movementNumber,
|
||||||
|
data.userId, data.userName, data.action, data.details, data.oldValues, data.newValues
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStockAuditLogs = async (stockItemId: string, organizationId: string) => {
|
||||||
|
const res = await query(
|
||||||
|
'SELECT * FROM gpi.stock_audit_logs WHERE stock_item_id = $1 AND organization_id = $2 ORDER BY timestamp DESC',
|
||||||
|
[stockItemId, organizationId]
|
||||||
|
);
|
||||||
|
return res.rows;
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { query } from '../config/database.js';
|
||||||
|
|
||||||
|
export const syncUser = async (data: { externalId: string, email: string, name: string, organizationId?: string, clerkRole?: string }) => {
|
||||||
|
const { externalId, email, name, organizationId, clerkRole } = data;
|
||||||
|
|
||||||
|
let result = await query(
|
||||||
|
`INSERT INTO gpi.users (clerk_id, email, name, role, updated_at)
|
||||||
|
VALUES ($1, $2, $3, 'guest', NOW())
|
||||||
|
ON CONFLICT (clerk_id) DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name, updated_at = NOW()
|
||||||
|
RETURNING *`,
|
||||||
|
[externalId, email, name]
|
||||||
|
);
|
||||||
|
|
||||||
|
let user = result.rows[0];
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
let appRole = 'guest';
|
||||||
|
if (clerkRole === 'org:admin') appRole = 'admin';
|
||||||
|
else if (clerkRole === 'org:member') appRole = 'user';
|
||||||
|
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length > 0) {
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO gpi.user_organizations (user_id, organization_id, role, updated_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
ON CONFLICT (user_id, organization_id) DO NOTHING`,
|
||||||
|
[user.id, orgUuid, appRole]
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberResult = await query(
|
||||||
|
'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2',
|
||||||
|
[user.id, orgUuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
organizationRole: memberResult.rows[0].role,
|
||||||
|
organizationBanned: memberResult.rows[0].is_banned
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentUser = async (externalId: string, organizationId?: string) => {
|
||||||
|
const userResult = await query('SELECT * FROM gpi.users WHERE clerk_id = $1 OR logto_id = $1', [externalId]);
|
||||||
|
if (userResult.rows.length === 0) return null;
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length > 0) {
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
const memberResult = await query(
|
||||||
|
'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2',
|
||||||
|
[user.id, orgUuid]
|
||||||
|
);
|
||||||
|
if (memberResult.rows.length > 0) {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
role: memberResult.rows[0].role,
|
||||||
|
isBanned: memberResult.rows[0].is_banned,
|
||||||
|
organizationId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllUsersInOrg = async (organizationId: string) => {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length === 0) return [];
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
const results = await query(
|
||||||
|
`SELECT u.*, uo.role as organization_role, uo.is_banned as organization_banned, uo.id as member_id
|
||||||
|
FROM gpi.users u
|
||||||
|
JOIN gpi.user_organizations uo ON u.id = uo.user_id
|
||||||
|
WHERE uo.organization_id = $1
|
||||||
|
ORDER BY uo.created_at DESC`,
|
||||||
|
[orgUuid]
|
||||||
|
);
|
||||||
|
return results.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserRole = async (userIdOrMemberId: string, organizationId: string, role: string) => {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length === 0) return null;
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
// Try update by member_id or user_id
|
||||||
|
const result = await query(
|
||||||
|
'UPDATE gpi.user_organizations SET role = $1, updated_at = NOW() WHERE (id = $2 OR user_id = $2) AND organization_id = $3 RETURNING *',
|
||||||
|
[role, userIdOrMemberId, orgUuid]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleBanUser = async (userIdOrMemberId: string, organizationId: string, isBanned: boolean) => {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length === 0) return null;
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
'UPDATE gpi.user_organizations SET is_banned = $1, updated_at = NOW() WHERE (id = $2 OR user_id = $2) AND organization_id = $3 RETURNING *',
|
||||||
|
[isBanned, userIdOrMemberId, orgUuid]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const heartbeat = async (userId: string) => {
|
||||||
|
await query('UPDATE gpi.users SET last_seen_at = NOW() WHERE id = $1', [userId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveUsers = async (organizationId: string, excludeUserId?: string) => {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length === 0) return [];
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
|
||||||
|
const result = await query(
|
||||||
|
`SELECT u.id, u.name, u.email, u.last_seen_at, u.clerk_id as "externalId"
|
||||||
|
FROM gpi.users u
|
||||||
|
JOIN gpi.user_organizations uo ON u.id = uo.user_id
|
||||||
|
WHERE uo.organization_id = $1 AND u.last_seen_at >= $2 AND u.id != $3`,
|
||||||
|
[orgUuid, twoMinutesAgo, excludeUserId || '00000000-0000-0000-0000-000000000000']
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserFromOrg = async (userIdOrMemberId: string, organizationId: string) => {
|
||||||
|
const orgResult = await query('SELECT id FROM gpi.organizations WHERE clerk_id = $1 OR logto_id = $1', [organizationId]);
|
||||||
|
if (orgResult.rows.length === 0) return false;
|
||||||
|
const orgUuid = orgResult.rows[0].id;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
'DELETE FROM gpi.user_organizations WHERE (id = $1 OR user_id = $1) AND organization_id = $2',
|
||||||
|
[userIdOrMemberId, orgUuid]
|
||||||
|
);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
-3
@@ -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;
|
||||||
clerkUserId?: string;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export {};
|
||||||
|
|||||||
+120
-101
@@ -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