Compare commits

..

8 Commits

91 changed files with 3997 additions and 3035 deletions
+4 -4
View File
@@ -1,9 +1,9 @@
FROM node:22-slim FROM node:20-slim
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm install
COPY . . COPY . .
RUN npm run build RUN npm run build:client
ENV PORT=3000 ENV PORT=3000
EXPOSE 3000 EXPOSE 3000
CMD ["node", "dist/server/src/server/index.js"] CMD ["npx", "tsx", "src/server/index.ts"]
+55
View File
@@ -0,0 +1,55 @@
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;
+27
View File
@@ -0,0 +1,27 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import mongoose from 'mongoose';
export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error('MONGODB_URI is missing from Vercel settings');
await mongoose.connect(uri);
const state = mongoose.connection.readyState;
await mongoose.disconnect();
res.json({
success: true,
message: 'MongoDB Connection verified!',
state: state === 1 ? 'Connected' : state
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
const stack = error instanceof Error ? error.stack : undefined;
res.status(500).json({
success: false,
error: message,
stack: stack
});
}
}
+28
View File
@@ -0,0 +1,28 @@
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
View File
@@ -0,0 +1,10 @@
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'
});
}
-19
View File
@@ -1,19 +0,0 @@
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();
-7
View File
@@ -1,7 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAp2sxQE/eO30etME9tLKJjguVPUg3W8k3j2H7F/kBVogAAAJAeNjMSHjYz
EgAAAAtzc2gtZWQyNTUxOQAAACAp2sxQE/eO30etME9tLKJjguVPUg3W8k3j2H7F/kBVog
AAAEDLm78AwM6lbNoz7iVUh1xvlphZzNhitquW4jHyR7lIhCnazFAT947fR60wT20somOC
5U9SDdbyTePYfsX+QFWiAAAAC2FudGlncmF2aXR5AQI=
-----END OPENSSH PRIVATE KEY-----
-1
View File
@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnazFAT947fR60wT20somOC5U9SDdbyTePYfsX+QFWi antigravity
+1 -1
View File
@@ -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>GPI - JWT VERSION 1.6</title> <title>SteelPaint</title>
</head> </head>
<body> <body>
+72
View File
@@ -0,0 +1,72 @@
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);
};
+47 -161
View File
@@ -9,6 +9,8 @@
"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",
@@ -21,13 +23,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",
@@ -42,13 +44,10 @@
}, },
"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",
@@ -3342,7 +3341,6 @@
"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": {
@@ -3455,7 +3453,6 @@
"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": "*",
@@ -3473,7 +3470,6 @@
"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": {
@@ -3488,24 +3484,11 @@
"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,
@@ -3518,7 +3501,7 @@
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.9", "version": "19.2.9",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -3534,7 +3517,7 @@
}, },
"node_modules/@types/react/node_modules/csstype": { "node_modules/@types/react/node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
@@ -6683,6 +6666,33 @@
"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",
@@ -8513,6 +8523,21 @@
"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",
@@ -9073,95 +9098,6 @@
"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"
@@ -9220,45 +9156,6 @@
"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,
@@ -10319,15 +10216,6 @@
"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",
@@ -10840,7 +10728,6 @@
}, },
"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": {
@@ -11066,7 +10953,6 @@
}, },
"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": {
+9 -4
View File
@@ -14,7 +14,11 @@
}, },
"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",
@@ -24,11 +28,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",
"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",
@@ -36,21 +42,20 @@
"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",
+28 -31
View File
@@ -1,6 +1,7 @@
// App v1.5 - JWT Migration Final Check import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext'; import { AuthProvider } 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';
@@ -17,33 +18,44 @@ 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 { OrganizationSelector } from './pages/OrganizationSelector'; import Register from './pages/Register';
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 AppContent: React.FC = () => { const AppRoutes: React.FC = () => {
const { appUser, isLoading } = useAuth(); const { isSignedIn, isLoading } = useAuth();
if (isLoading) return <div className="flex h-screen items-center justify-center">Carregando...</div>; if (isLoading) {
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>
);
}
// AppUser exists but hasn't selected an org yet (if your business logic requires orgs) if (!isSignedIn) {
if (appUser && !appUser.organizationId) { return (
return <OrganizationSelector />; <Routes>
<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="/register" element={<Navigate to="/" replace />} />
<Route path="/guest-dashboard" element={<GuestDashboard />} /> <Route path="/guest-dashboard" element={<GuestDashboard />} />
<Route path="/projects" element={<ProjectList />} /> <Route path="/projects" element={<ProjectList />} />
<Route path="/project/:id" element={<ProjectDetails />} /> <Route path="/project/:id" element={<ProjectDetails />} />
@@ -89,40 +101,25 @@ const AppContent: React.FC = () => {
</DeveloperRoute> </DeveloperRoute>
} }
/> />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Layout> </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>
); );
} }
+22 -11
View File
@@ -2,11 +2,13 @@ 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 } from 'lucide-react'; import { Menu, X, FolderOpen, Layers, ClipboardCheck, LogOut, TrendingUp, Sun, Moon, HelpCircle, Shield, Wrench, Terminal, LayoutDashboard, Package, Thermometer, User as UserIcon } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useAuth } from '../context/useAuth';
import { TechnicalManual } from './TechnicalManual'; import { TechnicalManual } from './TechnicalManual';
import { useAuth } from '../context/useAuth';
// 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;
} }
@@ -22,6 +24,15 @@ 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) {
@@ -102,13 +113,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="Organizaçã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="Apenas visualização">
<div className="w-8 h-8 rounded-lg bg-surface-soft flex items-center justify-center font-bold text-xs"> <div className="w-8 h-8 rounded-lg bg-surface-soft flex items-center justify-center font-bold text-xs uppercase">
ORG {appUser?.organizationId ? 'ORG' : 'GPI'}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">Organização Matriz</p> <p className="text-sm font-semibold truncate">{appUser?.organizationId || 'Padrão'}</p>
<p className="text-[10px] text-text-muted uppercase tracking-wider">Conta</p> <p className="text-[10px] text-text-muted uppercase tracking-wider">Organização</p>
</div> </div>
</div> </div>
</div> </div>
@@ -212,7 +223,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} />
@@ -223,12 +234,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 bg-primary text-white rounded-full flex items-center justify-center font-bold text-xs"> <div className="w-8 h-8 rounded-full bg-surface-soft flex items-center justify-center">
{appUser?.name?.substring(0, 2).toUpperCase() || 'US'} <UserIcon size={16} className="text-text-muted" />
</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">v2.1.0</span> <span className="text-[8px] text-text-muted">v3.0.0</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
+31 -43
View File
@@ -3,12 +3,13 @@ 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;
} }
@@ -23,23 +24,21 @@ interface PendingMessage {
export const TeamPresence: React.FC = () => { export const TeamPresence: React.FC = () => {
const { activeUsers } = usePresence(); const { activeUsers } = usePresence();
const { appUser } = useAuth(); const { appUser, isSignedIn } = 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<OrganizationMember[]>('/users'); const response = await api.get<any[]>('/users');
setAllMembers(response.data); setAllMembers(response.data.map(m => ({ ...m, id: m._id || m.id })));
} 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');
@@ -50,35 +49,33 @@ export const TeamPresence: React.FC = () => {
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
if (appUser) { if (isSignedIn) {
fetchMembers(); fetchMembers();
fetchPendingMessages(); fetchPendingMessages();
const memberInterval = setInterval(fetchMembers, 60000); const intervalMembers = setInterval(fetchMembers, 60000);
const messageInterval = setInterval(fetchPendingMessages, 30000); const intervalMessages = setInterval(fetchPendingMessages, 30000);
return () => { return () => {
clearInterval(memberInterval); clearInterval(intervalMembers);
clearInterval(messageInterval); clearInterval(intervalMessages);
}; };
} }
}, [appUser, fetchMembers, fetchPendingMessages]); }, [isSignedIn, fetchMembers, fetchPendingMessages]);
if (allMembers.length === 0) { if (allMembers.length === 0) return null;
return null;
}
// Create a Set of active user IDs for fast lookup // Create a Set of active user emails or IDs (depends on what usePresence returns)
const activeUserEmails = new Set(activeUsers.map(u => u.email)); // Assuming usePresence returns objects with email or id
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; // Don't allow messaging yourself return;
} }
setSelectedUser({ id: member.email, name: member.name }); setSelectedUser({ id: member.id, name: member.name });
setIsModalOpen(true); setIsModalOpen(true);
}; };
@@ -87,7 +84,7 @@ export const TeamPresence: React.FC = () => {
setSelectedUser(null); setSelectedUser(null);
}; };
const handleMessageSent = async () => { const handleMessageSent = () => {
fetchPendingMessages(); fetchPendingMessages();
}; };
@@ -106,48 +103,45 @@ 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 = activeUserEmails.has(member.email); const isOnline = activeUserIdentifiers.has(member.email) || activeUserIdentifiers.has(member.id);
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={` <div className={clsx(`
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',
${isCurrentUser ? 'ring-2 ring-amber-500 cursor-default' : 'cursor-pointer hover:scale-110'} hasPendingMessage && 'ring-2 ring-blue-500'
${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} {member.name || 'Sem nome'}
{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={`text-[10px] mt-1 font-semibold ${isOnline ? 'text-green-500' : 'text-text-muted'}`}> <div className={clsx("text-[10px] mt-1 font-semibold", isOnline ? 'text-green-500' : 'text-text-muted')}>
{isOnline ? '🟢 Online' : '⚫ Offline'} {isOnline ? '🟢 Online' : '⚫ Offline'}
</div> </div>
{!isCurrentUser && ( {!isCurrentUser && (
@@ -155,11 +149,6 @@ 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>
@@ -168,14 +157,13 @@ 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.email === selectedUser.id)!)} existingMessage={getExistingMessage(allMembers.find(m => m.id === selectedUser.id)!)}
onMessageSent={handleMessageSent} onMessageSent={handleMessageSent}
/> />
)} )}
+10 -10
View File
@@ -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 { useAuth } from '../../context/useAuth'; import { useOrganization } from '@clerk/clerk-react';
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 { appUser } = useAuth(); const { organization } = useOrganization();
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 (!appUser) return; if (!organization) 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_gpi_${timestamp}.json`; link.download = `backup_${organization.name}_${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 do sistema ou restaurar dados de um backup anterior. Use esta ferramenta para criar cópias de segurança de todos os dados da organização ou restaurar dados de um backup anterior.
<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> <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>
</p> </p>
</div> </div>
</div> </div>
@@ -248,18 +248,18 @@ export const BackupRestore: React.FC = () => {
</label> </label>
{validationResult && ( {validationResult && (
<div className={`p-4 rounded-xl border ${validationResult.valid <div className={`p-4 rounded-xl border ${validationResult.valid && validationResult.isValidOrganization
? '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.valid && validationResult.isValidOrganization ? (
<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 <p className={`text-sm font-bold ${validationResult.valid && validationResult.isValidOrganization
? '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 || isImporting} disabled={!validationResult?.valid || !validationResult?.isValidOrganization || 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 { appUser } = useAuth(); const { organization } = useOrganization();
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,7 +20,13 @@ export const GeometrySettings: React.FC = () => {
efficiencyLoss: '20' efficiencyLoss: '20'
}); });
const fetchTypes = React.useCallback(async () => { useEffect(() => {
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();
@@ -30,18 +36,12 @@ 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 ?? 0).toString() }); setForm({ name: item.name, efficiencyLoss: item.efficiencyLoss.toString() });
} else { } else {
setEditingItem(null); setEditingItem(null);
setForm({ name: '', efficiencyLoss: '20' }); setForm({ name: '', efficiencyLoss: '20' });
+36 -74
View File
@@ -1,90 +1,62 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import type { AppUser } from '../types'; import type { AppUser } from '../types';
import { setApiToken, setApiOrganizationId, getBaseUrl } from '../services/api'; import { AuthContext } from './AuthContextType';
import api, { getBaseUrl, setApiOrganizationId } from '../services/api';
const API_URL = getBaseUrl(); const API_URL = getBaseUrl();
export interface AuthContextType { interface AuthProviderProps {
appUser: AppUser | null; children: React.ReactNode;
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 AuthContext = createContext<AuthContextType | undefined>(undefined); export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
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);
// Initial load: se tem token, setar no interceptor e buscar dados do usuário
useEffect(() => {
if (token) {
setApiToken(token);
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 logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem('jwt_token'); localStorage.removeItem('gpi_token');
setToken(null);
setAppUser(null); setAppUser(null);
setApiToken(null);
setApiOrganizationId(null); setApiOrganizationId(null);
window.location.href = '/login';
}, []); }, []);
const refetchUser = useCallback(async () => { const fetchMe = useCallback(async () => {
if (!token) return; const token = localStorage.getItem('gpi_token');
setIsLoading(true); if (!token) {
setAppUser(null);
setIsLoading(false);
return;
}
try { try {
const response = await fetch(`${API_URL}/auth/me`, { setIsLoading(true);
headers: { const response = await api.get('/auth/me');
'Authorization': `Bearer ${token}` const userData = response.data;
},
setAppUser({
...userData,
id: userData._id || userData.id
}); });
if (response.ok) {
const userData = await response.json();
setAppUser(userData);
if (userData.organizationId) { if (userData.organizationId) {
setApiOrganizationId(userData.organizationId); setApiOrganizationId(userData.organizationId);
} }
} else { } catch (err: any) {
// Token inválido ou expirado console.error('Error fetching current user:', err);
if (err.response?.status === 401) {
logout(); logout();
} else {
setError('Erro ao carregar dados do usuário');
} }
} catch (err) {
console.error('Error refetching user:', err);
setError('Falha na comunicação de autenticação.');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [token, logout]); }, [logout]);
useEffect(() => {
fetchMe();
}, [fetchMe]);
const isDeveloper = useCallback(() => { const isDeveloper = useCallback(() => {
return appUser?.email === 'admtracksteel@gmail.com'; return appUser?.email === 'admtracksteel@gmail.com';
@@ -93,7 +65,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ 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 !== null) || isDeveloper(), [appUser, isDeveloper]); const canEdit = useCallback(() => (appUser?.role !== 'guest' && appUser?.role !== undefined) || isDeveloper(), [appUser, isDeveloper]);
return ( return (
<AuthContext.Provider <AuthContext.Provider
@@ -102,26 +74,16 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
isLoading, isLoading,
isSignedIn: !!appUser, isSignedIn: !!appUser,
error, error,
token,
login,
logout,
isAdmin, isAdmin,
isUser, isUser,
isGuest, isGuest,
isDeveloper, isDeveloper,
canEdit, canEdit,
refetchUser, refetchUser: fetchMe,
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;
};
+4
View File
@@ -1,3 +1,4 @@
import { createContext } from 'react';
import type { AppUser } from '../types'; import type { AppUser } from '../types';
export interface AuthContextType { export interface AuthContextType {
@@ -11,4 +12,7 @@ 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);
+10 -1
View File
@@ -1 +1,10 @@
export { useAuth } from './AuthContext'; import { useContext } from 'react';
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;
};
+47 -21
View File
@@ -6,47 +6,56 @@ 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 || !orgId) return; if (!isSignedIn) return;
try { try {
setLoading(true); if (notifications.length === 0) setLoading(true);
const response = await api.get<INotification[]>('/notifications'); const response = await api.get('/notifications');
setNotifications(response.data); setNotifications(response.data);
} catch (error) { } catch (error) {
console.error('Error fetching notifications:', error); console.error('Failed to fetch notifications', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isSignedIn, orgId]); }, [isSignedIn, notifications.length]);
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.patch(`/notifications/${id}/read`); await api.put(`/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('Error marking notification as read:', error); console.error('Failed to mark as read', error);
} }
}; };
const markAllAsRead = async () => { const markAllAsRead = async () => {
try { try {
await api.post('/notifications/read-all'); await api.put('/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('Error marking all notifications as read:', error); console.error('Failed to mark all 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);
} }
}; };
@@ -55,10 +64,25 @@ 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('Error deleting notification:', error); console.error('Failed to delete 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 (
@@ -66,10 +90,12 @@ export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({
notifications, notifications,
unreadCount, unreadCount,
loading, loading,
fetchNotifications,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
deleteNotification clearAll,
archiveNotification,
deleteNotification,
fetchNotifications
}}> }}>
{children} {children}
</NotificationContext.Provider> </NotificationContext.Provider>
+1 -1
View File
@@ -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;
} }
+4 -4
View File
@@ -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 />
); )
+25 -88
View File
@@ -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, Upload, Info, Image as ImageIcon, Box, Database } from 'lucide-react'; import { Shield, UserCheck, UserX, Users, Search, RefreshCw, Crown, Eye, User as UserIcon, 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,103 +14,65 @@ const roleLabels: Record<UserRole, { label: string; color: string; icon: React.R
}; };
export const AdminDashboard: React.FC = () => { export const AdminDashboard: React.FC = () => {
const { appUser, isAdmin } = useAuth(); const { isAdmin, appUser } = 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: AppUser) => ({ ...u, id: u._id || u.id }))); setUsers(response.data.map((u: any) => ({ ...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: unknown) { } catch (error: any) {
const err = error as { response?: { data?: { error?: string } } };
console.error('Error updating role:', error); console.error('Error updating role:', error);
alert(err.response?.data?.error || 'Erro ao atualizar role'); alert(error.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: unknown) { } catch (error: any) {
const err = error as { response?: { data?: { error?: string } } };
console.error('Error toggling ban:', error); console.error('Error toggling ban:', error);
alert(err.response?.data?.error || 'Erro ao alterar status'); alert(error.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">
@@ -132,14 +94,14 @@ export const AdminDashboard: React.FC = () => {
</div> </div>
Administração Administração
</h1> </h1>
<p className="text-text-muted mt-2">Configurações globais e gerenciamento de usuários</p> <p className="text-text-muted mt-2">Gestão de usuários e configurações do sistema</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-surface hover:bg-surface-hover border border-border/40 rounded-xl text-text-main font-semibold transition-all disabled:opacity-50" 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"
> >
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} /> <RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
Atualizar Atualizar
@@ -162,18 +124,6 @@ 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(
@@ -305,14 +255,14 @@ export const AdminDashboard: React.FC = () => {
const isActionDisabled = actionLoading === u.id; const isActionDisabled = actionLoading === u.id;
return ( return (
<tr key={u.id} className={`hover:bg-surface-hover transition-colors ${u.isBanned ? 'opacity-60' : ''}`}> <tr key={u.id} className={clsx("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.charAt(0).toUpperCase()} {(u.name || u.email || '?').charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<p className="font-semibold text-text-main">{u.name}</p> <p className="font-semibold text-text-main">{u.name || 'Sem nome'}</p>
{isCurrentUser && ( {isCurrentUser && (
<span className="text-xs text-primary font-medium">(Você)</span> <span className="text-xs text-primary font-medium">(Você)</span>
)} )}
@@ -326,7 +276,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={`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`} 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)}
> >
<option value="guest">Convidado</option> <option value="guest">Convidado</option>
<option value="user">Usuário</option> <option value="user">Usuário</option>
@@ -351,10 +301,11 @@ export const AdminDashboard: React.FC = () => {
<button <button
onClick={() => handleToggleBan(u.id, !u.isBanned)} onClick={() => handleToggleBan(u.id, !u.isBanned)}
disabled={isActionDisabled} disabled={isActionDisabled}
className={`px-4 py-2 rounded-xl text-sm font-semibold transition-all ${u.isBanned className={clsx("px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-sm active:scale-95 disabled:opacity-50",
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30' u.isBanned
: 'bg-red-500/20 text-red-400 hover:bg-red-500/30' ? 'bg-green-500/10 text-green-500 hover:bg-green-500/20 border border-green-500/20'
} disabled:opacity-50`} : 'bg-red-500/10 text-red-500 hover:bg-red-500/20 border border-red-500/20'
)}
> >
{isActionDisabled ? ( {isActionDisabled ? (
<RefreshCw size={16} className="animate-spin" /> <RefreshCw size={16} className="animate-spin" />
@@ -375,16 +326,6 @@ 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' ? (
@@ -392,17 +333,13 @@ 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">Gestão de Estoque</h2> <h2 className="text-xl font-bold text-text-main">Em breve</h2>
<p className="text-text-muted mt-2">Acesse a nova página dedicada ao controle de estoque.</p> <p className="text-text-muted mt-2">Novas configurações serão adicionadas aqui.</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;
+2 -2
View File
@@ -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.userId} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-indigo-500/20"> <div key={admin.id || admin.email} 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.userId} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-border/40"> <div key={user.id || user.email} 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"
+74 -73
View File
@@ -1,115 +1,116 @@
import React, { useState } from "react"; import React, { useState } from 'react';
import { Hammer } from "lucide-react"; import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from "../context/useAuth"; import api from '../services/api';
import { getBaseUrl } from "../services/api"; import { useAuth } from '../context/AuthContext';
import { motion } from 'framer-motion';
const API_URL = getBaseUrl(); const Login: React.FC = () => {
const [email, setEmail] = useState('');
export const Login = () => { const [password, setPassword] = useState('');
const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(false);
const [password, setPassword] = useState(""); const [error, setError] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState(""); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const { refetchUser } = useAuth();
const { login } = useAuth();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setErrorMsg(""); setIsLoading(true);
setLoading(true); setError(null);
try { try {
const response = await fetch(`${API_URL}/auth/login`, { const response = await api.post('/auth/login', { email, password });
method: "POST", const { token } = response.data;
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const data = await response.json(); localStorage.setItem('gpi_token', token);
await refetchUser();
if (!response.ok) { navigate('/');
setErrorMsg(data.error || "Erro ao efetuar login"); } catch (err: any) {
setLoading(false); setError(err.response?.data?.error || 'Erro ao realizar login. Verifique suas credenciais.');
return; } finally {
} 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 w-full flex items-center justify-center bg-surface-soft relative overflow-hidden"> <div className="min-h-screen bg-[#0f172a] flex items-center justify-center p-4">
{/* Background decorative elements */} <motion.div
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/10 rounded-full blur-[120px] animate-pulse" /> initial={{ opacity: 0, y: 20 }}
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-primary/5 rounded-full blur-[120px]" /> animate={{ opacity: 1, y: 0 }}
className="max-w-md w-full"
<div className="relative z-10 w-full max-w-md px-6 flex flex-col items-center"> >
{/* Logo Area */} <div className="bg-slate-800/50 backdrop-blur-xl border border-slate-700/50 p-8 rounded-2xl shadow-2xl">
<div className="mb-8 flex flex-col items-center text-center"> <div className="text-center mb-8">
<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"> <h1 className="text-3xl font-bold text-white mb-2">GPI</h1>
G <p className="text-slate-400">Gestão de Pintura Industrial</p>
</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> </div>
{/* Custom Login Form */} <form onSubmit={handleSubmit} className="space-y-6">
<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"> {error && (
<h2 className="text-xl font-bold text-text-main mb-6 text-center">Entrar na sua conta</h2> <div className="bg-red-500/10 border border-red-500/50 text-red-500 p-3 rounded-lg text-sm">
{error}
{errorMsg && (
<div className="mb-4 p-3 rounded-lg bg-error/10 border border-error/20 text-error text-sm text-center">
{errorMsg}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <div>
<div className="flex flex-col gap-2"> <label className="block text-sm font-medium text-slate-300 mb-1.5">
<label className="text-sm font-semibold text-text-secondary" htmlFor="email">Email</label> E-mail
</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="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" 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@email.com" placeholder="exemplo@gmail.com"
required
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div>
<div className="flex justify-between items-center"> <label className="block text-sm font-medium text-slate-300 mb-1.5">
<label className="text-sm font-semibold text-text-secondary" htmlFor="password">Senha</label> Senha
</div> </label>
<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="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" 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="••••••••" placeholder="••••••••"
required
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={isLoading}
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" 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]"
> >
{loading ? "Entrando..." : "Entrar"} {isLoading ? (
<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 flex items-center gap-2 text-text-muted/60 text-xs font-medium"> <div className="mt-8 text-center">
<Hammer size={14} /> <p className="text-slate-600 text-xs">
<span>© 2026 GPI - Eficiência Industrial</span> &copy; 2026 GPI - Sistema de Gestão Industrial
</div> </p>
</div> </div>
</motion.div>
</div> </div>
); );
}; };
export default Login;
+160 -25
View File
@@ -1,19 +1,98 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useUser, useOrganizationList, useOrganization } from '@clerk/clerk-react';
import { useAuth } from '../context/useAuth'; import { Building2, Users, RefreshCw, Mail } from 'lucide-react';
import { Building2, RefreshCw } from 'lucide-react';
export const OrganizationSelector: React.FC = () => { export const OrganizationSelector: React.FC = () => {
const { appUser, isSignedIn } = useAuth(); const { user } = useUser();
const navigate = useNavigate(); const { setActive, userMemberships, userInvitations } = useOrganizationList({
userMemberships: {
useEffect(() => { infinite: true,
if (isSignedIn && appUser?.organizationId) { },
navigate('/'); userInvitations: {
infinite: true,
} }
}, [isSignedIn, appUser, navigate]); });
const { organization } = useOrganization();
const [isAcceptingInvites, setIsAcceptingInvites] = useState(false);
if (!isSignedIn) { 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(() => {
const acceptPendingInvitations = async () => {
if (userInvitations.data && userInvitations.data.length > 0 && !isAcceptingInvites) {
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]);
const handleSelectOrganization = async (orgId: string) => {
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">
@@ -21,28 +100,84 @@ 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">
Não Conectado Nenhuma Organização
</h1> </h1>
<p className="text-text-muted mb-6"> <p className="text-text-muted mb-6">
Você precisa estar logado para acessar esta página. Você ainda não faz parte de nenhuma organização. Entre em contato com o administrador para receber um convite.
</p> </p>
<button <div className="text-sm text-text-muted bg-surface-soft rounded-lg p-4">
onClick={() => navigate('/login')} <p className="font-semibold mb-1">Conectado como:</p>
className="w-full py-3 bg-primary text-white rounded-xl font-bold" <p>{user?.primaryEmailAddress?.emailAddress}</p>
> </div>
Ir para Login
</button>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-background flex items-center justify-center"> <div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="text-center"> <div className="max-w-2xl w-full">
<RefreshCw className="w-12 h-12 text-primary animate-spin mx-auto mb-4" /> <div className="text-center mb-8">
<p className="text-text-main font-bold mb-2">Redirecionando...</p> <div className="w-16 h-16 rounded-2xl bg-primary/20 flex items-center justify-center mx-auto mb-4">
<p className="text-text-muted text-sm">Carregando sua organização</p> <Building2 className="w-8 h-8 text-primary" />
</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>
); );
+3 -1
View File
@@ -8,6 +8,7 @@ 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';
@@ -51,9 +52,10 @@ 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; const logoUrl = settings?.appLogoUrl || organization?.imageUrl;
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin'; const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
+118
View File
@@ -0,0 +1,118 @@
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">
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;
+3 -1
View File
@@ -7,11 +7,13 @@ 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[]>([]);
@@ -26,7 +28,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; const logoUrl = settings?.appLogoUrl || organization?.imageUrl;
const fetchItems = async () => { const fetchItems = async () => {
setLoading(true); setLoading(true);
+18 -16
View File
@@ -17,13 +17,14 @@ const api = axios.create({
}, },
}); });
let currentToken: string | null = null; // Store the current user's clerk ID and Organization ID/Name
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 JWT token // Function to set the clerk user ID (called from AuthContext)
export const setApiToken = (token: string | null) => { export const setApiClerkUserId = (clerkId: string | null) => {
currentToken = token; currentClerkUserId = clerkId;
}; };
// Function to set the organization ID and Name (called from Layout/Context) // Function to set the organization ID and Name (called from Layout/Context)
@@ -40,22 +41,17 @@ export const setApiOrgId = (orgId: string | null) => {
// Alias for consistency // Alias for consistency
export const setApiOrganizationId = setApiOrgId; export const setApiOrganizationId = setApiOrgId;
// Request interceptor to add clerk user ID and Org ID headers // Request interceptor to add JWT token
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`, { const token = localStorage.getItem('gpi_token');
orgId: currentOrgId if (token) {
}); 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) => {
@@ -63,12 +59,18 @@ api.interceptors.request.use(
} }
); );
// Response interceptor to handle 403 errors (guest access denied) // Response interceptor to handle 401 (Unauthorized) and 403 errors
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();
+3 -3
View File
@@ -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-auth-user-id and x-organization-id headers // Axios interceptors in api.ts automatically handle x-clerk-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;
externalId: string; id: 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
View File
@@ -190,7 +190,7 @@ export type UserRole = 'guest' | 'user' | 'admin';
export interface AppUser { export interface AppUser {
id: string; id: string;
_id?: string; _id?: string;
externalId: string; clerkId: string;
email: string; email: string;
name: string; name: string;
role: UserRole; role: UserRole;
+6 -14
View File
@@ -11,37 +11,29 @@ 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', 'x-organization-id'] allowedHeaders: ['Content-Type', 'Authorization']
})); }));
app.use(express.json()); app.use(express.json());
import { extractUser } from './middleware/roleMiddleware.js'; // JWT Authentication Middleware
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
+39 -19
View File
@@ -1,26 +1,46 @@
import pg from 'pg'; import mongoose from 'mongoose';
const { Pool } = pg; import { GridFSBucket } from 'mongodb';
import dotenv from 'dotenv';
dotenv.config(); export let bucket: GridFSBucket;
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 client = await pool.connect(); const uri = process.env.MONGODB_URI;
console.log('✅ Postgres (Supabase) connected successfully'); if (!uri) {
client.release(); throw new Error('MONGODB_URI is not defined in environment variables');
}
if (mongoose.connection.readyState >= 1) {
console.log('Using existing MongoDB connection');
if (!bucket && mongoose.connection.db) {
bucket = new GridFSBucket(mongoose.connection.db, { bucketName: 'pdfs' });
console.log('✅ GridFS Bucket re-initialized');
}
return;
}
console.log('Connecting to MongoDB...');
if (!uri) console.error('MONGODB_URI is undefined!');
await mongoose.connect(uri, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
});
console.log('✅ MongoDB connected successfully');
const db = mongoose.connection.db;
if (!db) {
throw new Error('Database connection not established');
}
bucket = new GridFSBucket(db, {
bucketName: 'pdfs'
});
console.log('✅ GridFS Bucket initialized');
} catch (error) { } catch (error) {
console.error('❌ Postgres connection error:', error); console.error('❌ MongoDB 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);
-18
View File
@@ -1,18 +0,0 @@
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 { Request, Response } from 'express'; import { Response } from 'express';
import * as appRecordService from '../services/applicationRecordService.js'; import * as appRecordService from '../services/applicationRecordService.js';
import '../middleware/roleMiddleware.js'; // Ensure type augmentation import { AuthRequest } from '../middleware/authMiddleware.js';
export const createApplicationRecord = async (req: Request, res: Response) => { export const createApplicationRecord = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const createdBy = req.appUser?.externalId; const createdBy = req.appUser?._id?.toString();
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: Request, res: Response) => {
} }
}; };
export const getApplicationRecordsByProject = async (req: Request, res: Response) => { export const getApplicationRecordsByProject = async (req: AuthRequest, 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: Request, res: Response
} }
}; };
export const updateApplicationRecord = async (req: Request, res: Response) => { export const updateApplicationRecord = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.externalId; const userId = req.appUser?._id?.toString();
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: Request, res: Response) => {
} }
}; };
export const deleteApplicationRecord = async (req: Request, res: Response) => { export const deleteApplicationRecord = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.externalId; const userId = req.appUser?._id?.toString();
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';
+72 -94
View File
@@ -1,127 +1,105 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { query } from '../config/database.js'; import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid'; import User from '../models/User.js';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod'; const JWT_SECRET = process.env.JWT_SECRET || 'secret';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
export const register = async (req: Request, res: Response): Promise<void> => { export const login = async (req: Request, res: Response) => {
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) {
res.status(400).json({ error: 'Email e senha são obrigatórios' }); return res.status(400).json({ error: 'E-mail e senha são obrigatórios' });
return;
} }
const userRes = await query('SELECT * FROM gpi.users WHERE email = $1', [email]); const user = await User.findOne({ email }).select('+password');
const user = userRes.rows[0];
if (!user) { if (!user || !user.password) {
res.status(400).json({ error: 'Usuário não encontrado' }); return res.status(401).json({ error: 'Credenciais inválidas' });
return;
} }
// Recuperar password_hash de algum lugar se não estiver no select principal (alguns schemas escondem) const isMatch = await bcrypt.compare(password, user.password);
// 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) { if (!isMatch) {
res.status(400).json({ error: 'Credenciais inválidas' }); return res.status(401).json({ error: 'Credenciais inválidas' });
return; }
if (user.isBanned) {
return res.status(403).json({ error: 'Sua conta está bloqueada' });
} }
const token = jwt.sign( const token = jwt.sign(
{ userId: user.id, externalId: user.clerk_id || user.logto_id, role: user.role }, { id: user._id, email: user.email, role: user.role },
JWT_SECRET, JWT_SECRET,
{ expiresIn: '7d' } { expiresIn: JWT_EXPIRES_IN }
); );
res.status(200).json({ // Remove password from user object
message: 'Login realizado com sucesso', const userObj = user.toObject();
delete userObj.password;
res.json({
token, token,
user: { user: userObj
id: user.id,
name: user.name,
email: user.email,
role: user.role,
externalId: user.clerk_id || user.logto_id
}
}); });
} catch (error) { } catch (error) {
console.error('Login Error:', error); console.error('Login error:', error);
res.status(500).json({ error: 'Erro no servidor' }); res.status(500).json({ error: 'Erro interno no servidor' });
} }
}; };
export const getMe = async (req: Request, res: Response): Promise<void> => { export const register = async (req: Request, res: Response) => {
try { try {
if (!req.appUser) { const { email, password, name, organizationId } = req.body;
res.status(401).json({ error: 'Não autorizado' });
return; if (!email || !password || !name) {
return res.status(400).json({ error: 'Campos obrigatórios ausentes' });
} }
res.status(200).json(req.appUser); 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) { } catch (error) {
console.error('GetMe Error:', error); console.error('Registration error:', error);
res.status(500).json({ error: 'Erro no servidor' }); 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) {
return res.status(404).json({ error: 'Usuário não encontrado' });
}
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Erro ao obter dados do usuário' });
} }
}; };
+81 -15
View File
@@ -4,7 +4,6 @@ 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;
@@ -13,7 +12,9 @@ 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';
@@ -31,6 +32,7 @@ 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
@@ -42,6 +44,7 @@ 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;
@@ -55,15 +58,31 @@ export const createDataSheet = async (req: AuthRequest, res: Response) => {
} = req.body; } = req.body;
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
let fileId: string | undefined = undefined; // Note: New logic prefers 'file' upload which we store in DB.
// If fileUrl is provided (legacy or external link), we use that but don't store binary.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let fileId: any = undefined;
let finalFileUrl = fileUrl || ''; 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) {
@@ -71,6 +90,16 @@ 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,
@@ -98,13 +127,14 @@ 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' }
}); });
} }
@@ -120,6 +150,10 @@ 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();
@@ -145,7 +179,8 @@ export const updateDataSheet = async (req: AuthRequest, res: Response) => {
notes, fileUrl notes, fileUrl
} = req.body; } = req.body;
const updates: any = { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const updates: Record<string, any> = {
name, name,
manufacturer, manufacturer,
type, type,
@@ -167,11 +202,23 @@ 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) {
@@ -179,6 +226,12 @@ 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);
@@ -197,14 +250,15 @@ 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 = req.params.id as string; const id_or_filename = req.params.id as string;
// Check if it's a UUID // Check if it's a MongoDB ObjectId (24 hex chars)
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)) { if (/^[0-9a-fA-F]{24}$/.test(id_or_filename)) {
const fileDoc = await fileService.getFileById(id); const { default: StoredFile } = await import('../models/StoredFile.js');
const fileDoc = await StoredFile.findById(id_or_filename);
if (fileDoc) { if (fileDoc) {
res.set('Content-Type', fileDoc.content_type || 'application/pdf'); res.set('Content-Type', fileDoc.contentType || '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');
@@ -212,7 +266,19 @@ export const getFile = async (req: Request, res: Response) => {
} }
} }
// Fallback to file system (legacy)
const stream = dataSheetService.getFileStream(id_or_filename);
stream.on('file', (file) => {
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${file.filename}"`);
});
stream.on('error', () => {
res.status(404).json({ error: 'File not found' }); 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,5 +1,10 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { query } from '../config/database.js'; import GeometryType from '../models/GeometryType.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
interface AuthRequest extends Request {
appUser?: IAppUser;
}
// Default geometry types to seed if none exist // Default geometry types to seed if none exist
const DEFAULT_TYPES = [ const DEFAULT_TYPES = [
@@ -17,110 +22,139 @@ const DEFAULT_TYPES = [
{ name: 'Peças diversas (outras)', efficiencyLoss: 20 } { name: 'Peças diversas (outras)', efficiencyLoss: 20 }
]; ];
export const getAllnames = async (req: Request, res: Response) => { export const getAllnames = async (req: AuthRequest, 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' });
} }
let sql = 'SELECT * FROM gpi.geometry_types'; // Search for org-specific types OR orphan types (legacy)
const params: any[] = []; const query = isGlobalAdmin
? {}
: { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
if (!isGlobalAdmin) { let types = await GeometryType.find(query).sort({ name: 1 });
sql += ' WHERE organization_id = $1';
params.push(organizationId); // Auto-seed if empty AND we HAVE an organization (don't seed for global view)
if (types.length === 0 && organizationId) {
console.log(`[GeometryType] No types found. Seeding defaults...`);
try {
const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId }));
types = await GeometryType.insertMany(seedData) as any;
console.log(`[GeometryType] Seeded ${types.length} types successfully.`);
} catch (seedError) {
console.error('[GeometryType] Seeding failed:', seedError);
return res.json([]);
} }
sql += ' ORDER BY name ASC';
const result = await query(sql, params);
let types = result.rows;
// Auto-seed if empty for org
if (types.length === 0 && organizationId && !isGlobalAdmin) {
for (const t of DEFAULT_TYPES) {
await query(
'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
[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: any) { } catch (error: unknown) {
console.error('[GeometryType] Error:', error); console.error('[GeometryType] Error in getAllnames:', error);
res.status(500).json({ error: error.message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const restoreDefaults = async (req: Request, res: Response) => { export const restoreDefaults = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
if (!organizationId) return res.status(400).json({ error: 'Organization ID missing' }); if (!organizationId) {
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]
);
} }
const result = await query('SELECT * FROM gpi.geometry_types WHERE organization_id = $1 ORDER BY name ASC', [organizationId]); // Delete all existing types for this org
res.json(result.rows); await GeometryType.deleteMany({ organizationId });
} catch (error: any) {
res.status(500).json({ error: error.message }); // Insert defaults
const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId }));
const types = await GeometryType.insertMany(seedData);
res.json(types);
} catch (error: unknown) {
console.error('[GeometryType] Error in restoreDefaults:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const createType = async (req: Request, res: Response) => { export const createType = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const { name, efficiencyLoss } = req.body; const { name, efficiencyLoss } = req.body;
const result = await query( if (!name) {
'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3) RETURNING *', return res.status(400).json({ error: 'Name is required' });
[organizationId, name, Number(efficiencyLoss) || 0] }
);
res.status(201).json(result.rows[0]); const newType = new GeometryType({
} catch (error: any) { name,
if (error.code === '23505') return res.status(409).json({ error: 'Already exists' }); efficiencyLoss: Number(efficiencyLoss) || 0,
res.status(500).json({ error: error.message }); organizationId
});
const saved = await newType.save();
res.status(201).json(saved);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes('E11000')) {
return res.status(409).json({ error: 'A geometry type with this name already exists' });
}
res.status(500).json({ error: message });
} }
}; };
export const updateType = async (req: Request, res: Response) => { export const updateType = 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 isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const { name, efficiencyLoss } = req.body; const { name, efficiencyLoss } = req.body;
const result = await query( const query = isGlobalAdmin
'UPDATE gpi.geometry_types SET name = $1, efficiency_loss = $2, updated_at = NOW() WHERE id = $3 AND organization_id = $4 RETURNING *', ? { _id: id }
[name, Number(efficiencyLoss), id, organizationId] : { _id: id, organizationId };
const updated = await GeometryType.findOneAndUpdate(
query,
{ name, efficiencyLoss: Number(efficiencyLoss) },
{ new: true }
); );
if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' }); if (!updated) {
res.json(result.rows[0]); return res.status(404).json({ error: 'Record not found' });
} 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: Request, res: Response) => { export const deleteType = 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 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: any) { } catch (error: unknown) {
res.status(500).json({ error: error.message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
+10 -16
View File
@@ -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 * as fileService from '../services/fileService.js'; import '../middleware/roleMiddleware.js'; // Ensure type augmentation
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?.externalId; const createdBy = req.appUser?._id?.toString();
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?.externalId; const userId = req.appUser?._id?.toString();
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?.externalId; const userId = req.appUser?._id?.toString();
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,21 +99,15 @@ export const getAllInspections = async (req: Request, res: Response) => {
} }
}; };
export const uploadPhoto = async (req: Request, res: Response) => { export const uploadPhoto = async (req: Request & { file?: any }, 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' });
} }
const buffer = fs.readFileSync(req.file.path); // Return the public URL for the file
const savedFile = await fileService.saveFile(req.file.originalname, req.file.mimetype, buffer); // Assuming 'uploads' is served statically at /uploads
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) {
+55 -8
View File
@@ -1,5 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as instrumentService from '../services/instrumentService.js'; import Instrument from '../models/Instrument.js';
import { IAppUser } from '../middleware/roleMiddleware.js'; import { IAppUser } from '../middleware/roleMiddleware.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
@@ -9,10 +9,33 @@ 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 instrument = await instrumentService.createInstrument({ const { name, type, manufacturer, modelName, serialNumber, calibrationDate, calibrationExpirationDate, certificateUrl, notes } = req.body;
...req.body,
organizationId const existing = await Instrument.findOne({ organizationId, serialNumber });
if (existing) {
return res.status(400).json({ error: 'Já existe um instrumento com este número de série.' });
}
// Determinar status inicial baseado na validade
let status = 'active';
if (calibrationExpirationDate && new Date(calibrationExpirationDate) < new Date()) {
status = 'expired';
}
const instrument = await Instrument.create({
organizationId,
name,
type,
manufacturer,
modelName,
serialNumber,
calibrationDate,
calibrationExpirationDate,
certificateUrl,
status,
notes
}); });
res.status(201).json(instrument); 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';
@@ -24,7 +47,12 @@ 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';
@@ -36,7 +64,24 @@ 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) {
@@ -49,8 +94,10 @@ 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);
if (!success) return res.status(404).json({ error: 'Instrumento não encontrado.' }); const deleted = await Instrument.findOneAndDelete({ _id: id, organizationId });
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';
+139 -91
View File
@@ -1,11 +1,13 @@
import { Request, Response } from 'express'; import { Response } from 'express';
import { query } from '../config/database.js'; import Message from '../models/Message.js';
import OrganizationMember from '../models/OrganizationMember.js';
import { AuthRequest } from '../middleware/authMiddleware.js';
// Send a message // Send a message
export const sendMessage = async (req: Request, res: Response) => { export const sendMessage = async (req: AuthRequest, res: Response) => {
try { try {
const { toUserId, message } = req.body; const { toUserId, message } = req.body;
const fromUserId = req.appUser?.externalId; const fromUserId = req.appUser?._id?.toString();
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId) { if (!organizationId) {
@@ -20,27 +22,36 @@ export const sendMessage = async (req: Request, res: Response) => {
return res.status(400).json({ error: 'Destinatário e mensagem são obrigatórios.' }); return res.status(400).json({ error: 'Destinatário e mensagem são obrigatórios.' });
} }
// Check if there's already a pending (unread) message from this user to that user if (message.length > 255) {
const existingRes = await query( return res.status(400).json({ error: 'Mensagem muito longa (máximo 255 caracteres).' });
'SELECT * FROM gpi.messages WHERE organization_id = $1 AND from_user_id = $2 AND to_user_id = $3 AND is_read = false',
[organizationId, fromUserId, toUserId]
);
if (existingRes.rows.length > 0) {
const updatedRes = await query(
'UPDATE gpi.messages SET message = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
[message, existingRes.rows[0].id]
);
return res.json(updatedRes.rows[0]);
} }
const insertRes = await query( // Check if there's already a pending (unread) message from this user to that user
`INSERT INTO gpi.messages (organization_id, from_user_id, to_user_id, message) const existingMessage = await Message.findOne({
VALUES ($1, $2, $3, $4) RETURNING *`, organizationId,
[organizationId, fromUserId, toUserId, message] fromUserId,
); toUserId,
isRead: false,
});
res.status(201).json(insertRes.rows[0]); if (existingMessage) {
// Update existing message instead of creating a new one
existingMessage.message = message;
existingMessage.updatedAt = new Date();
await existingMessage.save();
return res.json(existingMessage);
}
// Create new message
const newMessage = new Message({
organizationId,
fromUserId,
toUserId,
message,
});
await newMessage.save();
res.status(201).json(newMessage);
} catch (error) { } 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.' });
@@ -48,30 +59,39 @@ export const sendMessage = async (req: Request, res: Response) => {
}; };
// Get unread messages for current user // Get unread messages for current user
export const getUnreadMessages = async (req: Request, res: Response) => { export const getUnreadMessages = async (req: AuthRequest, res: Response) => {
try { try {
const toUserId = req.appUser?.externalId; const toUserId = req.appUser?._id?.toString();
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId || !toUserId) { if (!organizationId) {
return res.status(400).json({ error: 'Contexto incompleto.' }); return res.status(400).json({ error: 'Organização não selecionada.' });
} }
const sql = ` if (!toUserId) {
SELECT m.*, u.name as "fromUserName", u.email as "fromUserEmail" return res.status(401).json({ error: 'Usuário não autenticado.' });
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 = result.rows.map(m => ({ const messages = await Message.find({
...m, organizationId,
fromUser: { name: m.fromUserName, email: m.fromUserEmail } toUserId,
})); isRead: false,
isArchived: false,
isDeletedByRecipient: false,
}).sort({ createdAt: -1 });
res.json(messages); // Populate sender info
const messagesWithSender = await Promise.all(
messages.map(async (msg) => {
const sender = await OrganizationMember.findOne({ userId: msg.fromUserId });
return {
...msg.toObject(),
fromUser: sender ? { name: sender.name, email: sender.email } : null,
};
})
);
res.json(messagesWithSender);
} catch (error) { } 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.' });
@@ -79,22 +99,35 @@ export const getUnreadMessages = async (req: Request, res: Response) => {
}; };
// Mark message as read // Mark message as read
export const markMessageAsRead = async (req: Request, res: Response) => { export const markMessageAsRead = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.externalId; const userId = req.appUser?._id?.toString();
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const result = await query( if (!organizationId) {
'UPDATE gpi.messages SET is_read = true, read_at = NOW() WHERE id = $1 AND organization_id = $2 AND to_user_id = $3 RETURNING *', return res.status(400).json({ error: 'Organização não selecionada.' });
[id, organizationId, userId] }
);
if (result.rows.length === 0) { if (!userId) {
return res.status(401).json({ error: 'Usuário não autenticado.' });
}
const message = await Message.findOne({
_id: id,
organizationId,
toUserId: userId,
});
if (!message) {
return res.status(404).json({ error: 'Mensagem não encontrada.' }); return res.status(404).json({ error: 'Mensagem não encontrada.' });
} }
res.json(result.rows[0]); message.isRead = true;
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.' });
@@ -102,30 +135,37 @@ export const markMessageAsRead = async (req: Request, res: Response) => {
}; };
// Get my pending (unread) sent messages // Get my pending (unread) sent messages
export const getMyPendingMessages = async (req: Request, res: Response) => { export const getMyPendingMessages = async (req: AuthRequest, res: Response) => {
try { try {
const fromUserId = req.appUser?.externalId; const fromUserId = req.appUser?._id?.toString();
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId || !fromUserId) { if (!organizationId) {
return res.status(400).json({ error: 'Contexto incompleto.' }); return res.status(400).json({ error: 'Organização não selecionada.' });
} }
const sql = ` if (!fromUserId) {
SELECT m.*, u.name as "toUserName", u.email as "toUserEmail" return res.status(401).json({ error: 'Usuário não autenticado.' });
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 = result.rows.map(m => ({ const messages = await Message.find({
...m, organizationId,
toUser: { name: m.toUserName, email: m.toUserEmail } fromUserId,
})); isRead: false,
}).sort({ createdAt: -1 });
res.json(messages); // Populate recipient info
const messagesWithRecipient = await Promise.all(
messages.map(async (msg) => {
const recipient = await OrganizationMember.findOne({ userId: msg.toUserId });
return {
...msg.toObject(),
toUser: recipient ? { name: recipient.name, email: recipient.email } : null,
};
})
);
res.json(messagesWithRecipient);
} catch (error) { } 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.' });
@@ -133,21 +173,32 @@ export const getMyPendingMessages = async (req: Request, 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: Request, res: Response) => { export const deleteMessage = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.externalId; const userId = req.appUser?._id?.toString();
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const result = await query( if (!organizationId) {
'DELETE FROM gpi.messages WHERE id = $1 AND from_user_id = $2 AND organization_id = $3 AND is_read = false', return res.status(400).json({ error: 'Organização não selecionada.' });
[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);
@@ -155,40 +206,37 @@ export const deleteMessage = async (req: Request, res: Response) => {
} }
}; };
// Recipient archives a message // Recipient deletes/archives a message
export const archiveMessage = async (req: Request, res: Response) => { export const archiveMessage = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.externalId; const userId = req.appUser?._id?.toString();
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const result = await query( const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
'UPDATE gpi.messages SET is_archived = true, is_read = true WHERE id = $1 AND to_user_id = $2 AND organization_id = $3 RETURNING *', if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
[id, userId, organizationId]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Mensagem não encontrada.' }); message.isArchived = true;
message.isRead = true; // Arquivar implica ler
res.json(result.rows[0]); await message.save();
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: Request, res: Response) => { export const recipientDeleteMessage = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.externalId; const userId = req.appUser?._id?.toString();
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const result = await query( const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
'UPDATE gpi.messages SET is_deleted_by_recipient = true WHERE id = $1 AND to_user_id = $2 AND organization_id = $3 RETURNING *', if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
[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,14 +1,12 @@
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: Request, res: Response) => { getUserNotifications: 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 userId = req.headers['x-user-id'] as string; // Assumindo que temos o ID do usuário (externalId ou email) const userId = req.appUser?._id?.toString() || '';
// 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' });
@@ -26,7 +24,7 @@ export const notificationController = {
} }
}, },
markAsRead: async (req: Request, res: Response) => { markAsRead: async (req: AuthRequest, 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);
@@ -37,10 +35,10 @@ export const notificationController = {
} }
}, },
markAllAsRead: async (req: Request, res: Response) => { markAllAsRead: 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 userId = req.headers['x-user-id'] as string; const userId = req.appUser?._id?.toString() || '';
if (!organizationId) { if (!organizationId) {
return res.status(400).json({ error: 'Organization ID is required' }); return res.status(400).json({ error: 'Organization ID is required' });
@@ -54,10 +52,10 @@ export const notificationController = {
} }
}, },
clearAll: async (req: Request, res: Response) => { clearAll: 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 userId = req.headers['x-user-id'] as string; const userId = req.appUser?._id?.toString() || '';
if (!organizationId) { if (!organizationId) {
return res.status(400).json({ error: 'Organization ID is required' }); return res.status(400).json({ error: 'Organization ID is required' });
@@ -71,10 +69,10 @@ export const notificationController = {
} }
}, },
archive: async (req: Request, res: Response) => { archive: async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.headers['x-user-id'] as string; const userId = req.appUser?._id?.toString() || '';
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) {
@@ -83,10 +81,10 @@ export const notificationController = {
} }
}, },
delete: async (req: Request, res: Response) => { delete: async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.headers['x-user-id'] as string; const userId = req.appUser?._id?.toString() || '';
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) {
+6 -1
View File
@@ -9,8 +9,10 @@ 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';
@@ -72,13 +74,16 @@ 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' }
}); });
} }
+285 -71
View File
@@ -1,5 +1,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as stockItemService from '../services/stockItemService.js'; import StockItem from '../models/StockItem.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';
@@ -12,26 +14,80 @@ 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, rrNumber, batchNumber, quantity, unit, dataSheetId,
expirationDate, notes, color, invoiceNumber, receivedBy, minStock rrNumber,
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.' });
} }
const savedItem = await stockItemService.createStockItem({ // Check for duplicate RR within Org
...req.body, const existing = await StockItem.findOne({ organizationId, rrNumber });
if (existing) {
return res.status(400).json({ error: `Já existe um item com o RR ${rrNumber}.` });
}
// --- Min Stock Inheritance Logic ---
let finalMinStock = Number(minStock) || 0;
// If user didn't provide a specific minStock (or provided 0), try to inherit from existing group
if (finalMinStock === 0) {
const existingGroupItem = await StockItem.findOne({
organizationId, organizationId,
createdBy: req.appUser?.externalId, dataSheetId,
color
}).sort({ updatedAt: -1 }); // Get latest active config
if (existingGroupItem && existingGroupItem.minStock > 0) {
finalMinStock = existingGroupItem.minStock;
}
} else {
// If user DID provide a minStock, update all existing items in that group to match?
// User requested: "a regra de estoque minimo definido no cadastro precisa estar clonado para novos cadastros"
// And "soma dessas 'mesmas' tintas sejam comparadas com o estoque minimo cadastrado a elas"
// This implies the rule is a Property of the Group. So create/update should enforce consistency.
if (finalMinStock > 0) {
await StockItem.updateMany(
{ organizationId, dataSheetId, color },
{ $set: { minStock: finalMinStock } }
);
}
}
const newItem = new StockItem({
organizationId,
createdBy: req.appUser?._id,
dataSheetId,
rrNumber,
batchNumber,
quantity: Number(quantity), quantity: Number(quantity),
minStock: Number(minStock) || 0 unit,
minStock: finalMinStock,
expirationDate,
notes,
color,
invoiceNumber,
receivedBy
}); });
await stockItemService.createStockMovement({ const savedItem = await newItem.save();
// Create Initial Movement (ENTRY)
await StockMovement.create({
organizationId, organizationId,
createdBy: req.appUser?.externalId, createdBy: req.appUser?._id,
stockItemId: savedItem.id, stockItemId: savedItem._id,
movementNumber: 1, movementNumber: 1,
type: 'ENTRY', type: 'ENTRY',
quantity: Number(quantity), quantity: Number(quantity),
@@ -39,19 +95,24 @@ 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.rr_number} (Lote: ${batchNumber}).`, message: `Recebido: ${quantity}${unit} de ${savedItem.rrNumber} (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 });
} }
}; };
@@ -60,16 +121,46 @@ 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;
const { quantity, ...otherData } = req.body; // Only allow updating metadata, NOT quantity directly (quantity must be via adjustments)
// Adjusting logic: Admin might need to fix typo in quantity without movement record?
// Better enforcing movements. If quantity changes, user should use "Adjustment".
// Here we create a general update for details like Notes, Dates, etc.
const { quantity, ...otherData } = req.body; // Separate quantity
if (quantity !== undefined) { 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.' });
} }
const updated = await stockItemService.updateStockItem(id, organizationId!, otherData); // Check if Min Stock is being updated
if (otherData.minStock !== undefined) {
const item = await StockItem.findOne({ _id: id, organizationId });
if (item) {
// Propagate to all siblings (same Product + Color)
await StockItem.updateMany(
{
organizationId,
dataSheetId: item.dataSheetId,
color: item.color
},
{ $set: { minStock: otherData.minStock } }
);
}
}
const updated = await StockItem.findOneAndUpdate(
{ _id: id, organizationId },
otherData,
{ new: true }
);
if (!updated) return res.status(404).json({ error: 'Item não encontrado.' }); 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 });
@@ -81,32 +172,43 @@ 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; const { quantityDelta, reason } = req.body; // quantityDelta: +10 or -5
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' }); if (!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 stockItemService.getStockItemById(id, organizationId!); 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.' });
// 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.' });
await stockItemService.updateStockItemQuantity(id, newQuantity); item.quantity = newQuantity;
await item.save();
const lastNum = await stockItemService.getLatestMovementNumber(id); // Calculate next movement number
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
const count = await StockMovement.countDocuments({ stockItemId: item._id });
const movementNumber = (lastMov?.movementNumber || count) + 1;
await stockItemService.createStockMovement({ // Register Movement
await StockMovement.create({
organizationId, organizationId,
createdBy: req.appUser?.externalId, createdBy: req.appUser?._id,
stockItemId: id, stockItemId: item._id,
movementNumber: lastNum + 1, movementNumber,
type: 'ADJUSTMENT', type: 'ADJUSTMENT',
quantity: Number(quantityDelta), quantity: Number(quantityDelta),
responsible: userName, responsible: userName,
reason reason
}); });
res.json({ ...item, quantity: newQuantity }); // Check Low Stock
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 });
@@ -120,29 +222,40 @@ 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;
const item = await stockItemService.getStockItemById(id, organizationId!); if (!requester) return res.status(400).json({ error: 'Solicitante é obrigatório.' });
if (!quantityConsumed || Number(quantityConsumed) <= 0) return res.status(400).json({ error: 'Quantidade deve ser maior que zero.' });
const item = await StockItem.findOne({ _id: id, organizationId });
if (!item) return res.status(404).json({ error: 'Item não encontrado.' }); if (!item) 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.' });
const newQuantity = item.quantity - Number(quantityConsumed); item.quantity -= Number(quantityConsumed);
await stockItemService.updateStockItemQuantity(id, newQuantity); await item.save();
const lastNum = await stockItemService.getLatestMovementNumber(id); // Calculate next movement number
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
const count = await StockMovement.countDocuments({ stockItemId: item._id });
const movementNumber = (lastMov?.movementNumber || count) + 1;
await stockItemService.createStockMovement({ // Register Movement (Negative quantity for consumption)
await StockMovement.create({
organizationId, organizationId,
createdBy: req.appUser?.externalId, createdBy: req.appUser?._id,
stockItemId: id, stockItemId: item._id,
movementNumber: lastNum + 1, movementNumber,
type: 'CONSUMPTION', type: 'CONSUMPTION',
quantity: -Number(quantityConsumed), quantity: -Number(quantityConsumed), // Negative
responsible: userName, responsible: userName,
requester, requester,
date: date || new Date() date: date || new Date()
}); });
res.json({ ...item, quantity: newQuantity }); // Check Low Stock
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 });
@@ -153,8 +266,17 @@ 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!);
if (!success) return res.status(404).json({ error: 'Item não encontrado.' }); // Optional: Block delete if there are movements other than ENTRY?
// For simplicity allow Admin to nuke it.
const deleted = await StockItem.findOneAndDelete({ _id: id, organizationId });
if (!deleted) return res.status(404).json({ error: 'Item não encontrado.' });
// Cleanup movements & logs
await StockMovement.deleteMany({ stockItemId: id });
await StockAuditLog.deleteMany({ stockItemId: id });
res.status(204).send(); 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';
@@ -166,7 +288,16 @@ 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';
@@ -178,8 +309,12 @@ 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';
@@ -189,9 +324,12 @@ 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; const { id } = req.params; // StockItem ID
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';
@@ -199,41 +337,91 @@ 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; const { id } = req.params; // Movement ID
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User'; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
const userId = req.appUser?.externalId || 'system'; const userId = req.appUser?._id || 'system';
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
const oldMovement = await stockItemService.getStockMovementById(id, organizationId!); if (!isAdmin) {
if (!oldMovement) return res.status(404).json({ error: 'Movimentação não encontrada.' }); return res.status(403).json({ error: 'Apenas administradores podem editar movimentações.' });
}
const item = await stockItemService.getStockItemById(oldMovement.stock_item_id, organizationId!); const { date, quantity, notes } = req.body;
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) return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' }); if (newStockLevel < 0) {
return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
}
await stockItemService.updateStockItemQuantity(item.id, newStockLevel); item.quantity = newStockLevel;
await item.save();
await stockItemService.createAuditLog({ // 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: id, movementId: movement._id,
movementNumber: oldMovement.movement_number, movementNumber: movement.movementNumber,
userId, userId,
userName, userName,
action: 'UPDATE', action: 'UPDATE',
details: `Edição de Movimentação: Qtd ${oldMovement.quantity} -> ${req.body.quantity}`, details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`,
oldValues: oldMovement, oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes },
newValues: req.body newValues: { date, quantity: newQuantitySigned, notes }
}); });
const updated = await stockItemService.updateStockMovement(id, organizationId!, req.body); // Update Movement
res.json(updated); movement.quantity = newQuantitySigned;
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 });
} }
}; };
@@ -243,43 +431,69 @@ 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?.externalId || 'system'; const userId = req.appUser?._id || 'system';
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
const movement = await stockItemService.getStockMovementById(id, organizationId!); if (!isAdmin) {
return res.status(403).json({ error: 'Apenas administradores podem excluir movimentações.' });
}
const movement = await StockMovement.findOne({ _id: id, organizationId });
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' }); if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
const item = await stockItemService.getStockItemById(movement.stock_item_id, organizationId!); const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
const newStockLevel = Number(item.quantity) - Number(movement.quantity); if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
if (newStockLevel < 0) return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' }); // Reverse the effect
// If we delete an Entry (+10), we MUST subtract 10 from Item.
// If we delete a Consumption (-10), we MUST add 10 (subtract -10) to Item.
// So: Item.quantity -= movement.quantity
await stockItemService.updateStockItemQuantity(item.id, newStockLevel); const reverseQty = Number(movement.quantity);
const newStockLevel = Number(item.quantity) - reverseQty;
await stockItemService.createAuditLog({ if (newStockLevel < 0) {
return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
}
item.quantity = newStockLevel;
await item.save();
// Audit Log
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
const typeLabel = typeMap[movement.type] || movement.type;
await StockAuditLog.create({
organizationId, organizationId,
stockItemId: item.id, stockItemId: item._id,
movementId: id, movementId: movement._id,
movementNumber: movement.movement_number, movementNumber: movement.movementNumber,
userId, userId,
userName, userName,
action: 'DELETE', action: 'DELETE',
details: `Exclusão de Movimentação: Qtd ${movement.quantity}`, details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`,
oldValues: movement oldValues: movement.toObject()
}); });
await stockItemService.deleteStockMovement(id, organizationId!); await StockMovement.deleteOne({ _id: 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';
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; const { id } = req.params; // StockItem ID
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,20 +1,24 @@
import { Request, Response } from 'express'; import { Response } from 'express';
import { query } from '../config/database.js'; import SystemSettings from '../models/SystemSettings.js';
import User from '../models/User.js';
import OrganizationMember from '../models/OrganizationMember.js';
import Organization from '../models/Organization.js';
import path from 'path'; import 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: Request, res: Response) => { export const getSettings = async (req: AuthRequest, res: Response) => {
try { try {
const resSettings = await query('SELECT * FROM gpi.system_settings WHERE settings_id = $1', ['global']); let settings = await SystemSettings.findOne({ settingsId: 'global' });
let settings = resSettings.rows[0];
if (!settings) { if (!settings) {
const insertRes = await query( // Create default if not exists
'INSERT INTO gpi.system_settings (settings_id, app_name, app_subtitle) VALUES ($1, $2, $3) RETURNING *', settings = await SystemSettings.create({
['global', 'GPI', 'Gestão de Pintura Industrial'] settingsId: 'global',
); appName: 'GPI',
settings = insertRes.rows[0]; appSubtitle: 'Gestão de Pintura Industrial'
});
} }
res.json(settings); res.json(settings);
@@ -24,38 +28,45 @@ export const getSettings = async (req: Request, res: Response) => {
} }
}; };
export const updateSettings = async (req: Request, res: Response) => { export const updateSettings = async (req: AuthRequest, res: Response) => {
try { try {
const { appName, appSubtitle, appLogoUrl } = req.body; const { appName, appSubtitle, appLogoUrl } = req.body;
const result = await query( const settings = await SystemSettings.findOneAndUpdate(
`INSERT INTO gpi.system_settings (settings_id, app_name, app_subtitle, app_logo_url, updated_by, updated_at) { settingsId: 'global' },
VALUES ('global', $1, $2, $3, $4, NOW()) {
ON CONFLICT (settings_id) DO UPDATE SET appName,
app_name = EXCLUDED.app_name, appSubtitle,
app_subtitle = EXCLUDED.app_subtitle, appLogoUrl,
app_logo_url = EXCLUDED.app_logo_url, updatedBy: req.appUser?.email
updated_by = EXCLUDED.updated_by, },
updated_at = NOW() { new: true, upsert: true } // Create if not exists
RETURNING *`,
[appName, appSubtitle, appLogoUrl, req.appUser?.email]
); );
res.json(result.rows[0]); console.log(`⚙️ System Settings updated by ${req.appUser?.email}`);
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(localPath)) { if (fs.existsSync(tmpPath)) {
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) {
@@ -64,12 +75,16 @@ export const serveLogo = async (req: Request, res: Response) => {
} }
}; };
export const uploadLogo = async (req: Request, res: Response) => { export const uploadLogo = async (req: AuthRequest & { file?: any }, 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);
@@ -77,39 +92,71 @@ export const uploadLogo = async (req: Request, res: Response) => {
} }
}; };
export const getGlobalUsers = async (req: Request, res: Response) => { // Global Admin Functions
export const getGlobalUsers = async (req: AuthRequest, res: Response) => {
try { try {
const resUsers = await query('SELECT * FROM gpi.users ORDER BY created_at DESC'); const users = await User.find({}).sort({ createdAt: -1 });
res.json(resUsers.rows); res.json(users);
} 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: Request, res: Response) => { export const getGlobalOrganizations = async (req: AuthRequest, res: Response) => {
try { try {
const sql = ` // Aggregate members to group by org and get full member lists
SELECT const organizations = await OrganizationMember.aggregate([
o.id as _id, {
o.name, $group: {
o.is_banned as "isBanned", _id: '$organizationId',
COUNT(uo.user_id) as "memberCount", members: {
MAX(uo.updated_at) as "lastActive" $push: {
FROM gpi.organizations o id: '$userId',
LEFT JOIN gpi.user_organizations uo ON o.id = uo.organization_id name: '$name',
GROUP BY o.id, o.name, o.is_banned email: '$email',
ORDER BY "memberCount" DESC role: '$role',
`; 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: Request, res: Response) => { export const toggleOrganizationBan = async (req: AuthRequest, res: Response) => {
try { try {
const { organizationId, isBanned } = req.body; const { organizationId, isBanned } = req.body;
@@ -117,12 +164,15 @@ export const toggleOrganizationBan = async (req: Request, res: Response) => {
return res.status(400).json({ error: 'ID da organização é obrigatório.' }); return res.status(400).json({ error: 'ID da organização é obrigatório.' });
} }
const resOrg = await query( // Upsert the Organization record
'UPDATE gpi.organizations SET is_banned = $1, updated_at = NOW() WHERE id = $2 RETURNING *', const org = await Organization.findOneAndUpdate(
[isBanned, organizationId] { organizationId: organizationId },
{ isBanned: isBanned },
{ new: true, upsert: true }
); );
res.json(resOrg.rows[0]); console.log(`Organization ${organizationId} ban status set to ${isBanned} by ${req.appUser?.email}`);
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.' });
+80 -44
View File
@@ -1,33 +1,9 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as userService from '../services/userService.js'; import User, { IUser } from '../models/User.js';
import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js';
interface AuthRequest extends Request { import { AuthRequest } from '../middleware/authMiddleware.js';
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) {
@@ -35,19 +11,31 @@ 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 (!user) { if (organizationId) {
return res.status(404).json({ error: 'Usuário não encontrado.' }); const member = await OrganizationMember.findOne({
userId: req.appUser._id.toString(),
organizationId
});
if (member) {
return res.json({
...req.appUser.toObject(),
role: member.role,
isBanned: member.isBanned,
organizationId
});
}
} }
res.json(user); res.json(req.appUser);
} 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;
@@ -56,7 +44,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 userService.getAllUsersInOrg(organizationId); const members = await OrganizationMember.find({ organizationId }).sort({ createdAt: -1 });
res.json(members); res.json(members);
} catch (error) { } catch (error) {
console.error('Error getting users:', error); console.error('Error getting users:', error);
@@ -64,6 +52,7 @@ 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;
@@ -74,18 +63,34 @@ 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.' });
} }
const member = await userService.updateUserRole(id, organizationId, role); if (!['guest', 'user', 'admin'].includes(role)) {
if (!member) { return res.status(400).json({ error: 'Role inválido. Use: guest, user ou admin.' });
}
const member = await OrganizationMember.findById(id);
if (!member || member.organizationId !== organizationId) {
return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' }); 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 alterar role.' }); res.status(500).json({ error: 'Erro ao atualizar 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;
@@ -96,11 +101,24 @@ 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 userService.toggleBanUser(id, organizationId, isBanned); const member = await OrganizationMember.findById(id);
if (!member) { 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 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);
@@ -108,12 +126,14 @@ 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);
@@ -121,16 +141,26 @@ 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 activeUsers = await userService.getActiveUsers(organizationId, currentUserId); const members = await OrganizationMember.find({ organizationId });
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);
@@ -138,6 +168,7 @@ 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;
@@ -147,15 +178,20 @@ 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 success = await userService.deleteUserFromOrg(id, organizationId); const result = await OrganizationMember.findOneAndDelete({ _id: id, organizationId });
if (!success) { if (!result) {
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.' }); res.json({ message: 'Membro removido com sucesso.', deletedMember: result });
} catch (error) { } catch (error) {
console.error('Error deleting user:', error); console.error('Error deleting user:', error);
res.status(500).json({ error: 'Erro ao remover membro.' }); res.status(500).json({ error: 'Erro ao remover membro.' });
} }
}; };
// 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.' });
};
+9 -1
View File
@@ -1,6 +1,8 @@
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();
@@ -12,6 +14,8 @@ 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...');
@@ -20,7 +24,11 @@ const startServer = async () => {
}, 24 * 60 * 60 * 1000); }, 24 * 60 * 60 * 1000);
// Executar uma vez no início para garantir (opcional, bom para dev) // Executar uma vez no início para garantir (opcional, bom para dev)
// notificationService.checkStockExpirations(); notificationService.checkStockExpirations();
} else {
console.warn('⚠️ MongoDB is not connected. Skipping migrations.');
}
}); });
} catch (error) { } catch (error) {
console.error('Failed to start server:', error); console.error('Failed to start server:', error);
-26
View File
@@ -1,26 +0,0 @@
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' });
}
};
+38
View File
@@ -0,0 +1,38 @@
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' });
}
};
+36 -85
View File
@@ -1,107 +1,38 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { query } from '../config/database.js'; import User, { IUser } from '../models/User.js';
import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js';
import Organization from '../models/Organization.js';
export type UserRole = 'guest' | 'user' | 'admin'; // Extended user info with organization context
export type OrgRole = 'guest' | 'user' | 'admin'; export interface IAppUser extends IUser {
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;
} }
export const extractUser = async (req: Request, res: Response, next: NextFunction) => { // Module augmentation for Express Request
try { declare module 'express-serve-static-core' {
const externalId = req.headers['x-auth-user-id'] as string; interface Request {
const organizationId = req.headers['x-organization-id'] as string; appUser?: IAppUser;
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; /**
} * Middleware to require specific roles for a route
* @param allowedRoles Array of roles that can access the route
next(); */
} catch (error) {
console.error('Error extracting user:', error);
next();
}
};
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)) {
@@ -112,26 +43,46 @@ 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.' }); return res.status(403).json({ error: 'Convidados não podem editar. Solicite acesso ao administrador.' });
} }
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();
}; };
+47
View File
@@ -0,0 +1,47 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IApplicationRecord extends Document {
organizationId?: string;
createdBy?: string;
projectId: mongoose.Types.ObjectId;
coatStage: string;
pieceDescription?: string | null;
date?: Date | null;
operator?: string | null;
realWeight?: number | null;
volumeUsed?: number | null;
areaPainted?: number | null;
wetThicknessAvg?: number | null;
dryThicknessCalc?: number | null;
method?: string | null;
diluentUsed?: number | null;
notes?: string | null;
items?: {
partId: mongoose.Types.ObjectId;
quantity: number;
}[];
}
const ApplicationRecordSchema: Schema = new Schema({
organizationId: { type: String, index: true },
createdBy: { type: String, index: true },
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
coatStage: { type: String, required: true },
pieceDescription: { type: String }, // Can be auto-generated or manual name for the Batch
date: { type: Date },
operator: { type: String },
realWeight: { type: Number },
volumeUsed: { type: Number },
areaPainted: { type: Number },
wetThicknessAvg: { type: Number },
dryThicknessCalc: { type: Number },
method: { type: String },
diluentUsed: { type: Number },
notes: { type: String },
items: [{
partId: { type: Schema.Types.ObjectId, ref: 'Part' },
quantity: { type: Number, required: true }
}]
}, { timestamps: true });
export default mongoose.models.ApplicationRecord || mongoose.model<IApplicationRecord>('ApplicationRecord', ApplicationRecordSchema);
+22
View File
@@ -0,0 +1,22 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface IGeometryType extends Document {
name: string;
efficiencyLoss: number; // Percentage, e.g., 10 for 10%
organizationId: string;
createdAt: Date;
updatedAt: Date;
}
const GeometryTypeSchema: Schema = new Schema({
name: { type: String, required: true },
efficiencyLoss: { type: Number, required: true, default: 0 },
organizationId: { type: String, required: true, index: true },
}, {
timestamps: true
});
// Compound index to ensure unique names per organization
GeometryTypeSchema.index({ organizationId: 1, name: 1 }, { unique: true });
export default mongoose.model<IGeometryType>('GeometryType', GeometryTypeSchema);
+73
View File
@@ -0,0 +1,73 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IInspection extends Document {
organizationId?: string;
createdBy?: string; // Clerk User ID
projectId: mongoose.Types.ObjectId;
type: 'painting' | 'surface_treatment';
// Common
date?: Date | null;
inspector?: string | null;
appearance?: 'approved' | 'rejected' | 'notes' | null; // Unified status
defects?: string | null; // Observations
photos?: string[]; // URLs
partTemperature?: number | null;
weightKg?: number | null;
// Painting Specific
pieceDescription?: string | null;
epsPoints?: (number | null)[];
adhesionTest?: string | null;
// Surface Treatment Specific
batch?: string | null; // Lote
treatmentExecutor?: string | null;
treatmentType?: string | null; // Jateamento, Mecânica...
cleaningDegree?: string | null; // Sa 2.5, St 3...
roughnessReadings?: (number | null)[]; // 5 measurements
flashRust?: string | null;
temperature?: number | null;
relativeHumidity?: number | null;
period?: 'morning' | 'afternoon' | 'night' | null;
applicationRecordId?: mongoose.Types.ObjectId; // Link to specific painting batch
stockItemId?: mongoose.Types.ObjectId; // Link to Stock Item (Paint used)
instrumentId?: mongoose.Types.ObjectId; // Link to Instrument used
}
const InspectionSchema: Schema = new Schema({
organizationId: { type: String, index: true },
createdBy: { type: String, index: true },
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
applicationRecordId: { type: Schema.Types.ObjectId, ref: 'ApplicationRecord' },
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem' },
instrumentId: { type: Schema.Types.ObjectId, ref: 'Instrument' },
type: { type: String, enum: ['painting', 'surface_treatment'], default: 'painting', index: true },
// Common
date: { type: Date },
inspector: { type: String },
appearance: { type: String }, // approved, rejected, notes
defects: { type: String },
photos: [{ type: String }],
partTemperature: { type: Number },
weightKg: { type: Number },
// Painting
pieceDescription: { type: String },
epsPoints: [{ type: Number }],
adhesionTest: { type: String },
// Surface Treatment
batch: { type: String },
treatmentExecutor: { type: String },
treatmentType: { type: String },
cleaningDegree: { type: String },
roughnessReadings: [{ type: Number }],
flashRust: { type: String },
temperature: { type: Number },
relativeHumidity: { type: Number },
period: { type: String },
}, { timestamps: true });
export default mongoose.models.Inspection || mongoose.model<IInspection>('Inspection', InspectionSchema);
+40
View File
@@ -0,0 +1,40 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IInstrument extends Document {
organizationId: string;
name: string;
type: string; // Ex: Medidor de Camada, Termo-higrômetro
manufacturer?: string;
modelName?: string;
serialNumber: string;
calibrationDate?: Date;
calibrationExpirationDate?: Date;
certificateUrl?: string; // URL do PDF
status: 'active' | 'inactive' | 'maintenance' | 'expired';
notes?: string;
createdAt: Date;
updatedAt: Date;
}
const InstrumentSchema: Schema = new Schema({
organizationId: { type: String, required: true, index: true },
name: { type: String, required: true },
type: { type: String, required: true },
manufacturer: { type: String },
modelName: { type: String },
serialNumber: { type: String, required: true },
calibrationDate: { type: Date },
calibrationExpirationDate: { type: Date },
certificateUrl: { type: String },
status: {
type: String,
enum: ['active', 'inactive', 'maintenance', 'expired'],
default: 'active'
},
notes: { type: String }
}, { timestamps: true });
// Index para evitar duplicidade de número de série dentro da mesma organização
InstrumentSchema.index({ organizationId: 1, serialNumber: 1 }, { unique: true });
export default mongoose.models.Instrument || mongoose.model<IInstrument>('Instrument', InstrumentSchema);
+63
View File
@@ -0,0 +1,63 @@
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);
+32
View File
@@ -0,0 +1,32 @@
import mongoose, { Schema, Document } from 'mongoose';
export type NotificationType = 'info' | 'warning' | 'error' | 'success';
export interface INotification extends Document {
organizationId: string;
recipientId?: string; // Se null, é para todos da organização
title: string;
message: string;
type: NotificationType;
isRead: boolean;
isArchived: boolean;
archivedBy: string[]; // IDs dos usuários que arquivaram (para notificações globais)
deletedBy: string[]; // IDs dos usuários que deletaram (para notificações globais)
metadata?: any; // Para guardar IDs de projetos, itens, etc.
createdAt: Date;
}
const NotificationSchema: Schema = new Schema({
organizationId: { type: String, required: true, index: true },
recipientId: { type: String, index: true }, // Opcional
title: { type: String, required: true },
message: { type: String, required: true },
type: { type: String, enum: ['info', 'warning', 'error', 'success'], default: 'info' },
isRead: { type: Boolean, default: false },
isArchived: { type: Boolean, default: false },
archivedBy: [{ type: String }],
deletedBy: [{ type: String }],
metadata: { type: Schema.Types.Mixed },
}, { timestamps: true });
export default mongoose.models.Notification || mongoose.model<INotification>('Notification', NotificationSchema);
+17
View File
@@ -0,0 +1,17 @@
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);
+52
View File
@@ -0,0 +1,52 @@
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);
+54
View File
@@ -0,0 +1,54 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IPaintingScheme extends Document {
projectId: mongoose.Types.ObjectId;
name: string;
type?: string | null;
coat?: string | null;
solidsVolume?: number | null;
yieldTheoretical?: number | null;
epsMin?: number | null;
epsMax?: number | null;
dilution?: number | null;
manufacturer?: string | null;
color?: string | null;
notes?: string | null;
organizationId?: string;
// Consumption Planning
paintConsumption?: number | null;
thinnerConsumption?: number | null;
paintId?: mongoose.Types.ObjectId | null; // Ref to TechnicalDataSheet
thinnerId?: mongoose.Types.ObjectId | null; // Ref to TechnicalDataSheet
preferredStockItemId?: mongoose.Types.ObjectId | null; // Ref to StockItem (Suggested Batch)
}
const PaintingSchemeSchema: Schema = new Schema({
organizationId: { type: String, index: true },
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
name: { type: String, required: true },
type: { type: String },
coat: { type: String },
solidsVolume: { type: Number },
yieldTheoretical: { type: Number },
epsMin: { type: Number },
epsMax: { type: Number },
dilution: { type: Number },
manufacturer: { type: String },
color: { type: String },
notes: { type: String },
// Consumption Planning
paintConsumption: { type: Number },
thinnerConsumption: { type: Number },
paintId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet' },
thinnerId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet' },
preferredStockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem' }
}, { strict: false });
console.log("✅✅✅ PAINTING SCHEME MODEL (WITH CONSUMPTION) LOADED ✅✅✅");
// Force model recompilation to ensure schema updates are applied
if (mongoose.models.PaintingScheme) {
delete mongoose.models.PaintingScheme;
}
export default mongoose.model<IPaintingScheme>('PaintingScheme', PaintingSchemeSchema);
+29
View File
@@ -0,0 +1,29 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IPart extends Document {
projectId?: mongoose.Types.ObjectId;
description: string;
dimensions?: string | null;
weight?: number | null;
type?: string | null;
area?: number | null;
complexity?: number | null;
quantity: number;
notes?: string | null;
organizationId?: string;
}
const PartSchema: Schema = new Schema({
organizationId: { type: String, index: true },
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: false },
description: { type: String, required: true },
dimensions: { type: String },
weight: { type: Number },
type: { type: String },
area: { type: Number },
complexity: { type: Number },
quantity: { type: Number, required: true, default: 1 },
notes: { type: String },
});
export default mongoose.models.Part || mongoose.model<IPart>('Part', PartSchema);
+29
View File
@@ -0,0 +1,29 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IProject extends Document {
name: string;
client: string;
startDate?: Date | null;
endDate?: Date | null;
technician?: string | null;
environment?: string | null;
organizationId?: string;
weightKg?: number | null;
status: 'active' | 'archived';
createdAt: Date;
updatedAt: Date;
}
const ProjectSchema: Schema = new Schema({
name: { type: String, required: true },
client: { type: String, required: true },
organizationId: { type: String, index: true },
startDate: { type: Date },
endDate: { type: Date },
technician: { type: String },
environment: { type: String },
weightKg: { type: Number },
status: { type: String, enum: ['active', 'archived'], default: 'active', index: true },
}, { timestamps: true });
export default mongoose.models.Project || mongoose.model<IProject>('Project', ProjectSchema);
+31
View File
@@ -0,0 +1,31 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IStockAuditLog extends Document {
organizationId?: string;
stockItemId: mongoose.Types.ObjectId;
movementId?: mongoose.Types.ObjectId; // Optional, might be deleted
movementNumber?: number;
userId: string;
userName: string;
action: 'CREATE' | 'UPDATE' | 'DELETE';
details: string; // Human readable summary
oldValues?: Record<string, any>;
newValues?: Record<string, any>;
timestamp: Date;
}
const StockAuditLogSchema: Schema = new Schema({
organizationId: { type: String, index: true },
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem', required: true },
movementId: { type: Schema.Types.ObjectId, ref: 'StockMovement' },
movementNumber: { type: Number },
userId: { type: String, required: true },
userName: { type: String, required: true },
action: { type: String, required: true, enum: ['CREATE', 'UPDATE', 'DELETE'] },
details: { type: String, required: true },
oldValues: { type: Object },
newValues: { type: Object },
timestamp: { type: Date, default: Date.now }
}, { timestamps: true });
export default mongoose.models.StockAuditLog || mongoose.model<IStockAuditLog>('StockAuditLog', StockAuditLogSchema);
+43
View File
@@ -0,0 +1,43 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IStockItem extends Document {
organizationId?: string;
createdBy?: string;
dataSheetId: mongoose.Types.ObjectId;
rrNumber: string; // Registro de Rastreabilidade
batchNumber: string; // Lote
color?: string;
invoiceNumber?: string; // Nota Fiscal
receivedBy?: string; // Quem recebeu
quantity: number;
unit: string;
minStock?: number; // Estoque mínimo estipulado
expirationDate?: Date;
entryDate: Date;
notes?: string;
createdAt: Date;
updatedAt: Date;
}
const StockItemSchema: Schema = new Schema({
organizationId: { type: String, index: true },
createdBy: { type: String, index: true },
dataSheetId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet', required: true },
rrNumber: { type: String, required: true },
batchNumber: { type: String, required: true },
color: { type: String },
invoiceNumber: { type: String },
receivedBy: { type: String },
quantity: { type: Number, required: true, default: 0 },
unit: { type: String, required: true },
minStock: { type: Number, default: 0 },
expirationDate: { type: Date },
entryDate: { type: Date, default: Date.now },
notes: { type: String }
}, { timestamps: true });
// Compound index to prevent duplicate RR within an organization, if desirable.
// For now, indexing RR for fast lookup.
StockItemSchema.index({ organizationId: 1, rrNumber: 1 });
export default mongoose.models.StockItem || mongoose.model<IStockItem>('StockItem', StockItemSchema);
+34
View File
@@ -0,0 +1,34 @@
import mongoose, { Schema, Document } from 'mongoose';
export type MovementType = 'ENTRY' | 'ADJUSTMENT' | 'CONSUMPTION';
export interface IStockMovement extends Document {
organizationId?: string;
createdBy?: string;
stockItemId: mongoose.Types.ObjectId;
movementNumber?: number;
type: MovementType;
quantity: number; // Positive for entry, negative for exit
date: Date;
responsible: string; // User who performed the action
reason?: string; // For ADJUSTMENT
requester?: string; // For CONSUMPTION
notes?: string;
createdAt: Date;
}
const StockMovementSchema: Schema = new Schema({
organizationId: { type: String, index: true },
createdBy: { type: String, index: true },
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem', required: true },
movementNumber: { type: Number },
type: { type: String, enum: ['ENTRY', 'ADJUSTMENT', 'CONSUMPTION'], required: true },
quantity: { type: Number, required: true },
date: { type: Date, default: Date.now },
responsible: { type: String, required: true },
reason: { type: String },
requester: { type: String },
notes: { type: String }
}, { timestamps: true });
export default mongoose.models.StockMovement || mongoose.model<IStockMovement>('StockMovement', StockMovementSchema);
+19
View File
@@ -0,0 +1,19 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IStoredFile extends Document {
filename: string;
contentType: string;
data: Buffer;
size: number;
uploadDate: Date;
}
const StoredFileSchema: Schema = new Schema({
filename: { type: String, required: true },
contentType: { type: String, required: true },
data: { type: Buffer, required: true },
size: { type: Number, required: true },
uploadDate: { type: Date, default: Date.now }
});
export default mongoose.models.StoredFile || mongoose.model<IStoredFile>('StoredFile', StoredFileSchema);
+19
View File
@@ -0,0 +1,19 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface ISystemSettings extends Document {
settingsId: string;
appName: string;
appSubtitle: string;
appLogoUrl?: string;
updatedBy?: string;
}
const SystemSettingsSchema: Schema = new Schema({
settingsId: { type: String, required: true, unique: true, default: 'global' },
appName: { type: String, required: true, default: 'GPI' },
appSubtitle: { type: String, required: true, default: 'Gestão de Pintura Industrial' },
appLogoUrl: { type: String },
updatedBy: { type: String } // Email of the dev who updated it
}, { timestamps: true });
export default mongoose.models.SystemSettings || mongoose.model<ISystemSettings>('SystemSettings', SystemSettingsSchema);
+59
View File
@@ -0,0 +1,59 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface ITechnicalDataSheet extends Document {
name: string;
manufacturer?: string;
type?: string;
fileId?: mongoose.Types.ObjectId;
fileUrl: string;
uploadDate: Date;
solidsVolume?: number;
density?: number;
mixingRatio?: string;
mixingRatioWeight?: string;
mixingRatioVolume?: string;
wftMin?: number;
wftMax?: number;
dftMin?: number;
dftMax?: number;
reducer?: string;
yieldTheoretical?: number;
dftReference?: number;
yieldFactor?: number;
dilution?: number;
notes?: string;
organizationId?: string;
manufacturerCode?: string;
minStock?: number;
typicalApplication?: string;
}
const TechnicalDataSheetSchema: Schema = new Schema({
organizationId: { type: String, index: true },
name: { type: String, required: true },
manufacturer: { type: String },
manufacturerCode: { type: String },
type: { type: String },
minStock: { type: Number },
typicalApplication: { type: String },
fileId: { type: Schema.Types.ObjectId, ref: 'StoredFile' },
fileUrl: { type: String },
uploadDate: { type: Date, default: Date.now },
solidsVolume: { type: Number },
density: { type: Number },
mixingRatio: { type: String },
mixingRatioWeight: { type: String },
mixingRatioVolume: { type: String },
wftMin: { type: Number },
wftMax: { type: Number },
dftMin: { type: Number },
dftMax: { type: Number },
reducer: { type: String },
yieldTheoretical: { type: Number },
dftReference: { type: Number },
yieldFactor: { type: Number },
dilution: { type: Number },
notes: { type: String },
}, { timestamps: true });
export default mongoose.models.TechnicalDataSheet || mongoose.model<ITechnicalDataSheet>('TechnicalDataSheet', TechnicalDataSheetSchema);
+60
View File
@@ -0,0 +1,60 @@
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);
+53
View File
@@ -0,0 +1,53 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IPieceCategory {
id: string; // Keep as string for internal mapping if needed, or convert to Sub-document
name: string;
organizationId?: string;
weight: number;
area?: number; // Área em m² para cálculo alternativo
historicalYield: number;
historicalDft: number;
efficiency: number;
}
const PieceCategorySchema: Schema = new Schema({
name: { type: String, required: true },
weight: { type: Number, required: true },
area: { type: Number }, // Área em m² (opcional)
historicalYield: { type: Number, required: true },
historicalDft: { type: Number, required: true },
efficiency: { type: Number, required: true },
});
export interface IYieldStudy extends Document {
name: string;
organizationId?: string;
dataSheetId: mongoose.Types.ObjectId;
targetDft: number;
dilutionPercent: number;
categories: IPieceCategory[];
totalWeight: number;
estimatedPaintVolume: number;
estimatedReducerVolume: number;
estimatedPaintVolumeByArea?: number; // Cálculo por área (m²)
estimatedReducerVolumeByArea?: number; // Cálculo por área (m²)
averageComplexity: number;
}
const YieldStudySchema: Schema = new Schema({
name: { type: String, required: true },
organizationId: { type: String, index: true },
dataSheetId: { type: Schema.Types.ObjectId, ref: 'TechnicalDataSheet', required: true },
targetDft: { type: Number, required: true },
dilutionPercent: { type: Number, default: 0 },
categories: [PieceCategorySchema],
totalWeight: { type: Number },
estimatedPaintVolume: { type: Number },
estimatedReducerVolume: { type: Number },
estimatedPaintVolumeByArea: { type: Number }, // Cálculo por área
estimatedReducerVolumeByArea: { type: Number }, // Cálculo por área
averageComplexity: { type: Number },
}, { timestamps: true });
export default mongoose.models.YieldStudy || mongoose.model<IYieldStudy>('YieldStudy', YieldStudySchema);
+7 -6
View File
@@ -1,10 +1,11 @@
import express from 'express'; import { Router } from 'express';
import { login, register, getMe } from '../controllers/authController.js'; import * as authController from '../controllers/authController.js';
import { authenticateJWT } from '../middleware/authMiddleware.js';
const router = express.Router(); const router = Router();
router.post('/login', login); router.post('/login', authController.login);
router.post('/register', register); router.post('/register', authController.register);
router.get('/me', getMe); router.get('/me', authenticateJWT, authController.getMe);
export default router; export default router;
+10 -10
View File
@@ -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 { extractUser, requireAdmin } from '../middleware/roleMiddleware.js'; import { requireAdmin, requireUser } from '../middleware/roleMiddleware.js';
const router = express.Router(); const router = express.Router();
// Sync user from Auth (public - called on login) // Sync user (placeholder)
router.post('/sync', syncUser); router.post('/sync', syncUser);
// Get current user (requires extractUser middleware) // Get current user
router.get('/me', extractUser, getCurrentUser); router.get('/me', requireUser, getCurrentUser);
// Heartbeat & Presence // Heartbeat & Presence
router.post('/heartbeat', extractUser, heartbeat); router.post('/heartbeat', requireUser, heartbeat);
router.get('/active', extractUser, getActiveUsers); router.get('/active', requireUser, getActiveUsers);
// Admin-only routes // Admin-only routes
router.get('/', extractUser, requireAdmin, getAllUsers); router.get('/', requireUser, requireAdmin, getAllUsers);
router.patch('/:id/role', extractUser, requireAdmin, updateUserRole); router.patch('/:id/role', requireUser, requireAdmin, updateUserRole);
router.patch('/:id/ban', extractUser, requireAdmin, toggleBanUser); router.patch('/:id/ban', requireUser, requireAdmin, toggleBanUser);
router.delete('/:id', extractUser, requireAdmin, deleteUser); router.delete('/:id', requireUser, requireAdmin, deleteUser);
export default router; export default router;
-326
View File
@@ -1,326 +0,0 @@
-- 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);
+53
View File
@@ -0,0 +1,53 @@
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();
+121
View File
@@ -0,0 +1,121 @@
import fs from 'fs';
import path from 'path';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
// Import Models
import Project from '../models/Project.js';
import Part from '../models/Part.js';
import PaintingScheme from '../models/PaintingScheme.js';
import ApplicationRecord from '../models/ApplicationRecord.js';
import Inspection from '../models/Inspection.js';
import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
import YieldStudy from '../models/YieldStudy.js';
dotenv.config();
const DATA_DIR = path.join(process.cwd(), 'data');
const readJson = (filename: string) => {
const filePath = path.join(DATA_DIR, filename);
if (!fs.existsSync(filePath)) return [];
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
};
const migrate = async () => {
try {
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error('MONGODB_URI not defined');
await mongoose.connect(uri);
console.log('Connected to MongoDB for migration...');
const idMap = new Map<string, mongoose.Types.ObjectId>();
// 1. TechnicalDataSheets
console.log('Migrating TechnicalDataSheets...');
const datasheets = readJson('datasheets.json');
for (const ds of datasheets) {
const newDs = new TechnicalDataSheet({
...ds,
_id: new mongoose.Types.ObjectId()
});
await newDs.save();
idMap.set(ds.id, newDs._id as mongoose.Types.ObjectId);
}
// 2. Projects
console.log('Migrating Projects...');
const projects = readJson('projects.json');
for (const p of projects) {
const newP = new Project({
...p,
_id: new mongoose.Types.ObjectId()
});
await newP.save();
idMap.set(p.id, newP._id as mongoose.Types.ObjectId);
}
// 3. Parts
console.log('Migrating Parts...');
const parts = readJson('parts.json');
for (const part of parts) {
const projectId = idMap.get(part.projectId);
if (projectId) {
const newPart = new Part({ ...part, projectId });
await newPart.save();
}
}
// 4. PaintingSchemes
console.log('Migrating PaintingSchemes...');
const schemes = readJson('paintingSchemes.json');
for (const s of schemes) {
const projectId = idMap.get(s.projectId);
if (projectId) {
const newS = new PaintingScheme({ ...s, projectId });
await newS.save();
}
}
// 5. ApplicationRecords
console.log('Migrating ApplicationRecords...');
const records = readJson('applicationRecords.json');
for (const r of records) {
const projectId = idMap.get(r.projectId);
if (projectId) {
const newR = new ApplicationRecord({ ...r, projectId });
await newR.save();
}
}
// 6. Inspections
console.log('Migrating Inspections...');
const inspections = readJson('inspections.json');
for (const i of inspections) {
const projectId = idMap.get(i.projectId);
if (projectId) {
const newI = new Inspection({ ...i, projectId });
await newI.save();
}
}
// 7. YieldStudies
console.log('Migrating YieldStudies...');
const studies = readJson('yield_studies.json');
for (const s of studies) {
const dataSheetId = idMap.get(s.dataSheetId);
if (dataSheetId) {
const newS = new YieldStudy({ ...s, dataSheetId });
await newS.save();
}
}
console.log('✅ Migration completed successfully!');
process.exit(0);
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
}
};
migrate();
+48
View File
@@ -0,0 +1,48 @@
import mongoose from 'mongoose';
const MONGODB_URI = 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0';
const UserSchema = new mongoose.Schema({
clerkId: String,
email: String,
name: String,
role: { type: String, enum: ['guest', 'user', 'admin'], default: 'guest' },
isBanned: { type: Boolean, default: false }
}, { timestamps: true });
async function fixAdmin() {
await mongoose.connect(MONGODB_URI);
console.log('✅ Conectado ao MongoDB');
const User = mongoose.model('User', UserSchema);
// Resetar m.reifonas para guest
await User.updateOne(
{ email: 'm.reifonas@gmail.com' },
{ $set: { role: 'guest' } }
);
console.log('✅ m.reifonas@gmail.com resetado para guest');
// Atualizar admtracksteel para admin (o com clerkId real)
const result = await User.updateOne(
{ email: 'admtracksteel@gmail.com', clerkId: { $ne: 'pending' } },
{ $set: { role: 'admin' } }
);
console.log('✅ admtracksteel@gmail.com atualizado para admin', result.modifiedCount > 0 ? '(sucesso)' : '(não encontrado)');
// Listar todos os usuários
const users = await User.find({});
console.log('\n📋 Usuários atualizados:');
users.forEach((u, i) => {
console.log(` ${i + 1}. ${u.email} | role: ${u.role}`);
});
await mongoose.disconnect();
console.log('\n✅ Desconectado do MongoDB');
process.exit(0);
}
fixAdmin().catch(err => {
console.error('❌ Erro:', err);
process.exit(1);
});
+41 -131
View File
@@ -1,162 +1,72 @@
import { query } from '../config/database.js'; import ApplicationRecord from '../models/ApplicationRecord.js';
interface ApplicationRecordItem { // eslint-disable-next-line @typescript-eslint/no-explicit-any
partId: string; export const createApplicationRecord = async (data: any & { organizationId?: string, createdBy?: string }) => {
quantity: number; const newRecord = new ApplicationRecord({
} ...data,
date: data.date ? new Date(data.date) : null,
interface ApplicationRecordData { organizationId: data.organizationId,
organizationId?: string; createdBy: data.createdBy
createdBy?: string; });
projectId: string; const saved = await newRecord.save();
coatStage: string; return { ...saved.toObject(), id: saved._id.toString() };
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) => {
let sql = 'SELECT * FROM gpi.application_records WHERE project_id = $1'; const query = { projectId, ...(organizationId ? { organizationId } : {}) };
const params: any[] = [projectId]; const records = await ApplicationRecord.find(query).sort({ date: -1 }).lean();
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;
}; };
export const updateApplicationRecord = async ( // eslint-disable-next-line @typescript-eslint/no-explicit-any
id: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Partial<ApplicationRecordData>, export const updateApplicationRecord = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
organizationId?: string, const existing = await ApplicationRecord.findById(id);
userId?: string, if (!existing) return null;
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];
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { // Organization Check
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
return null; return null;
} }
// 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) {
console.warn(`Permission Denied: User ${userId} tried to update record ${id} created by ${existing.createdBy}`);
return null; return null;
} }
const fields: string[] = []; const updateData = {
const params: any[] = [id]; ...data,
let paramIdx = 2; date: data.date ? new Date(data.date) : undefined
};
const updatableFields = [ if (organizationId && !existing.organizationId) {
'coatStage', 'pieceDescription', 'date', 'operator', 'realWeight', updateData.organizationId = organizationId;
'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) { const updated = await ApplicationRecord.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
await query( if (updated) {
`UPDATE gpi.application_records SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1`, return { ...updated, id: updated._id.toString() };
params
);
} }
return null;
// 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 checkResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]); const existing = await ApplicationRecord.findById(id);
if (checkResult.rows.length === 0) return false; if (!existing) return false;
const existing = checkResult.rows[0];
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { // Organization Check
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 query('DELETE FROM gpi.application_records WHERE id = $1', [id]); await ApplicationRecord.deleteOne({ _id: id });
return true; return true;
}; };
+151 -110
View File
@@ -1,133 +1,174 @@
import { query } from '../config/database.js'; import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
import fs from 'fs';
import path from 'path';
import { bucket } from '../config/database.js';
import { ObjectId } from 'mongodb';
interface DataSheetData { export const saveFileToGridFS = (localPath: string, filename: string): Promise<string> => {
organizationId?: string; return new Promise((resolve, reject) => {
name: string; const uploadStream = bucket.openUploadStream(filename);
manufacturer?: string; const readStream = fs.createReadStream(localPath);
manufacturerCode?: string;
type?: string; readStream.pipe(uploadStream)
minStock?: number; .on('error', reject)
typicalApplication?: string; .on('finish', () => {
fileId?: string; // Remove local file after upload
fileUrl?: string; fs.unlink(localPath, (err) => {
solidsVolume?: number; if (err) console.error('Failed to delete local temp file:', err);
density?: number; });
mixingRatio?: string; resolve(uploadStream.id.toString());
mixingRatioWeight?: string; });
mixingRatioVolume?: string; });
wftMin?: number; };
wftMax?: number;
dftMin?: number; export const deleteFileFromGridFS = async (fileId: string) => {
dftMax?: number; try {
reducer?: string; await bucket.delete(new ObjectId(fileId));
yieldTheoretical?: number; return true;
dftReference?: number; } catch (err) {
yieldFactor?: number; console.error('Failed to delete file from GridFS:', err);
dilution?: number; return false;
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) => {
let sql = 'SELECT * FROM gpi.technical_data_sheets'; const query = organizationId
const params: any[] = []; ? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }
: {};
if (organizationId) { const sheets = await TechnicalDataSheet.find(query).sort({ uploadDate: -1 }).lean();
sql += ' WHERE (organization_id = $1 OR organization_id IS NULL)'; return sheets.map(s => ({ ...s, id: s._id.toString() }));
params.push(organizationId);
}
sql += ' ORDER BY created_at DESC';
const result = await query(sql, params);
return result.rows;
}; };
export const matchSheets = async (searchTerm: string, organizationId?: string) => { export const matchSheets = async (query: string, organizationId?: string) => {
let sql = ` const orgFilter = organizationId
SELECT * FROM gpi.technical_data_sheets ? { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] }
WHERE (name ILIKE $1 OR manufacturer ILIKE $1 OR type ILIKE $1)`; : {};
const params: any[] = [`%${searchTerm}%`];
if (organizationId) { const filter = {
sql += ' AND (organization_id = $2 OR organization_id IS NULL)'; ...orgFilter,
params.push(organizationId); $or: [
} { name: { $regex: query, $options: 'i' } },
{ manufacturer: { $regex: query, $options: 'i' } },
const result = await query(sql, params); { type: { $regex: query, $options: 'i' } }
return result.rows;
};
export const createDataSheet = async (data: DataSheetData) => {
const result = await query(
`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]; const sheets = await TechnicalDataSheet.find(filter).lean();
return sheets.map(s => ({ ...s, id: s._id.toString() }));
}; };
export const updateDataSheet = async (id: string, data: Partial<DataSheetData>, organizationId?: string) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const checkResult = await query('SELECT * FROM gpi.technical_data_sheets WHERE id = $1', [id]); export const createDataSheet = async (data: any & { organizationId?: string }) => {
if (checkResult.rows.length === 0) return null; let fileId = data.fileUrl;
const existing = checkResult.rows[0];
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { // If fileUrl is a local path (exists on disk), move to GridFS
return null; if (data.fileUrl && fs.existsSync(data.fileUrl)) {
fileId = await saveFileToGridFS(data.fileUrl, data.name + '.pdf');
} }
const fields: string[] = []; const newSheet = new TechnicalDataSheet({
const params: any[] = [id]; ...data,
let paramIdx = 2; fileUrl: fileId, // Now storing GridFS ID instead of path
uploadDate: new Date(),
organizationId: data.organizationId
});
const updatableFields = [ const saved = await newSheet.save();
'name', 'manufacturer', 'manufacturerCode', 'type', 'minStock', return { ...saved.toObject(), id: saved._id.toString() };
'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]);
}
}
if (fields.length === 0) return existing;
const updateSql = `
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) => {
const checkResult = await query('SELECT * FROM gpi.technical_data_sheets WHERE id = $1', [id]); // Find first to check permissions
if (checkResult.rows.length === 0) return false; const sheet = await TechnicalDataSheet.findById(id);
const existing = checkResult.rows[0]; if (!sheet) return false;
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { // Permission Check:
// If current user is in an Org, and Sheet is in a DIFFERENT Org, deny.
// Explicitly allow if Sheet has NO Org (Legacy/Global).
if (organizationId && sheet.organizationId && sheet.organizationId !== organizationId) {
console.warn(`[Delete DataSheet] Access Denied. User Org: ${organizationId}, Sheet Org: ${sheet.organizationId}`);
return false; return false;
} }
await query('DELETE FROM gpi.technical_data_sheets WHERE id = $1', [id]); // Delete from GridFS if not a full URL
if (sheet.fileUrl && !sheet.fileUrl.startsWith('http')) {
await deleteFileFromGridFS(sheet.fileUrl);
}
await TechnicalDataSheet.findByIdAndDelete(id);
return true; return true;
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const updateDataSheet = async (id: string, updates: any, organizationId?: string) => {
// SECURITY FIX: Allow update if:
// 1. Matches ID AND Matches Organization
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
const oldSheet = await TechnicalDataSheet.findById(id);
if (!oldSheet) return null;
if (organizationId && oldSheet.organizationId && oldSheet.organizationId !== organizationId) {
console.warn(`Access Denied: Sheet ${id} belongs to ${oldSheet.organizationId}, user is ${organizationId}`);
return null; // Return null effectively hides it or acts as fail
}
// If new file is uploaded (path exists locally)
if (updates.fileUrl && updates.fileUrl !== oldSheet.fileUrl && fs.existsSync(updates.fileUrl)) {
// Upload new file
const newFileId = await saveFileToGridFS(updates.fileUrl, (updates.name || oldSheet.name) + '.pdf');
// Delete old file from GridFS
if (oldSheet.fileUrl && !oldSheet.fileUrl.startsWith('http')) {
await deleteFileFromGridFS(oldSheet.fileUrl);
}
updates.fileUrl = newFileId;
}
if (organizationId && !oldSheet.organizationId) {
updates.organizationId = organizationId;
}
const updated = await TechnicalDataSheet.findOneAndUpdate({ _id: id }, updates, { new: true }).lean();
if (updated) {
return { ...updated, id: updated._id.toString() };
}
return null;
};
export const migrateFilesToGridFS = async () => {
try {
const sheets = await TechnicalDataSheet.find({ fileUrl: { $regex: /^uploads\// } });
console.log(`[MIGRATION] Found ${sheets.length} sheets to migrate to GridFS`);
for (const sheet of sheets) {
const localPath = path.join(process.cwd(), sheet.fileUrl);
if (fs.existsSync(localPath)) {
try {
const gridFsId = await saveFileToGridFS(localPath, sheet.name + '.pdf');
sheet.fileUrl = gridFsId;
await sheet.save();
console.log(`[MIGRATION] Successfully migrated: ${sheet.name}`);
} catch (err) {
console.error(`[MIGRATION] Error migrating ${sheet.name}:`, err);
}
} else {
console.warn(`[MIGRATION] File not found for ${sheet.name}: ${localPath}`);
}
}
} catch (error) {
console.error('[MIGRATION] Migration failed:', error);
}
};
-18
View File
@@ -1,18 +0,0 @@
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]);
};
+50 -124
View File
@@ -1,155 +1,81 @@
import { query } from '../config/database.js'; import Inspection from '../models/Inspection.js';
interface InspectionData { // eslint-disable-next-line @typescript-eslint/no-explicit-any
projectId: string; export const createInspection = async (data: any & { organizationId?: string, createdBy?: string }) => {
partId: string; const newInspection = new Inspection({
type: string; ...data,
status: string; date: data.date ? new Date(data.date) : null,
notes?: string; organizationId: data.organizationId,
inspectorId?: string; createdBy: data.createdBy
organizationId?: string; });
createdBy?: string; const saved = await newInspection.save();
} 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) => {
let sql = 'SELECT * FROM gpi.inspections WHERE project_id = $1'; const query = { projectId, ...(organizationId ? { organizationId } : {}) };
const params: any[] = [projectId]; const inspections = await Inspection.find(query).sort({ date: -1 }).lean();
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;
}; };
export const updateInspection = async ( // eslint-disable-next-line @typescript-eslint/no-explicit-any
id: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Partial<InspectionData>, export const updateInspection = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
organizationId?: string, const existing = await Inspection.findById(id);
userId?: string, if (!existing) return null;
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]);
if (checkResult.rows.length === 0) return null;
const existing = checkResult.rows[0];
// Organization Check // Organization Check
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
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) // Role/Ownership check
// 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; const isPowerUser = userRole === 'admin' || isDeveloper;
if (!isPowerUser && existing.inspector_id && existing.inspector_id !== userId) { if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
console.warn(`Permission Denied: User ${userId} tried to update inspection ${id} created by ${existing.createdBy}`);
return null; return null;
} }
const fields: string[] = []; const updateData = {
const params: any[] = [id]; ...data,
let paramIdx = 2; date: data.date ? new Date(data.date) : undefined
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 ( if (organizationId && !existing.organizationId) {
id: string, updateData.organizationId = organizationId;
organizationId?: string, }
userId?: string,
userRole?: string,
isDeveloper: boolean = false
) => {
const checkSql = 'SELECT * FROM gpi.inspections WHERE id = $1';
const checkResult = await query(checkSql, [id]);
if (checkResult.rows.length === 0) return false; const updated = await Inspection.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
const existing = checkResult.rows[0]; if (updated) {
return { ...updated, id: updated._id.toString() };
}
return null;
};
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { export const deleteInspection = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
const existing = await Inspection.findById(id);
if (!existing) return false;
// Organization Check
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
return false; return false;
} }
// Role/Ownership check
const isPowerUser = userRole === 'admin' || isDeveloper; const isPowerUser = userRole === 'admin' || isDeveloper;
if (!isPowerUser && existing.inspector_id && existing.inspector_id !== userId) { if (!isPowerUser && existing.createdBy && existing.createdBy !== userId) {
return false; return false;
} }
await query('DELETE FROM gpi.inspections WHERE id = $1', [id]); await Inspection.deleteOne({ _id: id });
return true; return true;
}; };
export const getAllInspections = async (organizationId?: string) => { export const getAllInspections = async (organizationId?: string) => {
let sql = 'SELECT * FROM gpi.inspections'; const query = organizationId ? { organizationId } : {};
const params: any[] = []; const inspections = await Inspection.find(query).lean();
return inspections.map(i => ({ ...i, id: i._id.toString() }));
if (organizationId) {
sql += ' WHERE organization_id = $1';
params.push(organizationId);
}
const result = await query(sql, params);
return result.rows;
}; };
-91
View File
@@ -1,91 +0,0 @@
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;
};
+271 -121
View File
@@ -1,33 +1,15 @@
import { query } from '../config/database.js'; import Notification, { INotification } from '../models/Notification.js';
import StockItem from '../models/StockItem.js';
import Instrument from '../models/Instrument.js';
import { addMonths, isBefore } from 'date-fns'; 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<NotificationData>) { async create(data: Partial<INotification>) {
try { try {
const res = await query( const notification = new Notification(data);
`INSERT INTO gpi.notifications ( await notification.save();
organization_id, recipient_id, title, message, type, metadata return notification;
) 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;
@@ -40,17 +22,21 @@ export const notificationService = {
const graceDate = new Date(); const graceDate = new Date();
graceDate.setDate(graceDate.getDate() - graceDays); graceDate.setDate(graceDate.getDate() - graceDays);
// No Postgres com JSONB, usamos o operador @> para verificar se a metadata contém as chaves/valores const query: Record<string, unknown> = {
const res = await query( organizationId: orgId
`SELECT id FROM gpi.notifications };
WHERE organization_id = $1
AND metadata @> $2::jsonb
AND created_at >= $3
LIMIT 1`,
[orgId, JSON.stringify(metadata), graceDate]
);
return res.rows.length > 0; // Adicionar campos de metadata à query
for (const [key, value] of Object.entries(metadata)) {
query[`metadata.${key}`] = value;
}
// Verificar se existe alguma notificação com essa metadata nos últimos graceDays
// Independente de estar lida ou não, para evitar duplicidade.
query.createdAt = { $gte: graceDate };
const existing = await Notification.findOne(query);
return !!existing;
} catch (error) { } catch (error) {
console.error('Error checking notification existence:', error); console.error('Error checking notification existence:', error);
return false; return false;
@@ -60,22 +46,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 {
let sql = ` const query: Record<string, unknown> = {
SELECT * FROM gpi.notifications organizationId,
WHERE organization_id = $1 $or: [
AND (recipient_id = $2 OR recipient_id IS NULL) { recipientId: userId },
AND NOT ($2 = ANY(deleted_by)) { recipientId: null } // Notificações globais
`; ],
const params: any[] = [organizationId, userId]; deletedBy: { $ne: userId } // Não mostrar as deletadas pelo usuário
};
if (!includeArchived) { if (!includeArchived) {
sql += ' AND is_archived = false AND NOT ($2 = ANY(archived_by))'; // Filtra as arquivadas (pelo usuário ou globalmente)
query.isArchived = false;
query.archivedBy = { $ne: userId };
} }
sql += ' ORDER BY created_at DESC LIMIT 50'; return await Notification.find(query).sort({ createdAt: -1 }).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;
@@ -85,11 +71,7 @@ export const notificationService = {
// Marcar como lida // Marcar como lida
async markAsRead(id: string) { async markAsRead(id: string) {
try { try {
const res = await query( return await Notification.findByIdAndUpdate(id, { isRead: true }, { new: true });
'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;
@@ -99,13 +81,16 @@ 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 query( return await Notification.updateMany(
`UPDATE gpi.notifications {
SET is_read = true, updated_at = NOW() organizationId,
WHERE organization_id = $1 $or: [
AND (recipient_id = $2 OR recipient_id IS NULL) { recipientId: userId },
AND is_read = false`, { recipientId: null }
[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);
@@ -116,18 +101,25 @@ 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 {
// Se for pessoal, marca is_archived. Se for global, adiciona ao array archived_by. const notification = await Notification.findById(id);
const res = await query( if (!notification) return null;
`UPDATE gpi.notifications
SET if (notification.recipientId) {
is_archived = CASE WHEN recipient_id IS NOT NULL THEN true ELSE is_archived END, // Notificação pessoal
archived_by = CASE WHEN recipient_id IS NULL THEN array_append(archived_by, $2) ELSE archived_by END, notification.isArchived = true;
is_read = true, notification.isRead = true;
updated_at = NOW() } else {
WHERE id = $1 RETURNING *`, // Notificação global
[id, userId] if (!notification.archivedBy.includes(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;
@@ -137,19 +129,18 @@ 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 {
// Se for pessoal, deletamos. Se for global, adicionamos ao deleted_by. const notification = await Notification.findById(id);
const checkRes = await query('SELECT recipient_id FROM gpi.notifications WHERE id = $1', [id]); if (!notification) return null;
if (checkRes.rows.length === 0) return null;
if (checkRes.rows[0].recipient_id === userId) { if (notification.recipientId && notification.recipientId === userId) {
await query('DELETE FROM gpi.notifications WHERE id = $1', [id]); // Se for pessoal, podemos deletar do banco ou apenas marcar
return { id, deleted: true }; return await Notification.findByIdAndDelete(id);
} else { } else {
const res = await query( // Se for global, apenas adicionar ao deletedBy
'UPDATE gpi.notifications SET deleted_by = array_append(deleted_by, $2), updated_at = NOW() WHERE id = $1 RETURNING *', if (!notification.deletedBy.includes(userId)) {
[id, userId] notification.deletedBy.push(userId);
); }
return res.rows[0]; return await notification.save();
} }
} catch (error) { } catch (error) {
console.error('Error soft deleting notification:', error); console.error('Error soft deleting notification:', error);
@@ -160,19 +151,23 @@ 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 {
// Deletar as pessoais // Para notificações pessoais: Deletar
await query( await Notification.deleteMany({
'DELETE FROM gpi.notifications WHERE organization_id = $1 AND recipient_id = $2', organizationId,
[organizationId, userId] recipientId: userId
); });
// Adicionar às globais deletadas // Para notificações globais: Marcar como deletadas por esse usuário
await query( const globalNotifications = await Notification.find({
`UPDATE gpi.notifications organizationId,
SET deleted_by = array_append(deleted_by, $2), updated_at = NOW() recipientId: null,
WHERE organization_id = $1 AND recipient_id IS NULL AND NOT ($2 = ANY(deleted_by))`, deletedBy: { $ne: userId }
[organizationId, userId] });
);
for (const notif of globalNotifications) {
notif.deletedBy.push(userId);
await notif.save();
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -185,57 +180,87 @@ export const notificationService = {
async checkStockExpirations() { async checkStockExpirations() {
console.log('Running stock expiration checkJob...'); console.log('Running stock expiration checkJob...');
try { try {
const res = await query( // Buscar todos os itens de estoque com data de validade que ainda não venceram ou venceram recentemente
'SELECT * FROM gpi.stock_items WHERE expiration_date IS NOT NULL AND quantity > 0' // Otimização: Em um sistema real, faríamos isso por query direta, mas aqui vamos iterar para aplicar a lógica de 2 meses, 1 mês, vencido.
); const stockItems = await StockItem.find({ expirationDate: { $exists: true, $ne: null }, quantity: { $gt: 0 } });
const 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) {
const expirationDate = new Date(item.expiration_date); if (!item.expirationDate) continue;
const itemId = item.id;
const orgId = item.organization_id; const expirationDate = new Date(item.expirationDate);
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' | 'info' = 'warning'; let type: 'warning' | 'error' = '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.rr_number} - Lote ${item.batch_number} venceu em ${expirationDate.toLocaleDateString()}.`; message = `O item ${item.rrNumber} - Lote ${item.batchNumber} 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';
}
if (triggerType) { const notified = await this.isAlreadyNotified(orgId.toString(), {
const notified = await this.isAlreadyNotified(orgId, {
stockItemId: itemId, stockItemId: itemId,
triggerType triggerType: 'expired'
}); });
if (!notified) { if (!notified) {
await this.create({ await this.create({
organization_id: orgId, organizationId: orgId,
title, title,
message, message,
type, type,
metadata: { stockItemId: itemId, triggerType } metadata: { stockItemId: itemId, triggerType: 'expired' }
});
}
}
// 2. Vence em 1 mês (aprox)
else if (isBefore(expirationDate, oneMonthFromNow)) {
title = 'Vencimento Próximo (1 mês)';
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em menos de 1 mês (${expirationDate.toLocaleDateString()}).`;
const notified = await this.isAlreadyNotified(orgId.toString(), {
stockItemId: itemId,
triggerType: 'expire_1_month'
});
if (!notified) {
await this.create({
organizationId: orgId,
title,
message,
type: 'warning',
metadata: { stockItemId: itemId, triggerType: 'expire_1_month' }
});
}
}
// 3. Vence em 2 meses (aprox)
else if (isBefore(expirationDate, twoMonthsFromNow)) {
title = 'Vencimento em 2 meses';
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em 2 meses (${expirationDate.toLocaleDateString()}).`;
const notified = await this.isAlreadyNotified(orgId.toString(), {
stockItemId: itemId,
triggerType: 'expire_2_months'
});
if (!notified) {
await this.create({
organizationId: orgId,
title,
message,
type: 'info',
metadata: { stockItemId: itemId, triggerType: 'expire_2_months' }
}); });
} }
} }
@@ -243,5 +268,130 @@ 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);
}
} }
}; };
+53 -75
View File
@@ -1,94 +1,72 @@
import { query } from '../config/database.js'; import PaintingScheme from '../models/PaintingScheme.js';
interface PaintingSchemeData { // eslint-disable-next-line @typescript-eslint/no-explicit-any
organizationId?: string; export const createPaintingScheme = async (data: any & { organizationId?: string }) => {
projectId?: string; const newScheme = new PaintingScheme({ ...data, organizationId: data.organizationId });
name: string; const saved = await newScheme.save();
description?: string; return { ...saved.toObject(), id: saved._id.toString() };
}
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) => {
let sql = 'SELECT * FROM gpi.painting_schemes WHERE project_id = $1'; const query = { projectId, ...(organizationId ? { organizationId } : {}) };
const params: any[] = [projectId]; const schemes = await PaintingScheme.find(query).lean();
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;
}; };
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]); // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (checkResult.rows.length === 0) return null; export const updatePaintingScheme = async (id: string, data: any, organizationId?: string) => {
const existing = checkResult.rows[0]; // SECURITY FIX: Allow update if:
// 1. Matches ID AND Matches Organization
// 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { let query: any = { _id: id };
// First, check if the record exists and what is its state
const existing = await PaintingScheme.findById(id);
if (!existing) return null;
// Check ownership
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
// Exists but belongs to ANOTHER organization -> Deny
console.warn(`Access Denied: Scheme ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
return null; // Return null effectively hides it or acts as fail
}
// If we passed the check, we perform the update.
// Ensure we "adopt" the record if it didn't have an orgId
if (organizationId && !data.organizationId) {
data.organizationId = organizationId;
}
const updated = await PaintingScheme.findOneAndUpdate({ _id: id }, data, { new: true }).lean();
if (updated) {
return { ...updated, id: updated._id.toString() };
}
return null; 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) => {
const checkResult = await query('SELECT * FROM gpi.painting_schemes WHERE id = $1', [id]); // Find first to check permissions
if (checkResult.rows.length === 0) return; const existing = await PaintingScheme.findById(id);
const existing = checkResult.rows[0]; if (!existing) return;
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { // Permissions:
// If user has org, and item has OTHER org, deny.
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
console.warn(`[Delete PaintingScheme] Access Denied. User Org: ${organizationId}, Scheme Org: ${existing.organizationId}`);
return; return;
} }
await query('DELETE FROM gpi.painting_schemes WHERE id = $1', [id]); await PaintingScheme.findByIdAndDelete(id);
}; };
export const getAllSchemes = async (organizationId?: string) => { export const getAllSchemes = async (organizationId?: string) => {
let sql = 'SELECT * FROM gpi.painting_schemes'; const query = organizationId ? { organizationId } : {};
const params: any[] = []; const schemes = await PaintingScheme.find(query).lean();
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;
}; };
+37 -91
View File
@@ -1,114 +1,60 @@
import { query } from '../config/database.js'; import Part from '../models/Part.js';
interface PartData { // eslint-disable-next-line @typescript-eslint/no-explicit-any
projectId: string; export const createPart = async (data: any & { organizationId?: string }) => {
organizationId?: string; const newPart = new Part({ ...data, organizationId: data.organizationId });
name: string; const saved = await newPart.save();
description?: string; return { ...saved.toObject(), id: saved._id.toString() };
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) => {
let sql = 'SELECT * FROM gpi.parts WHERE project_id = $1'; const query = isGlobalAdmin
const params: any[] = [projectId]; ? { projectId }
: { projectId, $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
if (!isGlobalAdmin && organizationId) { const parts = await Part.find(query).lean();
sql += ' AND (organization_id = $2 OR organization_id IS NULL)'; return parts.map(p => ({ ...p, id: p._id.toString() }));
params.push(organizationId);
}
const result = await query(sql, params);
return result.rows;
}; };
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]); // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (checkResult.rows.length === 0) return null; export const updatePart = async (id: string, data: any, organizationId?: string, isGlobalAdmin: boolean = false) => {
const existing = checkResult.rows[0]; const existing = await Part.findById(id);
if (!existing) return null;
if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) { if (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) {
console.warn(`Access Denied: Part ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
return null; return null;
} }
const fields: string[] = []; if (organizationId && !existing.organizationId) {
const params: any[] = [id]; data.organizationId = organizationId; // Adopt
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 updated = await Part.findOneAndUpdate({ _id: id }, data, { new: true }).lean();
if (updated) {
const updateSql = ` return { ...updated, id: updated._id.toString() };
UPDATE gpi.parts }
SET ${fields.join(', ')}, updated_at = NOW() return null;
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 checkResult = await query('SELECT * FROM gpi.parts WHERE id = $1', [id]); const part = await Part.findById(id);
if (checkResult.rows.length === 0) return; if (!part) return;
const existing = checkResult.rows[0];
if (!isGlobalAdmin && organizationId && existing.organization_id && existing.organization_id !== organizationId) { if (!isGlobalAdmin && organizationId && part.organizationId && part.organizationId !== organizationId) {
throw new Error('Sem permissão para excluir esta peça'); throw new Error('Sem permissão para excluir esta peça');
} }
await query('DELETE FROM gpi.parts WHERE id = $1', [id]); await Part.findByIdAndDelete(id);
}; };
export const getAllParts = async (organizationId?: string, isGlobalAdmin: boolean = false) => { export const getAllParts = async (organizationId?: string, isGlobalAdmin: boolean = false) => {
let sql = 'SELECT * FROM gpi.parts'; const query = isGlobalAdmin
const params: any[] = []; ? {}
: { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
if (!isGlobalAdmin && organizationId) { const parts = await Part.find(query).lean();
sql += ' WHERE (organization_id = $1 OR organization_id IS NULL)'; return parts.map(p => ({ ...p, id: p._id.toString() }));
params.push(organizationId);
}
const result = await query(sql, params);
return result.rows;
}; };
+173 -56
View File
@@ -1,4 +1,8 @@
import { query } from '../config/database.js'; import Project from '../models/Project.js';
import Part from '../models/Part.js';
import PaintingScheme from '../models/PaintingScheme.js';
import ApplicationRecord from '../models/ApplicationRecord.js';
import Inspection from '../models/Inspection.js';
interface ProjectData { interface ProjectData {
name: string; name: string;
@@ -8,97 +12,210 @@ 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 res = await query( const newProject = new Project({
'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 *', name: data.name,
[data.organizationId, data.name, data.client, data.startDate, data.endDate, data.technician, data.environment, data.weightKg] client: data.client,
); startDate: data.startDate ? new Date(data.startDate) : null,
return res.rows[0]; endDate: data.endDate ? new Date(data.endDate) : null,
technician: data.technician,
environment: data.environment,
organizationId: data.organizationId,
weightKg: data.weightKg
});
return await newProject.save();
}; };
export const getDashboardProjects = async (organizationId?: string) => { export const getDashboardProjects = async (organizationId?: string) => {
const sql = ` const matchStage = organizationId ? { organizationId } : {};
SELECT p.*,
(SELECT json_agg(s) FROM gpi.painting_schemes s WHERE s.project_id = p.id) as schemes, const projects = await Project.aggregate([
(SELECT SUM(weight_kg) FROM gpi.inspections i WHERE i.project_id = p.id) as painted_weight { $match: matchStage },
FROM gpi.projects p { $sort: { name: 1 } },
WHERE (p.organization_id = $1 OR $1 IS NULL) AND p.status = 'active' {
ORDER BY p.name ASC $lookup: {
`; from: 'paintingschemes',
const res = await query(sql, [organizationId || null]); localField: '_id',
return res.rows; foreignField: 'projectId',
as: 'paintingSchemes'
}
},
{
$lookup: {
from: 'inspections',
localField: '_id',
foreignField: 'projectId',
as: 'inspections'
}
},
{
$project: {
_id: 1,
name: 1,
client: 1,
technician: 1,
weightKg: 1,
createdAt: 1,
schemes: {
$map: {
input: "$paintingSchemes",
as: "scheme",
in: {
id: { $toString: "$$scheme._id" },
name: "$$scheme.name",
type: "$$scheme.type",
coat: "$$scheme.coat",
color: "$$scheme.color",
colorHex: "$$scheme.colorHex",
thinnerSymbol: "$$scheme.thinnerSymbol",
epsMin: "$$scheme.epsMin",
epsMax: "$$scheme.epsMax"
}
}
},
paintedWeight: { $sum: "$inspections.weightKg" }
}
}
]);
return projects.map(p => ({ ...p, id: p._id.toString() }));
}; };
export const getAllProjects = async (organizationId?: string, isGlobalAdmin: boolean = false, status: string = 'active') => { export const getAllProjects = async (organizationId?: string, isGlobalAdmin: boolean = false, status: string = 'active') => {
let sql = 'SELECT * FROM gpi.projects WHERE status = $1'; const statusQuery = status === 'active'
const params: any[] = [status]; ? { status: { $ne: 'archived' } }
: { status: 'archived' };
if (!isGlobalAdmin) { const matchQuery: Record<string, unknown> = isGlobalAdmin
sql += ' AND (organization_id = $2 OR organization_id IS NULL)'; ? { ...statusQuery }
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');
} }
sql += ' ORDER BY name ASC'; const newStatus = project.status === 'active' ? 'archived' : 'active';
const res = await query(sql, params); const updated = await Project.findByIdAndUpdate(id, { status: newStatus }, { new: true }).lean();
return res.rows; return updated;
}; };
export const getProjectById = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => { export const getProjectById = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
const res = await query('SELECT * FROM gpi.projects WHERE id = $1', [id]); const project = await Project.findById(id).lean();
const project = res.rows[0];
if (!project) throw new Error('Projeto não encontrado'); if (!project) throw new Error('Projeto não encontrado');
if (!isGlobalAdmin && organizationId && project.organization_id && project.organization_id !== organizationId) { // Security check: Allow if global admin OR matches organization OR project has no organization
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
throw new Error('Acesso negado a este projeto'); throw new Error('Acesso negado a este projeto');
} }
const [parts, schemes, records, inspections] = await Promise.all([ const [parts, schemes, records, inspections] = await Promise.all([
query('SELECT * FROM gpi.parts WHERE project_id = $1', [id]), Part.find({ projectId: id }).lean(),
query('SELECT * FROM gpi.painting_schemes WHERE project_id = $1', [id]), PaintingScheme.find({ projectId: id }).populate('paintId thinnerId').lean(),
query('SELECT * FROM gpi.application_records WHERE project_id = $1', [id]), ApplicationRecord.find({ projectId: id }).lean(),
query('SELECT * FROM gpi.inspections WHERE project_id = $1', [id]) Inspection.find({ projectId: id })
.populate({
path: 'stockItemId',
select: 'batchNumber dataSheetId',
populate: { path: 'dataSheetId', select: 'name' }
})
.lean()
]); ]);
return { return {
...project, ...project,
parts: parts.rows, id: project._id.toString(),
paintingSchemes: schemes.rows, // eslint-disable-next-line @typescript-eslint/no-explicit-any
applicationRecords: records.rows, parts: parts.map((p: any) => ({ ...p, id: p._id.toString() })),
inspections: inspections.rows // eslint-disable-next-line @typescript-eslint/no-explicit-any
paintingSchemes: schemes.map((s: any) => ({ ...s, id: s._id.toString() })),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
applicationRecords: records.map((r: any) => ({ ...r, id: r._id.toString() })),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inspections: inspections.map((i: any) => ({ ...i, id: i._id.toString() }))
}; };
}; };
export const updateProject = async (id: string, data: Partial<ProjectData>, organizationId?: string, isGlobalAdmin: boolean = false) => { export const updateProject = async (id: string, data: Partial<ProjectData>, organizationId?: string, isGlobalAdmin: boolean = false) => {
const fields: string[] = []; const existing = await Project.findById(id);
const params: any[] = [id]; if (!existing) return null;
let paramIdx = 2;
const updatableFields = ['name', 'client', 'startDate', 'endDate', 'technician', 'environment', 'weightKg', 'status']; // Check ownership
if (!isGlobalAdmin && organizationId && existing.organizationId && existing.organizationId !== organizationId) {
for (const key of updatableFields) { console.warn(`Access Denied: Project ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
if ((data as any)[key] !== undefined) { return null;
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
fields.push(`${dbKey} = $${paramIdx++}`);
params.push((data as any)[key]);
}
} }
if (fields.length === 0) return null; const updateData: Partial<ProjectData> & { updatedAt: Date, organizationId?: string } = {
...data,
const sql = `UPDATE gpi.projects SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1 RETURNING *`; updatedAt: new Date(),
const res = await query(sql, params); startDate: data.startDate ? new Date(data.startDate) : undefined,
return res.rows[0]; endDate: data.endDate ? new Date(data.endDate) : undefined,
weightKg: data.weightKg,
}; };
export const archiveProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => { // Adopt if needed
const res = await query("UPDATE gpi.projects SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *", [id]); if (organizationId && !existing.organizationId) {
return res.rows[0]; updateData.organizationId = organizationId;
}
const updated = await Project.findOneAndUpdate({ _id: id }, updateData, { new: true }).lean();
if (updated) {
return { ...updated, id: updated._id.toString() };
}
return null;
}; };
export const deleteProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => { export const deleteProject = async (id: string, organizationId?: string, isGlobalAdmin: boolean = false) => {
await query('DELETE FROM gpi.projects WHERE id = $1', [id]); const project = await Project.findById(id);
if (!project) return;
// Check ownership
if (!isGlobalAdmin && organizationId && project.organizationId && project.organizationId !== organizationId) {
throw new Error('Sem permissão para excluir este projeto');
}
await Project.findByIdAndDelete(id);
// Also cleanup related data
await Promise.all([
Part.deleteMany({ projectId: id }),
PaintingScheme.deleteMany({ projectId: id }),
ApplicationRecord.deleteMany({ projectId: id }),
Inspection.deleteMany({ projectId: id })
]);
}; };
-163
View File
@@ -1,163 +0,0 @@
import { query } from '../config/database.js';
export const createStockItem = async (data: any) => {
const result = await query(
`INSERT INTO gpi.stock_items (
organization_id, created_by, data_sheet_id, rr_number, batch_number,
color, invoice_number, received_by, quantity, unit, min_stock,
expiration_date, notes
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
data.organizationId, data.createdBy, data.dataSheetId, data.rrNumber, data.batchNumber,
data.color, data.invoiceNumber, data.receivedBy, data.quantity, data.unit, data.minStock,
data.expirationDate, data.notes
]
);
return result.rows[0];
};
export const createStockMovement = async (data: any) => {
const result = await query(
`INSERT INTO gpi.stock_movements (
organization_id, created_by, stock_item_id, movement_number, type,
quantity, date, responsible, reason, requester, notes
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *`,
[
data.organizationId, data.createdBy, data.stockItemId, data.movementNumber, data.type,
data.quantity, data.date || new Date(), data.responsible, data.reason, data.requester, data.notes
]
);
return result.rows[0];
};
export const getStockItems = async (organizationId: string, dataSheetId?: string) => {
let sql = `
SELECT si.*, ds.name as "dataSheetName", ds.manufacturer as "dataSheetManufacturer"
FROM gpi.stock_items si
LEFT JOIN gpi.technical_data_sheets ds ON si.data_sheet_id = ds.id
WHERE si.organization_id = $1`;
const params: any[] = [organizationId];
if (dataSheetId) {
sql += ' AND si.data_sheet_id = $2';
params.push(dataSheetId);
}
sql += ' ORDER BY si.expiration_date ASC';
const result = await query(sql, params);
return result.rows;
};
export const getStockItemById = async (id: string, organizationId: string) => {
const result = await query(
`SELECT si.*, ds.name as "dataSheetName", ds.manufacturer as "dataSheetManufacturer"
FROM gpi.stock_items si
LEFT JOIN gpi.technical_data_sheets ds ON si.data_sheet_id = ds.id
WHERE si.id = $1 AND si.organization_id = $2`,
[id, organizationId]
);
return result.rows[0];
};
export const updateStockItem = async (id: string, organizationId: string, data: any) => {
const fields: string[] = [];
const params: any[] = [id, organizationId];
let idx = 3;
for (const key in data) {
if (data[idx] !== undefined && key !== 'id' && key !== 'organizationId') {
const dbKey = key.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`);
fields.push(`${dbKey} = $${idx++}`);
params.push(data[key]);
}
}
// Manual loop just in case
const updatable = ['notes', 'expirationDate', 'minStock', 'invoiceNumber', 'receivedBy', 'batchNumber', 'rrNumber', 'color'];
const finalFields = [];
const finalParams = [id, organizationId];
let pIdx = 3;
for (const key of updatable) {
if (data[key] !== undefined) {
const dbKey = key.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`);
finalFields.push(`${dbKey} = $${pIdx++}`);
finalParams.push(data[key]);
}
}
if (finalFields.length === 0) return null;
const sql = `UPDATE gpi.stock_items SET ${finalFields.join(', ')}, updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING *`;
const res = await query(sql, finalParams);
return res.rows[0];
};
export const updateStockItemQuantity = async (id: string, newQuantity: number) => {
await query('UPDATE gpi.stock_items SET quantity = $1, updated_at = NOW() WHERE id = $2', [newQuantity, id]);
};
export const deleteStockItem = async (id: string, organizationId: string) => {
await query('DELETE FROM gpi.stock_movements WHERE stock_item_id = $1', [id]);
await query('DELETE FROM gpi.stock_audit_logs WHERE stock_item_id = $1', [id]);
const res = await query('DELETE FROM gpi.stock_items WHERE id = $1 AND organization_id = $2', [id, organizationId]);
return res.rowCount > 0;
};
export const getStockMovements = async (stockItemId: string, organizationId: string) => {
const result = await query(
'SELECT * FROM gpi.stock_movements WHERE stock_item_id = $1 AND organization_id = $2 ORDER BY date DESC',
[stockItemId, organizationId]
);
return result.rows;
};
export const getLatestMovementNumber = async (stockItemId: string) => {
const result = await query(
'SELECT MAX(movement_number) as max FROM gpi.stock_movements WHERE stock_item_id = $1',
[stockItemId]
);
return result.rows[0].max || 0;
};
export const getStockMovementById = async (id: string, organizationId: string) => {
const res = await query('SELECT * FROM gpi.stock_movements WHERE id = $1 AND organization_id = $2', [id, organizationId]);
return res.rows[0];
};
export const updateStockMovement = async (id: string, organizationId: string, data: any) => {
const res = await query(
'UPDATE gpi.stock_movements SET quantity = COALESCE($1, quantity), date = COALESCE($2, date), notes = COALESCE($3, notes), updated_at = NOW() WHERE id = $4 AND organization_id = $5 RETURNING *',
[data.quantity, data.date, data.notes, id, organizationId]
);
return res.rows[0];
};
export const deleteStockMovement = async (id: string, organizationId: string) => {
const res = await query('DELETE FROM gpi.stock_movements WHERE id = $1 AND organization_id = $2', [id, organizationId]);
return res.rowCount > 0;
};
export const createAuditLog = async (data: any) => {
await query(
`INSERT INTO gpi.stock_audit_logs (
organization_id, stock_item_id, movement_id, movement_number,
user_id, user_name, action, details, old_values, new_values
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
data.organizationId, data.stockItemId, data.movementId, data.movementNumber,
data.userId, data.userName, data.action, data.details, data.oldValues, data.newValues
]
);
};
export const getStockAuditLogs = async (stockItemId: string, organizationId: string) => {
const res = await query(
'SELECT * FROM gpi.stock_audit_logs WHERE stock_item_id = $1 AND organization_id = $2 ORDER BY timestamp DESC',
[stockItemId, organizationId]
);
return res.rows;
};
-145
View File
@@ -1,145 +0,0 @@
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;
};
+32 -60
View File
@@ -1,83 +1,55 @@
import { query } from '../config/database.js'; import YieldStudy from '../models/YieldStudy.js';
export const getAllStudies = async (organizationId?: string) => { export const getAllStudies = async (organizationId?: string) => {
let sql = 'SELECT * FROM gpi.yield_studies'; const query = organizationId ? { organizationId } : {};
const params: any[] = []; const studies = await YieldStudy.find(query).populate('dataSheetId').sort({ createdAt: -1 }).lean();
if (organizationId) { return studies.map(s => ({ ...s, id: s._id.toString() }));
sql += ' WHERE organization_id = $1';
params.push(organizationId);
}
sql += ' ORDER BY created_at DESC';
const res = await query(sql, params);
return res.rows;
}; };
export const createStudy = async (data: any) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await query( export const createStudy = async (data: any & { organizationId?: string }) => {
`INSERT INTO gpi.yield_studies ( const newStudy = new YieldStudy({ ...data, organizationId: data.organizationId });
organization_id, data_sheet_id, name, target_dft, const saved = await newStudy.save();
dilution_percent, categories, total_weight, estimated_paint_volume, return { ...saved.toObject(), id: saved._id.toString() };
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) => {
const check = await query('SELECT * FROM gpi.yield_studies WHERE id = $1', [id]); // SECURITY FIX: Allow update if:
if (check.rows.length === 0) return null; // 1. Matches ID AND Matches Organization
const existing = check.rows[0]; // 2. OR Matches ID AND Record has NO organization (legacy/orphan) -> Adopt it!
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { const existing = await YieldStudy.findById(id);
if (!existing) return null;
if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
console.warn(`Access Denied: Study ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
return null; return null;
} }
const fields: string[] = []; if (organizationId && !existing.organizationId) {
const params: any[] = [id]; updates.organizationId = organizationId;
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]);
}
}
} }
if (fields.length === 0) return existing; const updated = await YieldStudy.findOneAndUpdate({ _id: id }, updates, { new: true }).lean();
if (updated) {
const sql = `UPDATE gpi.yield_studies SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1 RETURNING *`; return { ...updated, id: updated._id.toString() };
const res = await query(sql, params); }
return res.rows[0]; return null;
}; };
export const deleteStudy = async (id: string, organizationId?: string) => { export const deleteStudy = async (id: string, organizationId?: string) => {
const check = await query('SELECT organization_id FROM gpi.yield_studies WHERE id = $1', [id]); // SECURITY FIX: Same logic as update - allow delete if owned OR if orphan
if (check.rows.length === 0) return false; const existing = await YieldStudy.findById(id);
if (!existing) return false;
if (organizationId && check.rows[0].organization_id && check.rows[0].organization_id !== organizationId) { if (organizationId && existing.organizationId && existing.organizationId !== organizationId) {
console.warn(`Access Denied: Delete Study ${id} belongs to ${existing.organizationId}, user is ${organizationId}`);
return false; return false;
} }
await query('DELETE FROM gpi.yield_studies WHERE id = $1', [id]); await YieldStudy.findByIdAndDelete(id);
return true; return true;
}; };
+3 -3
View File
@@ -1,10 +1,10 @@
import { IAppUser } from '../middleware/roleMiddleware.js'; import { IUser } from '../models/User.js';
declare global { declare global {
namespace Express { namespace Express {
interface Request { interface Request {
appUser?: IAppUser; appUser?: IUser;
clerkUserId?: string;
} }
} }
} }
export {};
+103 -122
View File
@@ -1,144 +1,125 @@
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;
start_date?: Date; startDate?: Date | string | null;
end_date?: Date; endDate?: Date | string | null;
technician?: string; technician?: string | null;
environment?: string; environment?: string | null;
weight_kg?: number; createdAt: Date | string;
status: 'active' | 'archived'; updatedAt: Date | string;
created_at: Date; parts?: Part[];
updated_at: Date; paintingSchemes?: PaintingScheme[];
applicationRecords?: ApplicationRecord[];
inspections?: Inspection[];
} }
export interface Part { export interface Part {
id: string; id: string;
project_id: string; projectId: string;
organization_id: string; description: string;
name: string; dimensions?: string | null;
description?: string; weight?: number | null;
type?: string | null;
area?: number | null;
complexity?: number | null;
quantity: number; quantity: number;
weight_kg?: number; notes?: string | null;
drawing_number?: string; }
created_at: Date;
updated_at: Date; export interface PaintingScheme {
id: string;
projectId: string;
name: string;
type?: string | null;
solidsVolume?: number | null;
yieldTheoretical?: number | null;
epsMin?: number | null;
epsMax?: number | null;
dilution?: number | null;
manufacturer?: string | null;
color?: string | null;
notes?: string | null;
}
export interface ApplicationRecord {
id: string;
projectId: string;
coatStage: string;
pieceDescription?: string | null;
date?: Date | string | null;
operator?: string | null;
realWeight?: number | null; // Peso Real (kg)
volumeUsed?: number | null; // Volume Utilizado (L)
areaPainted?: number | null; // Área Pintada (m²)
wetThicknessAvg?: number | null; // Espessura Úmida Média (µm)
dryThicknessCalc?: number | null; // Espessura Seca Calculada (µm)
method?: string | null;
realYield?: number | null; // Rendimento Real (m²/L)
diluentUsed?: number | null; // Consumo estimado de Diluente (L)
notes?: string | null;
}
export interface Inspection {
id: string;
projectId: string;
date?: Date | string | null;
inspector?: string | null;
pieceDescription?: string | null;
epsPoints?: (number | null)[];
adhesionTest?: string | null; // Teste de Aderência
appearance?: string | null; // Aspecto Visual
defects?: string | null;
} }
export interface TechnicalDataSheet { export interface TechnicalDataSheet {
id: string; id: string;
organization_id: string;
name: string; name: string;
manufacturer?: string; manufacturer?: string;
manufacturer_code?: string; type?: string; // 'epoxi', 'pu', 'diluent', etc
type?: string; fileUrl: string; // Path or URL to PDF
min_stock?: number; uploadDate: Date | string;
typical_application?: string;
file_id?: string; // Technical Properties for Analysis
file_url?: string; solidsVolume?: number; // %
upload_date: Date; density?: number; // g/cm3
solids_volume?: number; mixingRatio?: string;
density?: number; mixingRatioWeight?: string;
mixing_ratio?: string; mixingRatioVolume?: string;
mixing_ratio_weight?: string; wftMin?: number;
mixing_ratio_volume?: string; wftMax?: number;
wft_min?: number; dftMin?: number;
wft_max?: number; dftMax?: number;
dft_min?: number;
dft_max?: number;
reducer?: string; reducer?: string;
yield_theoretical?: number; yieldTheoretical?: number; // m2/L
dft_reference?: number; dftReference?: number; // µm (Espessura de camada seca de referência para o rendimento teórico)
yield_factor?: number; yieldFactor?: number; // Razão m²/L por µm (Calculado como rendimento * espessura / 1 ou simplesmente sólidos * 10)
dilution?: number; dilution?: number; // % sugerida
notes?: string; notes?: string;
created_at: Date;
updated_at: Date;
} }
export interface Notification { export interface PieceCategory {
id: string; id: string;
organization_id: string;
recipient_id?: string;
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 interface StockItem {
id: string;
organization_id: string;
created_by?: string;
data_sheet_id: string;
rr_number: string;
batch_number: string;
color?: string;
invoice_number?: string;
received_by?: string;
quantity: number;
unit: string;
min_stock: number;
expiration_date?: Date;
entry_date: Date;
notes?: string;
created_at: Date;
updated_at: Date;
}
export interface Instrument {
id: string;
organization_id: string;
name: string; name: string;
type: string; weight: number; // Total weight in Kg
manufacturer?: string; historicalYield: number; // L/Kg (Historical consumption)
model_name?: string; historicalDft: number; // µm (Reference DFT for the historical yield)
serial_number: string; efficiency: number; // % (Expected application efficiency)
calibration_date?: Date; }
calibration_expiration_date?: Date;
certificate_url?: string; export interface YieldStudy {
status: 'active' | 'inactive' | 'maintenance' | 'expired'; id: string;
notes?: string; name: string;
created_at: Date; dataSheetId: string; // Ficha Técnica selecionada
updated_at: Date; targetDft: number; // µm (Desired dry thickness)
dilutionPercent: number; // % (Expected dilution)
categories: PieceCategory[];
createdAt: Date | string;
updatedAt: Date | string;
// Results (Calculated)
totalWeight: number;
estimatedPaintVolume: number; // Liters
estimatedReducerVolume: number; // Liters
averageComplexity: number;
} }