Compare commits

..

8 Commits

126 changed files with 5790 additions and 4338 deletions
-9
View File
@@ -75,15 +75,6 @@ When auto-applying an agent, inform the user:
## TIER 0: UNIVERSAL RULES (Always Active) ## TIER 0: UNIVERSAL RULES (Always Active)
### 🌍 Global User Rules (MANDATORY)
1. **Idioma**: Conversar e desenvolver sempre em **Português do Brasil**.
2. **Automação Terminais**: Executar comandos de terminal, leitura de arquivos e edições de forma automática. Comandos de desenvolvimento (build, install, lint, git, etc.) são sempre `SafeToAutoRun`.
3. **Memória & Monitoramento**: Após cada evolução ou atualização no app, é OBRIGATÓRIO:
- Atualizar a memória do app em `/root/Antigravity_Memory/knowledge/{AppName}.md`.
- Atualizar o status e credenciais no `app.py` do Cronos Dashboard.
- Realizar `git commit`, `git push` e executar o `update.sh` do Cronos para deploy automático.
### 🌐 Language Handling ### 🌐 Language Handling
When user's prompt is NOT in English: When user's prompt is NOT in English:
+4 -3
View File
@@ -1,6 +1,7 @@
node_modules node_modules
dist dist
.git .git
.gitignore .agent
*.md .antigravity
.env* uploads
*.pdf
+4 -10
View File
@@ -1,15 +1,9 @@
FROM node:22-alpine FROM node:22-slim
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY . . COPY . .
# Build the frontend
RUN npm run build RUN npm run build
ENV PORT=3000
EXPOSE 3000 EXPOSE 3000
CMD ["node", "dist/server/src/server/index.js"]
CMD ["npm", "run", "start"]
+2 -1
View File
@@ -1 +1,2 @@
Tue Mar 31 10:45:40 UTC 2026 Force Refresh Vercel - Timestamp: 2026-01-25 13:55
Commit Hash Target: 30f8b5c
+43 -2
View File
@@ -12,9 +12,10 @@ import userRoutes from '../src/server/routes/userRoutes.js';
import systemSettingsRoutes from '../src/server/routes/systemSettingsRoutes.js'; import systemSettingsRoutes from '../src/server/routes/systemSettingsRoutes.js';
import geometryTypeRoutes from '../src/server/routes/geometryTypeRoutes.js'; import geometryTypeRoutes from '../src/server/routes/geometryTypeRoutes.js';
import stockRoutes from '../src/server/routes/stockRoutes.js'; import stockRoutes from '../src/server/routes/stockRoutes.js';
import authRoutes from '../src/server/routes/authRoutes.js';
import notificationRoutes from '../src/server/routes/notificationRoutes.js'; import notificationRoutes from '../src/server/routes/notificationRoutes.js';
import instrumentRoutes from '../src/server/routes/instrumentRoutes.js'; import instrumentRoutes from '../src/server/routes/instrumentRoutes.js';
import { extractUser } from '../src/server/middleware/authMiddleware.js'; import { extractUser } from '../src/server/middleware/roleMiddleware.js';
import path from 'path'; import path from 'path';
const app = express(); const app = express();
@@ -27,12 +28,52 @@ app.use(cors({
app.use(express.json()); app.use(express.json());
// Global Middleware // Global Middleware
import { authMiddleware } from '../src/server/middleware/auth.js';
app.use(authMiddleware);
app.use(extractUser); app.use(extractUser);
// Static Uploads // Static Uploads
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads'))); app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
// Routes import pool from '../src/server/config/postgres.js';
// ... (existing routes)
app.get('/api/admin/migrate-to-gpi', async (req, res) => {
const TABLES = [
"organizations",
"users",
"user_organizations",
"projects",
"parts",
"painting_schemes",
"inspections",
"instruments",
"stock_items",
"stock_movements"
];
try {
const client = await pool.connect();
await client.query("CREATE SCHEMA IF NOT EXISTS gpi;");
const results = [];
for (const table of TABLES) {
try {
await client.query(`ALTER TABLE public.${table} SET SCHEMA gpi;`);
results.push({ table, status: 'success' });
} catch (err: any) {
results.push({ table, status: 'failed', error: err.message });
}
}
client.release();
res.json({ message: "Migration completed", results });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
app.use('/api/projects', projectRoutes); app.use('/api/projects', projectRoutes);
app.use('/api/parts', partRoutes); app.use('/api/parts', partRoutes);
+10 -8
View File
@@ -1,18 +1,19 @@
import dotenv from 'dotenv';
dotenv.config();
import type { VercelRequest, VercelResponse } from '@vercel/node'; import type { VercelRequest, VercelResponse } from '@vercel/node';
import app from './app.js'; import app from './app.js';
import { connectDB } from '../src/server/config/database.js'; import mongoose from 'mongoose';
export default async function handler(req: VercelRequest, res: VercelResponse) { export default async function handler(req: VercelRequest, res: VercelResponse) {
try { try {
console.log('--- API CALL:', req.url); console.log('--- API CALL:', req.url);
// Conecta ao Banco de Dados (Supabase/Postgres) // Inline connection to avoid external file dependency issues during boot
await connectDB(); 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);
}
// Passa o controle para o Express // Use the localized app.js
return app(req, res); return app(req, res);
} catch (error: unknown) { } catch (error: unknown) {
console.error('SERVERLESS BOOT ERROR:', error); console.error('SERVERLESS BOOT ERROR:', error);
@@ -20,7 +21,8 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
return res.status(500).json({ return res.status(500).json({
error: 'Serverless Boot Error', error: 'Serverless Boot Error',
message: message, message: message,
path: req.url path: req.url,
suggestion: 'Check Vercel Logs for module resolution errors'
}); });
} }
} }
-57
View File
@@ -1,57 +0,0 @@
TRUNCATE gpi.projects, gpi.technical_data_sheets, gpi.painting_schemes, gpi.parts, gpi.stock_items, gpi.notifications, gpi.yield_studies, gpi.geometry_types, gpi.messages, gpi.stock_audit_logs, gpi.system_settings, gpi.stored_files CASCADE;
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('8d3d82a6-584b-4a3f-9202-72b4d6cac8f1', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Chaparia comum', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('f994e015-dece-4d57-a112-b4813ba0f7b1', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Chapas de pisos (>0,5m²)', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('e19abd8b-f1b5-4eb2-a232-db38a5c2a306', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Peças diversas (outras)', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('cc789669-5801-4885-be69-e981adf73682', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Guarda-corpo/escada', 50, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('9c1b96c3-f41d-4062-afd9-ba600ccbd441', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Vigas leves', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('37fc80e8-7c67-44cf-9dcf-9571bbc936c5', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Calhas', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('d464cab2-2053-4b22-a770-6db47c40e508', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Telhas', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('654f690b-dd45-4d89-8d05-81b0a3963989', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Vigas médias', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('8e4a569d-2a03-4740-bd2a-b0077a03ef4f', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Vigas pesadas', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('41aaea81-3880-43fe-8455-3cf5e8ecee74', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Cantoneiras', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('97f82995-bfa9-4af2-8b16-45664d180249', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Tubulações (ret/red) <100mm', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('ff89d5bd-5b16-4462-8d5e-7ff79062b510', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Tubulações (ret/red) >100mm', 20, NOW());
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('900da2be-c901-453d-9dff-7aff16dd9a35', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Revran PHZ 528', 'RENNER', NULL, 'Epóxi (N-2630)', NULL, NULL, 82, NULL, NULL, 8.2, 122, 183, 100, 150, '420.0000', '100,0 : 101,0', '1,0 : 1,0', 100, 820, 10, '', '2026-01-24T11:26:09.181Z', '2026-02-06T18:55:55.697Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('d5392776-e6a4-41f9-8c75-65660563c978', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Revran DST QD 721', 'RENNER', NULL, 'Epóxi dupla função', NULL, NULL, 80, NULL, NULL, 6.67, 150, 312, 120, 250, '420.0000', '100,0 : 88.0000', '1,0 : 1,0', 120, 800, 10, '', '2026-01-24T11:26:09.340Z', '2026-02-06T18:54:37.426Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('8e099c07-7ad4-4d94-8846-cd0c6dd08e06', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Oxibar DFC 707', 'RENNER', NULL, 'Epóxi dupla função', NULL, NULL, 82, NULL, NULL, 8.2, 121, 244, 100, 200, '420.0000', '100,0 : 15,0', '4,0 : 1,0', 100, 820, 10, '', '2026-01-30T10:53:06.720Z', '2026-02-06T18:54:01.241Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('3c0651fc-93e9-436c-a6b8-72c1d4c848a9', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Rethane FHB 658', 'RENNER', NULL, 'Poliuretano (PU)', NULL, NULL, 70, NULL, NULL, 11.7, 60, 125, 86, 178, '440.0000', '100,0 : 18,0', '4,0 : 1,0', 60, 700, 10, '', '2026-01-30T14:36:46.654Z', '2026-02-06T18:47:20.843Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('be6eaa90-03ff-4028-bae8-2928fbdfb254', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Revran DST PLUS 727', 'RENNER', NULL, 'Epóxi dupla função', NULL, NULL, 80, NULL, NULL, 6.7, 150, 188, 120, 150, '420.0000', '100,0 : 95,0', '1,0 : 1,0', 120, 800, 10, '', '2026-02-06T18:50:27.668Z', '2026-02-06T18:52:53.633Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('732f2322-6fab-4e1e-b10b-c5928edc2351', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Diluente para Epoxi (Revran)', 'RENNER', '420.0000', 'THINNER', 30, 'diluente para epoxi da linha Revran e demais que utilizam o mesmo codigo', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2026-02-12T14:33:21.603Z', '2026-02-12T14:33:21.603Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('c65de128-fa1e-4a93-bd3c-780d6de39199', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Diluente PU e Esmalte Sintético (Rethanne)', 'RENNER', '440.0000', 'THINNER', 10, 'diluente para tintas da linha Rethanne e demais que utilizam o mesmo codigo', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2026-02-12T14:35:04.836Z', '2026-02-12T14:54:04.925Z');
INSERT INTO gpi.projects (id, organization_id, name, client, start_date, end_date, environment, technician, weight_kg, status, created_at, updated_at) VALUES ('1c7ed38a-a7b8-4ab4-965c-b74cbf9a6291', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'B121 - Residência Bia', 'FairBanks', '2025-12-01T00:00:00.000Z', '2026-03-31T00:00:00.000Z', 'C3', 'Eng. Baldon', 32165, 'active', '2026-02-09T10:21:58.928Z', '2026-02-09T10:24:35.727Z');
INSERT INTO gpi.projects (id, organization_id, name, client, start_date, end_date, environment, technician, weight_kg, status, created_at, updated_at) VALUES ('28ac813e-0d9d-4ef3-8b4e-ba34224f2e55', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'B129 - Rampa Toyota', 'Loja Toyota', '2026-02-11T00:00:00.000Z', '2026-04-15T00:00:00.000Z', 'C3', 'Eng. Baldon', 12430, 'active', '2026-02-09T10:36:24.278Z', '2026-02-25T17:54:11.346Z');
INSERT INTO gpi.projects (id, organization_id, name, client, start_date, end_date, environment, technician, weight_kg, status, created_at, updated_at) VALUES ('30db3f02-8631-4c11-bba9-74b40c6ebb1c', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'B128 - Cobertura Bridgestone', 'BRIDGESTONE', '2026-01-15T00:00:00.000Z', '2026-03-31T00:00:00.000Z', 'C4', 'Eng. Baldon', 6880, 'active', '2026-02-09T10:40:53.554Z', '2026-02-09T10:40:53.554Z');
INSERT INTO gpi.painting_schemes (id, organization_id, project_id, name, type, coat, solids_volume, yield_theoretical, eps_min, eps_max, dilution, manufacturer, color, paint_consumption, thinner_consumption, paint_id, thinner_id, color_hex, thinner_symbol, notes, created_at, updated_at) VALUES ('5ef2647b-4edd-48cf-9ae3-4c7e89d2ed4d', 'e47e6210-4879-4e5b-bf21-9285d2713123', '1c7ed38a-a7b8-4ab4-965c-b74cbf9a6291', 'Revran DST QD 721', 'epoxy', 'Primer', 80, 6.67, 100, 140, 10, 'RENNER', 'Cinza N6.5', 12, 15, '6974ac5112e9ddef6c122b81', NULL, '#dedede', '420.0000', '', NOW(), NOW());
INSERT INTO gpi.painting_schemes (id, organization_id, project_id, name, type, coat, solids_volume, yield_theoretical, eps_min, eps_max, dilution, manufacturer, color, paint_consumption, thinner_consumption, paint_id, thinner_id, color_hex, thinner_symbol, notes, created_at, updated_at) VALUES ('98efc99e-300c-4e3d-bb93-bcd7e27f25f0', 'e47e6210-4879-4e5b-bf21-9285d2713123', '28ac813e-0d9d-4ef3-8b4e-ba34224f2e55', 'Revran PHZ 528', 'epoxy', 'Primer', 82, 8.2, 100, 140, 15, 'RENNER', 'Cinza Grafite', 12, 20, '6974ac5112e9ddef6c122b7e', NULL, '#6b6b6b', '420.0000', '', NOW(), NOW());
INSERT INTO gpi.painting_schemes (id, organization_id, project_id, name, type, coat, solids_volume, yield_theoretical, eps_min, eps_max, dilution, manufacturer, color, paint_consumption, thinner_consumption, paint_id, thinner_id, color_hex, thinner_symbol, notes, created_at, updated_at) VALUES ('d49a9fae-1dc2-42ef-9d12-200d97207e59', 'e47e6210-4879-4e5b-bf21-9285d2713123', '30db3f02-8631-4c11-bba9-74b40c6ebb1c', 'Revran PHZ 528', 'epoxy', 'Primer', 82, 8.2, 100, 120, 10, 'RENNER', 'Cinza N6.5', 12, 15, '6974ac5112e9ddef6c122b7e', NULL, '#dedede', '420.0000', '', NOW(), NOW());
INSERT INTO gpi.painting_schemes (id, organization_id, project_id, name, type, coat, solids_volume, yield_theoretical, eps_min, eps_max, dilution, manufacturer, color, paint_consumption, thinner_consumption, paint_id, thinner_id, color_hex, thinner_symbol, notes, created_at, updated_at) VALUES ('6233be81-1de2-4a6f-9589-60db5b5dfc0f', 'e47e6210-4879-4e5b-bf21-9285d2713123', '30db3f02-8631-4c11-bba9-74b40c6ebb1c', 'Oxibar DFC 707', 'epoxy', 'Acabamento', 82, 8.2, 100, 120, 10, 'RENNER', 'Branco', 10, 15, '697c8d9210fff6d9214c398f', NULL, '#ffffff', '420.0000', '', NOW(), NOW());
INSERT INTO gpi.parts (id, organization_id, project_id, description, dimensions, weight, type, area, quantity, notes, created_at, updated_at) VALUES ('d9a46d4e-65bd-4fab-a45a-9c35f0373f6a', 'e47e6210-4879-4e5b-bf21-9285d2713123', '30db3f02-8631-4c11-bba9-74b40c6ebb1c', 'Peças diversas (outras)', NULL, 6880, 'Peças diversas (outras)', 450, 1, 'Colunas , vigas e terças (perfi UE) . Todos similares em area de pintura por tipo de peça', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('c71251e8-d5ad-4bda-9af5-4ee58a0d25a6', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2256132', 160, 'L', '697cc1fe5e1c0a9d4ebb92e4', NULL, '2026-07-12T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('35a3ee6a-6842-41c5-9348-569ffd18630b', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2258096', 14.4, 'L', '6974ac5112e9ddef6c122b81', NULL, '2027-01-11T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('1283b55c-2ceb-4285-b0b6-b10fcd9aa469', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2280862', 80, 'L', '6974ac5112e9ddef6c122b81', NULL, '2026-12-08T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('1b7e7116-8c3f-4b26-b789-bf1ced9563fa', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2256094', 18, 'L', '697cc1fe5e1c0a9d4ebb92e4', NULL, '2027-03-04T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('68297f1e-4248-45de-9447-d42aafd22665', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2217893', 60, 'L', '6974ac5112e9ddef6c122b81', NULL, '2026-01-30T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('d7c9eb46-300b-41b8-8055-5ef59e2fb3e8', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2259482', 20, 'L', '697cc1fe5e1c0a9d4ebb92e4', NULL, '2026-12-01T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('79a45e7b-b2e6-4c3f-852f-739028321142', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2293040', 80, 'L', '697c8d9210fff6d9214c398f', NULL, '2027-08-30T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('0316dc9b-5fe7-4404-aff2-ff4abd9aeae3', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Item Vencido', 'O item 0107/24 - Lote 2217893 venceu em 29/01/2026.', 'error', false, false, '2026-02-12T12:37:19.420Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('39a10f4f-6c34-4bba-b863-9adb347bcb34', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Estoque Baixo!', 'O item 0305/25 (Rethane FHB 658) está com apenas 18L. (Mínimo: 20L)', 'error', false, false, '2026-02-12T12:40:46.684Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('eda70cc2-968b-4cce-a29e-7cd147b3a8c3', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Estoque Baixo (Total)', 'O produto Revran DST QD 721 (Cor: Cinza N6.5) atingiu o nível crítico. Total: 94.4L. (Mínimo: 100L)', 'error', false, false, '2026-02-12T13:26:38.421Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('d6d4cf44-bf16-4e24-a09f-3d33c0d2ec24', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Nova Ficha Técnica', 'A ficha técnica "Diluente para Epoxi (Revran)" (RENNER) foi adicionada à biblioteca.', 'info', false, false, '2026-02-12T14:33:21.615Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('6d470211-1f46-4f44-929e-f4048e71da30', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Nova Ficha Técnica', 'A ficha técnica "Diluente para tintas PU e Esmalte Sintético (Rethanne)" (RENNER) foi adicionada à biblioteca.', 'info', false, false, '2026-02-12T14:35:04.846Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('ea8be6bd-bd64-4719-8154-87df66ea07a4', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Recebimento de Material', 'Recebido: 12L de 4444 (Lote: N/A).', 'info', false, false, '2026-02-12T19:01:56.081Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('bf315f52-984f-436e-8323-2f17841807b1', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Estoque Baixo (Total)', 'O produto Diluente para Epoxi (Revran) (Cor: N/A) atingiu o nível crítico. Total: 7.0L. (Mínimo: 10L)', 'error', false, false, '2026-02-12T19:02:57.679Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('e2514cbc-0dd4-48cb-a4dd-336574dcc510', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Atualização de Obra', 'O peso da obra "B129 - Rampa Toyota" foi atualizado para 11925kg.', 'info', true, false, '2026-02-25T13:08:33.461Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('d7ef7575-c21b-499d-b9dc-bfb42d7302ac', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Atualização de Obra', 'O peso da obra "B129 - Rampa Toyota" foi atualizado para 12430kg.', 'info', false, false, '2026-02-25T17:54:11.359Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('ca87d8aa-e4f6-43bc-9316-6da0e2e185b4', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Item Vencido', 'O item 0107/24 - Lote 2217893 venceu em 1/30/2026.', 'error', false, false, '2026-03-14T15:52:41.972Z');
INSERT INTO gpi.yield_studies (id, organization_id, name, data_sheet_id, target_dft, dilution_percent, categories, total_weight, estimated_paint_volume, estimated_reducer_volume, estimated_paint_volume_by_area, estimated_reducer_volume_by_area, average_complexity, created_at, updated_at) VALUES ('6f9f5a49-90cf-47da-815d-3e726631e9fe', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Novo Estudo - 04/02/26 18:41', '697cc1fe5e1c0a9d4ebb92e4', 120, 10, '[{"name":"Calhas","weight":1.3,"area":160,"historicalYield":12,"historicalDft":120,"efficiency":10,"_id":"6983bd1c94117d5e52856de1"},{"name":"Vigas leves","weight":2,"area":200,"historicalYield":12,"historicalDft":100,"efficiency":24,"_id":"6983bf5294117d5e52856dfc"}]', 3.3, 48.91, 4.89, 75.59, 7.56, 1, NOW(), NOW());
INSERT INTO gpi.yield_studies (id, organization_id, name, data_sheet_id, target_dft, dilution_percent, categories, total_weight, estimated_paint_volume, estimated_reducer_volume, estimated_paint_volume_by_area, estimated_reducer_volume_by_area, average_complexity, created_at, updated_at) VALUES ('b9663cc7-0abe-4955-b2fc-05258bae6e88', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Novo Estudo - 05/02/26 16:07', '697cc1fe5e1c0a9d4ebb92e4', 120, 10, '[{"name":"Estrutura Primária","weight":1000,"area":0,"historicalYield":150,"historicalDft":120,"efficiency":85,"_id":"6984ea6a71299e618edb7c7c"}]', 1000, 0, 0, NULL, NULL, 1, NOW(), NOW());
INSERT INTO gpi.yield_studies (id, organization_id, name, data_sheet_id, target_dft, dilution_percent, categories, total_weight, estimated_paint_volume, estimated_reducer_volume, estimated_paint_volume_by_area, estimated_reducer_volume_by_area, average_complexity, created_at, updated_at) VALUES ('4d731b8f-968f-4039-8d3f-98d9d2327fd1', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Novo Estudo - 09/02/26 10:25', '698637f3059732a5be0b3535', 120, 10, '[{"name":"Estrutura Primária","weight":1000,"area":0,"historicalYield":150,"historicalDft":120,"efficiency":85,"_id":"6989e053e35a6917a7779bc6"}]', 1000, 0, 0, NULL, NULL, 1, NOW(), NOW());
INSERT INTO gpi.yield_studies (id, organization_id, name, data_sheet_id, target_dft, dilution_percent, categories, total_weight, estimated_paint_volume, estimated_reducer_volume, estimated_paint_volume_by_area, estimated_reducer_volume_by_area, average_complexity, created_at, updated_at) VALUES ('bd7d8c62-4a14-4937-bdb9-bfa363e93151', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'ESTUDO TOYOTA OFICIAL - 25/02/26', '6974ac5112e9ddef6c122b7e', 120, 20, '[{"name":"Vigas leves","weight":0.62,"area":18,"historicalYield":11,"historicalDft":120,"efficiency":70,"_id":"699ef49982121ff9fb05b214"},{"name":"Vigas leves","weight":3.86,"area":152,"historicalYield":12,"historicalDft":100,"efficiency":70,"_id":"699ef8c425baf4a3df44f593"},{"name":"Chapas de pisos (>0,5m²)","weight":6.2,"area":161,"historicalYield":7,"historicalDft":100,"efficiency":90,"_id":"699ef8c425baf4a3df44f594"},{"name":"Vigas leves","weight":1.7,"area":62,"historicalYield":13,"historicalDft":100,"efficiency":65,"_id":"699ef8c425baf4a3df44f595"}]', 12.379999999999999, 118.64, 23.73, 75.68, 15.14, 1, NOW(), NOW());
INSERT INTO gpi.messages (id, organization_id, message, from_user_id, to_user_id, is_read, created_at) VALUES ('3f4a7b79-1fd8-4f5a-bf8d-ebbb66e443c9', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'teste de mensagem 1', NULL, NULL, false, '2026-02-11T10:50:53.420Z');
INSERT INTO gpi.stock_audit_logs (id, organization_id, stock_item_id, action, quantity_before, quantity_after, performed_by, details, created_at) VALUES ('2d7ea21b-60bc-4883-b3b6-702491cb72dd', 'e47e6210-4879-4e5b-bf21-9285d2713123', '6984ba67b558533c8f30f419', 'UPDATE', NULL, NULL, NULL, 'Edição de Movimentação (ADJUSTMENT): Qtd -15 -> -14', '2026-02-06T20:42:23.410Z');
INSERT INTO gpi.stock_audit_logs (id, organization_id, stock_item_id, action, quantity_before, quantity_after, performed_by, details, created_at) VALUES ('bf23c460-67c3-465c-9f47-ce6a9e7a55d8', 'e47e6210-4879-4e5b-bf21-9285d2713123', '6984ba67b558533c8f30f419', 'UPDATE', NULL, NULL, NULL, 'Edição de Movimentação (ADJUSTMENT): Qtd -14 -> -14', '2026-02-06T20:42:46.091Z');
INSERT INTO gpi.stock_audit_logs (id, organization_id, stock_item_id, action, quantity_before, quantity_after, performed_by, details, created_at) VALUES ('9288f2ea-11af-4567-bc3c-26a760216969', 'e47e6210-4879-4e5b-bf21-9285d2713123', '6984ba67b558533c8f30f419', 'UPDATE', NULL, NULL, NULL, 'Edição de Movimentação (ADJUSTMENT): Qtd -14 -> -14', '2026-02-06T20:47:21.549Z');
INSERT INTO gpi.stock_audit_logs (id, organization_id, stock_item_id, action, quantity_before, quantity_after, performed_by, details, created_at) VALUES ('6dd63755-920d-4845-8740-6a371b65b9c5', 'e47e6210-4879-4e5b-bf21-9285d2713123', '6984ba67b558533c8f30f419', 'UPDATE', NULL, NULL, NULL, 'Edição de Movimentação (#4 ADJUSTMENT): Qtd -3 -> -2', '2026-02-06T21:11:30.917Z');
INSERT INTO gpi.system_settings (id, organization_id, key, value, updated_at) VALUES ('01ce7ceb-5fdd-4c4e-9e05-3807eba280e0', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'global', '{"appName":"SteelPaint","appSubtitle":"Gestão de Pintura Industrial","createdAt":"2026-02-04T10:42:20.738Z","updatedAt":"2026-02-12T20:20:05.747Z","__v":0,"appLogoUrl":"/api/system-settings/logo-image/logo-1770927600175-e0488184-c2a1-4ba1-a8f4-eac55e875c94.png","updatedBy":"admtracksteel@gmail.com"}', '2026-02-12T20:20:05.747Z');
INSERT INTO gpi.stored_files (id, organization_id, filename, mime_type, size_bytes, storage_path, metadata, created_at) VALUES ('52cdfbc6-e9c9-4759-b59c-d1b8a76ea617', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'E-412_Revran-DST-PLUS-727-BV_V01.pdf', NULL, 249614, NULL, NULL, NULL);
INSERT INTO gpi.stored_files (id, organization_id, filename, mime_type, size_bytes, storage_path, metadata, created_at) VALUES ('52885971-7e82-4ed7-afaf-b908c74c67eb', 'e47e6210-4879-4e5b-bf21-9285d2713123', '0590_Revran-DST-PLUS-727_V06.pdf', NULL, 182807, NULL, NULL, NULL);
-17
View File
@@ -1,17 +0,0 @@
-- Criar tabela de mensagens no schema gpi
CREATE TABLE IF NOT EXISTS gpi.messages (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
organization_id uuid REFERENCES gpi.organizations(id) ON DELETE CASCADE,
from_user_id uuid REFERENCES gpi.users(id) ON DELETE SET NULL,
to_user_id uuid REFERENCES gpi.users(id) ON DELETE SET NULL,
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()
);
-- Permissões para as roles do Supabase
GRANT ALL ON gpi.messages TO postgres, anon, authenticated, service_role;
-31
View File
@@ -1,31 +0,0 @@
-- Script para expor as tabelas do schema 'gpi' no schema 'public' via views
-- Isso facilita o acesso pela API padrão do Supabase/PostgREST
-- 1. Garantir que o schema public existe
CREATE SCHEMA IF NOT EXISTS public;
-- 2. Criar views no schema public para cada tabela do gpi
CREATE OR REPLACE VIEW public.organizations AS SELECT * FROM gpi.organizations;
CREATE OR REPLACE VIEW public.users AS SELECT * FROM gpi.users;
CREATE OR REPLACE VIEW public.projects AS SELECT * FROM gpi.projects;
CREATE OR REPLACE VIEW public.parts AS SELECT * FROM gpi.parts;
CREATE OR REPLACE VIEW public.painting_schemes AS SELECT * FROM gpi.painting_schemes;
CREATE OR REPLACE VIEW public.application_records AS SELECT * FROM gpi.application_records;
CREATE OR REPLACE VIEW public.inspections AS SELECT * FROM gpi.inspections;
CREATE OR REPLACE VIEW public.technical_data_sheets AS SELECT * FROM gpi.technical_data_sheets;
CREATE OR REPLACE VIEW public.yield_studies AS SELECT * FROM gpi.yield_studies;
CREATE OR REPLACE VIEW public.instruments AS SELECT * FROM gpi.instruments;
CREATE OR REPLACE VIEW public.stock_items AS SELECT * FROM gpi.stock_items;
CREATE OR REPLACE VIEW public.stock_movements AS SELECT * FROM gpi.stock_movements;
CREATE OR REPLACE VIEW public.notifications AS SELECT * FROM gpi.notifications;
CREATE OR REPLACE VIEW public.geometry_types AS SELECT * FROM gpi.geometry_types;
CREATE OR REPLACE VIEW public.messages AS SELECT * FROM gpi.messages;
CREATE OR REPLACE VIEW public.stock_audit_logs AS SELECT * FROM gpi.stock_audit_logs;
CREATE OR REPLACE VIEW public.system_settings AS SELECT * FROM gpi.system_settings;
CREATE OR REPLACE VIEW public.stored_files AS SELECT * FROM gpi.stored_files;
-- 3. Dar permissões de acesso às views
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO postgres, anon, authenticated, service_role;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO postgres, anon, authenticated, service_role;
SELECT '18 views criadas no schema public com sucesso!' AS resultado;
-13
View File
@@ -1,13 +0,0 @@
-- Habilitar schema gpi na PostgREST API
-- As permissões abaixo são suficientes para a PostgREST expor o schema
-- Permissões
GRANT USAGE ON SCHEMA gpi TO postgres, anon, authenticated, service_role;
-- Grant em todas as tabelas existentes do schema gpi
GRANT ALL ON ALL TABLES IN SCHEMA gpi TO postgres, anon, authenticated, service_role;
-- Grant em sequências
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA gpi TO postgres, anon, authenticated, service_role;
SELECT 'Schema gpi habilitado na PostgREST API!' AS result;
-337
View File
@@ -1,337 +0,0 @@
DROP SCHEMA IF EXISTS gpi CASCADE;
-- Criar schema gpi
CREATE SCHEMA IF NOT EXISTS gpi;
-- Tabela organizations
CREATE TABLE IF NOT EXISTS gpi.organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Inserir organização padrão
INSERT INTO gpi.organizations (id, name)
VALUES ('e47e6210-4879-4e5b-bf21-9285d2713123', 'Organização Migrada')
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS gpi.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
logto_id TEXT UNIQUE,
email TEXT NOT NULL,
name TEXT,
role TEXT DEFAULT 'user',
is_banned BOOLEAN DEFAULT false,
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS gpi.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
client TEXT,
start_date DATE,
end_date DATE,
environment TEXT,
technician TEXT,
weight_kg DECIMAL(10,2),
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'archived')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela parts
CREATE TABLE IF NOT EXISTS gpi.parts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
description TEXT,
dimensions TEXT,
weight DECIMAL(10,3),
type TEXT,
area DECIMAL(10,3),
complexity INTEGER DEFAULT 1,
quantity INTEGER NOT NULL DEFAULT 1,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela painting_schemes
CREATE TABLE IF NOT EXISTS gpi.painting_schemes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT,
coat TEXT,
solids_volume DECIMAL(12,3),
yield_theoretical DECIMAL(12,3),
eps_min DECIMAL(12,3),
eps_max DECIMAL(12,3),
dilution DECIMAL(12,3),
manufacturer TEXT,
color TEXT,
paint_consumption DECIMAL(12,3),
thinner_consumption DECIMAL(12,3),
paint_id TEXT,
thinner_id TEXT,
color_hex TEXT,
thinner_symbol TEXT,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela application_records
CREATE TABLE IF NOT EXISTS gpi.application_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
coat_stage TEXT NOT NULL,
piece_description TEXT,
date DATE,
operator TEXT,
real_weight DECIMAL(10,3),
volume_used DECIMAL(10,3),
area_painted DECIMAL(10,3),
wet_thickness_avg DECIMAL(6,2),
dry_thickness_calc DECIMAL(6,2),
real_yield DECIMAL(10,3),
method TEXT,
diluent_used DECIMAL(10,3),
items JSONB,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela inspections
CREATE TABLE IF NOT EXISTS gpi.inspections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
application_record_id UUID REFERENCES gpi.application_records(id),
stock_item_id TEXT,
instrument_id TEXT,
type TEXT CHECK (type IN ('painting', 'surface_treatment')),
date DATE,
inspector TEXT,
part_temperature DECIMAL(6,2),
weight_kg DECIMAL(10,3),
appearance TEXT,
defects TEXT,
photos TEXT[],
piece_description TEXT,
eps_points DECIMAL(6,2)[],
adhesion_test TEXT,
batch TEXT,
treatment_executor TEXT,
treatment_type TEXT,
cleaning_degree TEXT,
roughness_readings DECIMAL(6,2)[],
flash_rust TEXT,
temperature DECIMAL(6,2),
relative_humidity DECIMAL(5,2),
period TEXT CHECK (period IN ('morning', 'afternoon', 'night')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela technical_data_sheets
CREATE TABLE IF NOT EXISTS gpi.technical_data_sheets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
manufacturer TEXT,
type TEXT,
file_url TEXT,
upload_date DATE,
solids_volume DECIMAL(12,3),
density DECIMAL(12,3),
mixing_ratio TEXT,
yield_theoretical DECIMAL(12,3),
wft_min DECIMAL(12,3),
wft_max DECIMAL(12,3),
dft_min DECIMAL(12,3),
dft_max DECIMAL(12,3),
reducer TEXT,
mixing_ratio_weight TEXT,
mixing_ratio_volume TEXT,
dft_reference DECIMAL(12,3),
yield_factor DECIMAL(12,3),
dilution DECIMAL(12,3),
notes TEXT,
manufacturer_code TEXT,
min_stock DECIMAL(12,3),
typical_application TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela yield_studies
CREATE TABLE IF NOT EXISTS gpi.yield_studies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
data_sheet_id TEXT NOT NULL,
target_dft DECIMAL(6,2) NOT NULL,
dilution_percent DECIMAL(5,2) NOT NULL,
categories JSONB NOT NULL,
total_weight DECIMAL(10,3),
estimated_paint_volume DECIMAL(10,3),
estimated_reducer_volume DECIMAL(10,3),
estimated_paint_volume_by_area DECIMAL(10,3),
estimated_reducer_volume_by_area DECIMAL(10,3),
average_complexity DECIMAL(3,1),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela instruments
CREATE TABLE IF NOT EXISTS gpi.instruments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
serial_number TEXT,
manufacturer TEXT,
model TEXT,
last_calibration DATE,
next_calibration DATE,
status TEXT DEFAULT 'active',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela stock_items
CREATE TABLE IF NOT EXISTS gpi.stock_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT,
type TEXT,
batch_number TEXT,
quantity DECIMAL(12,3) DEFAULT 0,
unit TEXT DEFAULT 'L',
data_sheet_id TEXT,
location TEXT,
expiration_date DATE,
status TEXT DEFAULT 'available',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela stock_movements
CREATE TABLE IF NOT EXISTS gpi.stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
stock_item_id UUID REFERENCES gpi.stock_items(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('in', 'out', 'adjustment')),
quantity DECIMAL(10,3) NOT NULL,
reason TEXT,
performed_by TEXT,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela notifications
CREATE TABLE IF NOT EXISTS gpi.notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
type TEXT DEFAULT 'info' CHECK (type IN ('info', 'warning', 'error', 'success')),
is_read BOOLEAN DEFAULT false,
is_archived BOOLEAN DEFAULT false,
archived_by TEXT[],
deleted_by TEXT[],
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela geometry_types
CREATE TABLE IF NOT EXISTS gpi.geometry_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
efficiency_loss DECIMAL(5,2),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela messages
CREATE TABLE IF NOT EXISTS gpi.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
from_user_id UUID,
to_user_id UUID,
message TEXT NOT NULL,
is_read BOOLEAN DEFAULT false,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela stock_audit_logs
CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
stock_item_id TEXT,
action TEXT NOT NULL,
quantity_before DECIMAL(12,3),
quantity_after DECIMAL(12,3),
performed_by TEXT,
details TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela system_settings
CREATE TABLE IF NOT EXISTS gpi.system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
key TEXT UNIQUE NOT NULL,
value JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela stored_files (Metadados dos PDFs)
CREATE TABLE IF NOT EXISTS gpi.stored_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
filename TEXT NOT NULL,
mime_type TEXT,
size_bytes BIGINT,
storage_path TEXT, -- Caminho no Supabase Storage
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Habilitar PostgREST para o schema gpi
GRANT USAGE ON SCHEMA gpi TO postgres, anon, authenticated, service_role;
-- Grant permissions em todas as tabelas
GRANT ALL ON TABLE gpi.organizations TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.users TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.projects TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.parts TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.painting_schemes TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.application_records TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.inspections TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.technical_data_sheets TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.yield_studies TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.instruments TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.stock_items TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.stock_movements TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.notifications TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.geometry_types TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.messages TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.stock_audit_logs TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.system_settings TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.stored_files TO postgres, anon, authenticated, service_role;
-- Grant sequences
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA gpi TO postgres, anon, authenticated, service_role;
SELECT 'Schema gpi criado com sucesso!' AS result;
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAp2sxQE/eO30etME9tLKJjguVPUg3W8k3j2H7F/kBVogAAAJAeNjMSHjYz
EgAAAAtzc2gtZWQyNTUxOQAAACAp2sxQE/eO30etME9tLKJjguVPUg3W8k3j2H7F/kBVog
AAAEDLm78AwM6lbNoz7iVUh1xvlphZzNhitquW4jHyR7lIhCnazFAT947fR60wT20somOC
5U9SDdbyTePYfsX+QFWiAAAAC2FudGlncmF2aXR5AQI=
-----END OPENSSH PRIVATE KEY-----
+1
View File
@@ -0,0 +1 @@
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>SteelPaint</title> <title>GPI - JWT VERSION 1.6</title>
</head> </head>
<body> <body>
-147
View File
@@ -1,147 +0,0 @@
import { MongoClient } from 'mongodb';
import { execSync } from 'child_process';
import crypto from 'crypto';
import fs from 'fs';
const MONGO_URI = "mongodb+srv://admtracksteel:mongodb26@cluster0.a4xiilu.mongodb.net/ts_gpi";
const DB_NAME = "ts_gpi";
const DEFAULT_ORG_ID = "e47e6210-4879-4e5b-bf21-9285d2713123";
const client = new MongoClient(MONGO_URI);
const idMap = new Map();
function getUUID(mongoId) {
if (!mongoId) return null;
const mid = mongoId.toString();
if (idMap.has(mid)) return idMap.get(mid);
const newUuid = crypto.randomUUID();
idMap.set(mid, newUuid);
return newUuid;
}
function sqlSafe(val) {
if (val === null || val === undefined) return 'NULL';
if (val instanceof Date) return `'${val.toISOString()}'`;
if (Array.isArray(val)) return `'{"${val.join('","')}"}'`;
if (typeof val === 'object' && val.toString && !val.getMonth) { // not a date
const s = val.toString();
return `'${s.replace(/'/g, "''")}'`;
}
if (typeof val === 'string') return `'${val.replace(/'/g, "''")}'`;
return val;
}
async function run() {
try {
await client.connect();
console.log("🚀 Conectado ao MongoDB Atlas...");
const db = client.db(DB_NAME);
let allSQL = [];
// 1. Geometrias (Geometry Types)
const geoms = await db.collection('geometrytypes').find().toArray();
console.log(`📐 Migrando ${geoms.length} tipos de geometria...`);
for (const g of geoms) {
allSQL.push(`INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES (${sqlSafe(getUUID(g._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(g.name)}, ${sqlSafe(g.efficiencyLoss)}, NOW());`);
}
// 2. Fichas Técnicas (Technical Data Sheets)
const tds = await db.collection('technicaldatasheets').find().toArray();
console.log(`📚 Migrando ${tds.length} fichas técnicas...`);
for (const t of tds) {
allSQL.push(`INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES (${sqlSafe(getUUID(t._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(t.name)}, ${sqlSafe(t.manufacturer)}, ${sqlSafe(t.manufacturerCode)}, ${sqlSafe(t.type)}, ${sqlSafe(t.minStock)}, ${sqlSafe(t.typicalApplication)}, ${sqlSafe(t.solidsVolume)}, ${sqlSafe(t.density)}, ${sqlSafe(t.mixingRatio)}, ${sqlSafe(t.yieldTheoretical)}, ${sqlSafe(t.wftMin)}, ${sqlSafe(t.wftMax)}, ${sqlSafe(t.dftMin)}, ${sqlSafe(t.dftMax)}, ${sqlSafe(t.reducer)}, ${sqlSafe(t.mixingRatioWeight)}, ${sqlSafe(t.mixingRatioVolume)}, ${sqlSafe(t.dftReference)}, ${sqlSafe(t.yieldFactor)}, ${sqlSafe(t.dilution)}, ${sqlSafe(t.notes)}, ${sqlSafe(t.createdAt)}, ${sqlSafe(t.updatedAt)});`);
}
// 3. Projetos (Projects)
const projs = await db.collection('projects').find().toArray();
console.log(`📦 Migrando ${projs.length} projetos...`);
for (const p of projs) {
allSQL.push(`INSERT INTO gpi.projects (id, organization_id, name, client, start_date, end_date, environment, technician, weight_kg, status, created_at, updated_at) VALUES (${sqlSafe(getUUID(p._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(p.name)}, ${sqlSafe(p.client)}, ${sqlSafe(p.startDate)}, ${sqlSafe(p.endDate)}, ${sqlSafe(p.environment)}, ${sqlSafe(p.technician)}, ${sqlSafe(p.weightKg)}, 'active', ${sqlSafe(p.createdAt)}, ${sqlSafe(p.updatedAt)});`);
}
// 4. Esquemas de Pintura (Painting Schemes)
const schemes = await db.collection('paintingschemes').find().toArray();
console.log(`🎨 Migrando ${schemes.length} esquemas de pintura...`);
for (const s of schemes) {
allSQL.push(`INSERT INTO gpi.painting_schemes (id, organization_id, project_id, name, type, coat, solids_volume, yield_theoretical, eps_min, eps_max, dilution, manufacturer, color, paint_consumption, thinner_consumption, paint_id, thinner_id, color_hex, thinner_symbol, notes, created_at, updated_at) VALUES (${sqlSafe(crypto.randomUUID())}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(getUUID(s.projectId))}, ${sqlSafe(s.name)}, ${sqlSafe(s.type)}, ${sqlSafe(s.coat)}, ${sqlSafe(s.solidsVolume)}, ${sqlSafe(s.yieldTheoretical)}, ${sqlSafe(s.epsMin)}, ${sqlSafe(s.epsMax)}, ${sqlSafe(s.dilution)}, ${sqlSafe(s.manufacturer)}, ${sqlSafe(s.color)}, ${sqlSafe(s.paintConsumption)}, ${sqlSafe(s.thinnerConsumption)}, ${sqlSafe(s.paintId)}, ${sqlSafe(s.thinnerId)}, ${sqlSafe(s.colorHex)}, ${sqlSafe(s.thinnerSymbol)}, ${sqlSafe(s.notes)}, NOW(), NOW());`);
}
// 5. Peças (Parts)
const parts = await db.collection('parts').find().toArray();
console.log(`🧩 Migrando ${parts.length} peças...`);
for (const pt of parts) {
allSQL.push(`INSERT INTO gpi.parts (id, organization_id, project_id, description, dimensions, weight, type, area, quantity, notes, created_at, updated_at) VALUES (${sqlSafe(getUUID(pt._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(getUUID(pt.projectId))}, ${sqlSafe(pt.description)}, ${sqlSafe(pt.dimensions)}, ${sqlSafe(pt.weight)}, ${sqlSafe(pt.type)}, ${sqlSafe(pt.area)}, ${sqlSafe(pt.quantity)}, ${sqlSafe(pt.notes)}, NOW(), NOW());`);
}
// 6. Itens de Estoque (Stock Items)
const stockItems = await db.collection('stockitems').find().toArray();
console.log(`🏭 Migrando ${stockItems.length} itens de estoque...`);
for (const item of stockItems) {
allSQL.push(`INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES (${sqlSafe(getUUID(item._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(item.name)}, ${sqlSafe(item.type)}, ${sqlSafe(item.batchNumber)}, ${sqlSafe(item.quantity)}, ${sqlSafe(item.unit)}, ${sqlSafe(item.dataSheetId)}, ${sqlSafe(item.location)}, ${sqlSafe(item.expirationDate)}, ${sqlSafe(item.status)}, ${sqlSafe(item.notes)}, NOW(), NOW());`);
}
// 7. Notificações (Notifications)
const notifs = await db.collection('notifications').find().toArray();
console.log(`🔔 Migrando ${notifs.length} notificações...`);
for (const n of notifs) {
allSQL.push(`INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES (${sqlSafe(getUUID(n._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(n.title)}, ${sqlSafe(n.message)}, ${sqlSafe(n.type)}, ${sqlSafe(n.isRead)}, ${sqlSafe(n.isArchived)}, ${sqlSafe(n.createdAt)});`);
}
// 8. Estudos de Rendimento (Yield Studies)
const yields = await db.collection('yieldstudies').find().toArray();
console.log(`📈 Migrando ${yields.length} estudos de rendimento...`);
for (const y of yields) {
allSQL.push(`INSERT INTO gpi.yield_studies (id, organization_id, name, data_sheet_id, target_dft, dilution_percent, categories, total_weight, estimated_paint_volume, estimated_reducer_volume, estimated_paint_volume_by_area, estimated_reducer_volume_by_area, average_complexity, created_at, updated_at) VALUES (${sqlSafe(getUUID(y._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(y.name)}, ${sqlSafe(y.dataSheetId)}, ${sqlSafe(y.targetDft)}, ${sqlSafe(y.dilutionPercent)}, ${sqlSafe(JSON.stringify(y.categories))}, ${sqlSafe(y.totalWeight)}, ${sqlSafe(y.estimatedPaintVolume)}, ${sqlSafe(y.estimatedReducerVolume)}, ${sqlSafe(y.estimatedPaintVolumeByArea)}, ${sqlSafe(y.estimatedReducerVolumeByArea)}, ${sqlSafe(y.averageComplexity)}, NOW(), NOW());`);
}
// 9. Mensagens (Messages)
const msgs = await db.collection('messages').find().toArray();
console.log(`💬 Migrando ${msgs.length} mensagens...`);
for (const m of msgs) {
allSQL.push(`INSERT INTO gpi.messages (id, organization_id, message, from_user_id, to_user_id, is_read, created_at) VALUES (${sqlSafe(getUUID(m._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(m.content || m.message)}, NULL, NULL, ${sqlSafe(m.isRead)}, ${sqlSafe(m.createdAt)});`);
}
// 10. Logs de Auditoria de Estoque
const auditLogs = await db.collection('stockauditlogs').find().toArray();
console.log(`📝 Migrando ${auditLogs.length} logs de auditoria...`);
for (const al of auditLogs) {
allSQL.push(`INSERT INTO gpi.stock_audit_logs (id, organization_id, stock_item_id, action, quantity_before, quantity_after, performed_by, details, created_at) VALUES (${sqlSafe(getUUID(al._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(al.stockItemId)}, ${sqlSafe(al.action)}, ${sqlSafe(al.quantityBefore)}, ${sqlSafe(al.quantityAfter)}, ${sqlSafe(al.performedBy)}, ${sqlSafe(al.details)}, ${sqlSafe(al.createdAt)});`);
}
// 11. Configurações do Sistema
const settings = await db.collection('systemsettings').find().toArray();
console.log(`⚙️ Migrando ${settings.length} configurações...`);
for (const st of settings) {
const { _id, settingsId, ...rest } = st;
allSQL.push(`INSERT INTO gpi.system_settings (id, organization_id, key, value, updated_at) VALUES (${sqlSafe(getUUID(_id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(settingsId || 'global')}, ${sqlSafe(JSON.stringify(rest))}, ${sqlSafe(st.updatedAt)});`);
}
// 12. Metadados de Arquivos
const files = await db.collection('storedfiles').find().toArray();
console.log(`📁 Migrando ${files.length} metadados de arquivos...`);
for (const f of files) {
allSQL.push(`INSERT INTO gpi.stored_files (id, organization_id, filename, mime_type, size_bytes, storage_path, metadata, created_at) VALUES (${sqlSafe(getUUID(f._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(f.filename)}, ${sqlSafe(f.mimeType)}, ${sqlSafe(f.size)}, ${sqlSafe(f.path)}, ${sqlSafe(JSON.stringify(f.metadata))}, ${sqlSafe(f.createdAt)});`);
}
if (allSQL.length === 0) {
console.log("⚠️ Nenhum dado novo encontrado.");
return;
}
console.log("💾 Executando SQL em massa no Supabase...");
fs.writeFileSync('bulk_migration_final.sql', `TRUNCATE gpi.projects, gpi.technical_data_sheets, gpi.painting_schemes, gpi.parts, gpi.stock_items, gpi.notifications, gpi.yield_studies, gpi.geometry_types, gpi.messages, gpi.stock_audit_logs, gpi.system_settings, gpi.stored_files CASCADE;\n` + allSQL.join('\n'));
execSync(`export PGPASSWORD=Xz0oyb6ArGYG5uAVTVwcvJxRrMuT7EIJ && docker exec -i supabase-db-h0oggskgs0ws0sco8kc4s8ws psql -U supabase_admin -d postgres < bulk_migration_final.sql`);
console.log("✅ Migração final concluída com sucesso!");
} catch (e) {
console.error("❌ ERRO DURANTE MIGRAÇÃO:", e.message);
} finally {
await client.close();
}
}
run();
+1146 -988
View File
File diff suppressed because it is too large Load Diff
+14 -15
View File
@@ -3,27 +3,22 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": {
"node": ">=20.12.0"
},
"scripts": { "scripts": {
"dev": "concurrently \"vite\" \"tsx watch src/server/index.ts\"", "dev": "concurrently \"vite\" \"tsx watch src/server/index.ts\"",
"build:client": "vite build", "build:client": "vite build",
"build:server": "tsc -p tsconfig.server.json", "build:server": "tsc -p tsconfig.server.json",
"prebuild": "npm install", "build": "npm run build:client && npm run build:server",
"build": "npm run build:client",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"start": "tsx src/server/index.ts" "start": "node dist/server/index.js"
}, },
"dependencies": { "dependencies": {
"@logto/node": "^2.4.0",
"@logto/react": "^4.0.13",
"@supabase/supabase-js": "^2.47.0",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/mongoose": "^5.11.96",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vercel/speed-insights": "^1.3.1", "@vercel/speed-insights": "^1.3.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -31,11 +26,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",
"jose": "^5.2.0", "jsonwebtoken": "^9.0.3",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mongodb": "^7.1.1", "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",
@@ -46,19 +43,21 @@
"serverless-http": "^4.0.0", "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",
"tsx": "^4.21.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.0", "@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",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@@ -72,7 +71,7 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^6.0.0", "vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0"
} }
} }
-1
View File
@@ -1 +0,0 @@
DROP SCHEMA IF EXISTS gpi CASCADE;
-20
View File
@@ -1,20 +0,0 @@
# Segredos e Credenciais
## Supabase
- URL: https://supabase.reifonas.cloud
- Service Role Key: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro
- Anon Key: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJzdWIiOiJ0cnVlIn0.fake_anon_key_for_gpi
## Logto
- URL: https://logto-admin-bzlued1boxl3t8ewsyn99an9.187.77.227.172.sslip.io
- App ID: gpi-app-final
- App Secret: gpi-secret-2026
- Callback URL: https://gpi.reifonas.cloud/callback
## Google OAuth ( usuário )
- Client ID: 516979078541-7f0cm821nls01eb2prtffi5t58phmgiq.apps.googleusercontent.com
- Client Secret: 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
## URLs de Produção
- App: https://gpi.reifonas.cloud
- API: https://gpi.reifonas.cloud/api
+34 -26
View File
@@ -1,6 +1,6 @@
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; // App v1.5 - JWT Migration Final Check
import { AuthProvider } from './context/AuthContext'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/useAuth'; import { AuthProvider, useAuth } from './context/AuthContext';
import { SystemSettingsProvider } from './context/SystemSettingsContext'; import { SystemSettingsProvider } from './context/SystemSettingsContext';
import { NotificationProvider } from './contexts/NotificationContext'; import { NotificationProvider } from './contexts/NotificationContext';
import { Layout } from './components/Layout'; import { Layout } from './components/Layout';
@@ -18,6 +18,7 @@ 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 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 }) => {
@@ -30,30 +31,19 @@ const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =
}; };
const AppContent: React.FC = () => { const AppContent: React.FC = () => {
const { isSignedIn, isLoading } = useAuth(); const { appUser, isLoading } = useAuth();
const location = useLocation();
if (isLoading) { if (isLoading) return <div className="flex h-screen items-center justify-center">Carregando...</div>;
return (
<div className="min-h-screen bg-surface-soft flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
// If not signed in and not on the callback page, show login // AppUser exists but hasn't selected an org yet (if your business logic requires orgs)
if (!isSignedIn && location.pathname !== '/callback') { if (appUser && !appUser.organizationId) {
return <Login />; return <OrganizationSelector />;
} }
return ( return (
<ToastProvider>
<SystemSettingsProvider>
<NotificationProvider>
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<ProjectList />} /> <Route path="/" element={<ProjectList />} />
<Route path="/login" element={<Login />} />
<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 />} />
@@ -99,22 +89,40 @@ const AppContent: React.FC = () => {
</DeveloperRoute> </DeveloperRoute>
} }
/> />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Layout> </Layout>
</NotificationProvider> );
</SystemSettingsProvider> };
</ToastProvider>
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>
<AuthProvider> <AuthProvider>
<AppContent /> <SystemSettingsProvider>
<NotificationProvider>
<MainRouter />
</NotificationProvider>
</SystemSettingsProvider>
</AuthProvider> </AuthProvider>
</Router> </ToastProvider>
); );
} }

Before

Width:  |  Height:  |  Size: 6.3 MiB

After

Width:  |  Height:  |  Size: 6.3 MiB

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

+28 -10
View File
@@ -2,11 +2,11 @@ import React, { useState } from 'react';
import NotificationBell from './NotificationBell'; import NotificationBell from './NotificationBell';
import { TeamPresence } from './TeamPresence'; import { TeamPresence } from './TeamPresence';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Menu, X, FolderOpen, Layers, ClipboardCheck, TrendingUp, Sun, Moon, HelpCircle, Shield, Wrench, Terminal, LayoutDashboard, Package, Thermometer, User } from 'lucide-react'; import { Menu, X, FolderOpen, Layers, ClipboardCheck, LogOut, TrendingUp, Sun, Moon, HelpCircle, Shield, Wrench, Terminal, LayoutDashboard, Package, Thermometer } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { TechnicalManual } from './TechnicalManual';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
import { TechnicalManual } from './TechnicalManual';
// import { useSystemSettings } from '../context/SystemSettingsContext';
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
@@ -19,7 +19,8 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches); return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches);
}); });
const location = useLocation(); const location = useLocation();
const { isAdmin, isUser, isDeveloper, appUser } = useAuth(); const { isAdmin, isUser, isDeveloper, appUser, logout } = useAuth();
// const { settings } = useSystemSettings();
// Helper to get role display name // Helper to get role display name
const getRoleDisplay = () => { const getRoleDisplay = () => {
@@ -77,10 +78,6 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
return false; return false;
}; };
if (location.pathname === '/login' || location.pathname === '/callback') {
return <>{children}</>;
}
return ( return (
<div className="min-h-screen bg-surface-soft flex font-sans selection:bg-primary/30"> <div className="min-h-screen bg-surface-soft flex font-sans selection:bg-primary/30">
{/* Sidebar Desktop - Fixed */} {/* Sidebar Desktop - Fixed */}
@@ -104,6 +101,18 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
</div> </div>
</div> </div>
<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-8 h-8 rounded-lg bg-surface-soft flex items-center justify-center font-bold text-xs">
ORG
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">Organização Matriz</p>
<p className="text-[10px] text-text-muted uppercase tracking-wider">Conta</p>
</div>
</div>
</div>
{/* Team Presence - Shows all members with online/offline status */} {/* Team Presence - Shows all members with online/offline status */}
<TeamPresence /> <TeamPresence />
@@ -202,12 +211,20 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
)} )}
</button> </button>
<button
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"
>
<LogOut size={18} />
Sair
</button>
<div className="pt-2 flex items-center justify-between px-2"> <div className="pt-2 flex items-center justify-between px-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<NotificationBell /> <NotificationBell />
<div className="w-px h-6 bg-border/50 mx-1"></div> <div className="w-px h-6 bg-border/50 mx-1"></div>
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary"> <div className="w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center font-bold text-xs">
<User size={16} /> {appUser?.name?.substring(0, 2).toUpperCase() || 'US'}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] text-text-main font-bold truncate max-w-[100px]">{appUser?.name || 'Usuário'}</span> <span className="text-[10px] text-text-main font-bold truncate max-w-[100px]">{appUser?.name || 'Usuário'}</span>
@@ -355,3 +372,4 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
</div> </div>
); );
}; };
+10 -1
View File
@@ -23,7 +23,16 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
requireEdit = false, requireEdit = false,
redirectTo = '/', redirectTo = '/',
}) => { }) => {
const { appUser, canEdit } = useAuth(); const { appUser, isLoading, canEdit } = useAuth();
// Show loading state
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<RefreshCw size={32} className="animate-spin text-primary" />
</div>
);
}
// Check role-based access // Check role-based access
if (allowedRoles && appUser && !allowedRoles.includes(appUser.role)) { if (allowedRoles && appUser && !allowedRoles.includes(appUser.role)) {
+16 -20
View File
@@ -6,10 +6,9 @@ import api from '../services/api';
interface OrganizationMember { interface OrganizationMember {
_id: string; _id: string;
id: string;
name: string; name: string;
email: string; email: string;
logto_id: string; userId: string;
role: string; role: string;
} }
@@ -31,7 +30,7 @@ export const TeamPresence: React.FC = () => {
const [isModalOpen, setIsModalOpen] = React.useState(false); const [isModalOpen, setIsModalOpen] = React.useState(false);
// Fetch all members // Fetch all members
const fetchMembers = useCallback(async () => { const fetchMembers = React.useCallback(async () => {
try { try {
const response = await api.get<OrganizationMember[]>('/users'); const response = await api.get<OrganizationMember[]>('/users');
setAllMembers(response.data); setAllMembers(response.data);
@@ -41,7 +40,7 @@ export const TeamPresence: React.FC = () => {
}, []); }, []);
// Fetch pending messages // Fetch pending messages
const fetchPendingMessages = 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');
setPendingMessages(response.data); setPendingMessages(response.data);
@@ -51,35 +50,35 @@ export const TeamPresence: React.FC = () => {
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
if (appUser) {
fetchMembers(); fetchMembers();
fetchPendingMessages(); fetchPendingMessages();
const memberInterval = setInterval(fetchMembers, 60000); const memberInterval = setInterval(fetchMembers, 60000);
const messageInterval = setInterval(fetchPendingMessages, 30000); const messageInterval = setInterval(fetchPendingMessages, 30000);
return () => { return () => {
clearInterval(memberInterval); clearInterval(memberInterval);
clearInterval(messageInterval); clearInterval(messageInterval);
}; };
}, [fetchMembers, fetchPendingMessages]); }
}, [appUser, 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 IDs for fast lookup
const activeUserLogtoIds = new Set(activeUsers.map(u => u.logtoId)); const activeUserEmails = new Set(activeUsers.map(u => u.email));
// Create a map of pending messages by recipient 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.logto_id === appUser?.logtoId) { if (member.email === appUser?.email) {
return; // Don't allow messaging yourself return; // Don't allow messaging yourself
} }
setSelectedUser({ id: member.logto_id, name: member.name }); setSelectedUser({ id: member.email, name: member.name });
setIsModalOpen(true); setIsModalOpen(true);
}; };
@@ -89,7 +88,7 @@ export const TeamPresence: React.FC = () => {
}; };
const handleMessageSent = async () => { const handleMessageSent = async () => {
await fetchPendingMessages(); fetchPendingMessages();
}; };
const getExistingMessage = (member: OrganizationMember) => { const getExistingMessage = (member: OrganizationMember) => {
@@ -102,13 +101,13 @@ export const TeamPresence: React.FC = () => {
<div className="px-6 py-3"> <div className="px-6 py-3">
<div className="mb-2"> <div className="mb-2">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]"> <span className="text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]">
Equipe ({(activeUsers || []).length}/{(allMembers || []).length} online) Equipe ({activeUsers.length}/{allMembers.length} online)
</span> </span>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(allMembers || []).map((member) => { {allMembers.map((member) => {
const isOnline = activeUserLogtoIds.has(member.logto_id); const isOnline = activeUserEmails.has(member.email);
const isCurrentUser = member.logto_id === appUser?.logtoId; const isCurrentUser = member.email === appUser?.email;
const hasPendingMessage = pendingMessagesByRecipient.has(member.email); const hasPendingMessage = pendingMessagesByRecipient.has(member.email);
return ( return (
@@ -176,13 +175,10 @@ export const TeamPresence: React.FC = () => {
onClose={handleModalClose} onClose={handleModalClose}
recipientId={selectedUser.id} recipientId={selectedUser.id}
recipientName={selectedUser.name} recipientName={selectedUser.name}
existingMessage={getExistingMessage(allMembers.find(m => m.logto_id === selectedUser.id)!)} existingMessage={getExistingMessage(allMembers.find(m => m.email === selectedUser.id)!)}
onMessageSent={handleMessageSent} onMessageSent={handleMessageSent}
/> />
)} )}
</> </>
); );
}; };
// No the component body I used useCallback so I need to import it
import { useCallback } from 'react';
@@ -36,6 +36,8 @@ export const BackupRestore: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = async () => { const handleExport = async () => {
if (!appUser) return;
setIsExporting(true); setIsExporting(true);
try { try {
const response = await api.get('/backup/export', { const response = await api.get('/backup/export', {
@@ -50,8 +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);
const orgName = appUser?.name || 'GPI'; link.download = `backup_gpi_${timestamp}.json`;
link.download = `backup_${orgName}_${timestamp}.json`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
@@ -147,8 +148,8 @@ export const BackupRestore: React.FC = () => {
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-bold text-text-main mb-2">Backup e Restauração de Dados</h3> <h3 className="text-lg font-bold text-text-main mb-2">Backup e Restauração de Dados</h3>
<p className="text-sm text-text-muted leading-relaxed"> <p className="text-sm text-text-muted leading-relaxed">
Use esta ferramenta para criar cópias de segurança de todos os dados da organização ou restaurar dados de um backup anterior. Use esta ferramenta para criar cópias de segurança de todos os dados do sistema ou restaurar dados de um backup anterior.
<strong className="text-amber-500"> Os backups são específicos para cada organização e não podem ser restaurados em outras organizações.</strong> <strong className="text-amber-500"> Os backups são específicos para cada instalação e podem não ser compatíveis entre versões diferentes se houver mudanças estruturais.</strong>
</p> </p>
</div> </div>
</div> </div>
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState } from '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';
@@ -8,7 +8,7 @@ import type { GeometryType } from '../../types';
import { useAuth } from '../../context/useAuth'; import { useAuth } from '../../context/useAuth';
export const GeometrySettings: React.FC = () => { export const GeometrySettings: React.FC = () => {
const { isSignedIn } = useAuth(); const { appUser } = useAuth();
const [types, setTypes] = useState<GeometryType[]>([]); const [types, setTypes] = useState<GeometryType[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -20,7 +20,7 @@ export const GeometrySettings: React.FC = () => {
efficiencyLoss: '20' efficiencyLoss: '20'
}); });
const fetchTypes = useCallback(async () => { const fetchTypes = React.useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const response = await geometryService.getAllTypes(); const response = await geometryService.getAllTypes();
@@ -33,15 +33,15 @@ export const GeometrySettings: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isSignedIn) { if (appUser) {
fetchTypes(); fetchTypes();
} }
}, [isSignedIn, fetchTypes]); }, [appUser, fetchTypes]);
const handleOpenModal = (item?: GeometryType) => { const handleOpenModal = (item?: GeometryType) => {
if (item) { if (item) {
setEditingItem(item); setEditingItem(item);
setForm({ name: item.name, efficiencyLoss: item.efficiencyLoss.toString() }); setForm({ name: item.name, efficiencyLoss: (item.efficiencyLoss ?? 0).toString() });
} else { } else {
setEditingItem(null); setEditingItem(null);
setForm({ name: '', efficiencyLoss: '20' }); setForm({ name: '', efficiencyLoss: '20' });
+4 -8
View File
@@ -48,10 +48,7 @@ export const StockModal: React.FC<StockModalProps> = ({ isOpen, onClose, onSucce
if (isOpen) { if (isOpen) {
fetchDataSheets(); fetchDataSheets();
if (initialData) { if (initialData) {
const dsId = (typeof initialData.dataSheetId === 'object') setDataSheetId(typeof initialData.dataSheetId === 'object' ? initialData.dataSheetId._id : initialData.dataSheetId);
? (initialData.dataSheetId.id || initialData.dataSheetId._id)
: initialData.dataSheetId;
setDataSheetId(dsId || '');
setRrNumber(initialData.rrNumber); setRrNumber(initialData.rrNumber);
setBatchNumber(initialData.batchNumber); setBatchNumber(initialData.batchNumber);
setColor(initialData.color || ''); setColor(initialData.color || '');
@@ -111,8 +108,7 @@ export const StockModal: React.FC<StockModalProps> = ({ isOpen, onClose, onSucce
try { try {
if (initialData) { if (initialData) {
const itemId = initialData.id || initialData._id; await stockService.update(initialData._id!, payload);
await stockService.update(itemId!, payload);
} else { } else {
await stockService.create(payload); await stockService.create(payload);
} }
@@ -151,12 +147,12 @@ export const StockModal: React.FC<StockModalProps> = ({ isOpen, onClose, onSucce
const val = e.target.value; const val = e.target.value;
setDataSheetId(val); setDataSheetId(val);
// Auto-fill minStock from DataSheet if set and current is empty/0 // Auto-fill minStock from DataSheet if set and current is empty/0
const ds = dataSheets.find(d => (d.id || d._id) === val); const ds = dataSheets.find(d => d._id === val);
if (ds && ds.minStock && (!minStock || minStock === '0')) { if (ds && ds.minStock && (!minStock || minStock === '0')) {
setMinStock(String(ds.minStock)); setMinStock(String(ds.minStock));
} }
}} }}
options={filteredDataSheets.map(ds => ({ label: `${ds.name} - ${ds.manufacturer}`, value: ds.id || ds._id }))} options={filteredDataSheets.map(ds => ({ label: `${ds.name} - ${ds.manufacturer}`, value: ds._id }))}
disabled={!!initialData} // Lock product on edit disabled={!!initialData} // Lock product on edit
/> />
+100 -54
View File
@@ -1,81 +1,127 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { AppUser } from '../types'; import type { AppUser } from '../types';
import { AuthContext } from './AuthContextType'; import { setApiToken, setApiOrganizationId, getBaseUrl } from '../services/api';
import { setApiOrganizationId } from '../services/api';
interface AuthProviderProps { const API_URL = getBaseUrl();
children: React.ReactNode;
export interface AuthContextType {
appUser: AppUser | null;
isLoading: boolean;
isSignedIn: boolean;
error: string | null;
token: string | null;
login: (token: string, user: AppUser) => void;
logout: () => void;
isAdmin: () => boolean;
isUser: () => boolean;
isGuest: () => boolean;
isDeveloper: () => boolean;
canEdit: () => boolean;
refetchUser: () => Promise<void>;
} }
const defaultUser: AppUser = { export const AuthContext = createContext<AuthContextType | undefined>(undefined);
id: '00000000-0000-0000-0000-000000000000',
email: 'guest@gpi.app',
name: 'Guest User',
role: 'user',
isBanned: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
const DEFAULT_ORGANIZATION_ID = 'e47e6210-4879-4e5b-bf21-9285d2713123'; export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const DEFAULT_ORGANIZATION_NAME = 'Organização Padrão';
export const AuthProvider: React.FC<AuthProviderProps> = ({ 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 [error, setError] = useState<string | null>(null);
// Initial load: se tem token, setar no interceptor e buscar dados do usuário
useEffect(() => { useEffect(() => {
const storedUser = localStorage.getItem('gpi_user'); if (token) {
if (storedUser) { setApiToken(token);
try { refetchUser();
setAppUser(JSON.parse(storedUser)); } else {
} catch (e) { setIsLoading(false);
console.error("Error parsing stored user", e);
}
} }
}, [token]);
setApiOrganizationId(DEFAULT_ORGANIZATION_ID, DEFAULT_ORGANIZATION_NAME); 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 signInWithPassword = async (password: string): Promise<boolean> => { const logout = useCallback(() => {
if (password === '@@Gi05Br;;') { localStorage.removeItem('jwt_token');
const adminUser: AppUser = { setToken(null);
...defaultUser, setAppUser(null);
id: 'admin-001', setApiToken(null);
email: 'admtracksteel@gmail.com', setApiOrganizationId(null);
name: 'Administrator / DEV', }, []);
role: 'admin'
}; const refetchUser = useCallback(async () => {
setAppUser(adminUser); if (!token) return;
localStorage.setItem('gpi_user', JSON.stringify(adminUser)); setIsLoading(true);
return true; try {
const response = await fetch(`${API_URL}/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`
},
});
if (response.ok) {
const userData = await response.json();
setAppUser(userData);
if (userData.organizationId) {
setApiOrganizationId(userData.organizationId);
} }
return false; } else {
}; // Token inválido ou expirado
logout();
}
} catch (err) {
console.error('Error refetching user:', err);
setError('Falha na comunicação de autenticação.');
} finally {
setIsLoading(false);
}
}, [token, logout]);
const isDeveloper = useCallback(() => appUser?.email === 'admtracksteel@gmail.com', [appUser]); const isDeveloper = useCallback(() => {
const isAdmin = useCallback(() => appUser?.role === 'admin' || appUser?.email === 'admtracksteel@gmail.com', [appUser]); return appUser?.email === 'admtracksteel@gmail.com';
const isUser = useCallback(() => !!appUser, [appUser]); }, [appUser]);
const isGuest = useCallback(() => !appUser, [appUser]);
const canEdit = useCallback(() => isAdmin(), [isAdmin]);
const refetchUser = useCallback(async () => {}, []);
const value = useMemo(() => ({ const isAdmin = useCallback(() => appUser?.role === 'admin' || isDeveloper(), [appUser, isDeveloper]);
const isUser = useCallback(() => appUser?.role === 'user' || isAdmin(), [appUser, isAdmin]);
const isGuest = useCallback(() => appUser?.role === 'guest' && !isDeveloper(), [appUser, isDeveloper]);
const canEdit = useCallback(() => (appUser?.role !== 'guest' && appUser !== null) || isDeveloper(), [appUser, isDeveloper]);
return (
<AuthContext.Provider
value={{
appUser, appUser,
isLoading: false, isLoading,
isSignedIn: !!appUser, isSignedIn: !!appUser,
error: null, error,
token,
login,
logout,
isAdmin, isAdmin,
isUser, isUser,
isGuest, isGuest,
isDeveloper, isDeveloper,
canEdit, canEdit,
refetchUser, refetchUser,
signInWithPassword }}
}), [appUser, isAdmin, isUser, isGuest, isDeveloper, canEdit, refetchUser, signInWithPassword]); >
return (
<AuthContext.Provider value={value}>
{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,4 +1,3 @@
import { createContext } from 'react';
import type { AppUser } from '../types'; import type { AppUser } from '../types';
export interface AuthContextType { export interface AuthContextType {
@@ -12,7 +11,4 @@ export interface AuthContextType {
isDeveloper: () => boolean; isDeveloper: () => boolean;
canEdit: () => boolean; canEdit: () => boolean;
refetchUser: () => Promise<void>; refetchUser: () => Promise<void>;
signInWithPassword: (password: string) => Promise<boolean>;
} }
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
+1 -10
View File
@@ -1,10 +1 @@
import { useContext } from 'react'; export { useAuth } from './AuthContext';
import { AuthContext } from './AuthContextType';
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
+23 -49
View File
@@ -5,57 +5,48 @@ import type { INotification } from '../types';
import { NotificationContext } from './NotificationContextState'; import { NotificationContext } from './NotificationContextState';
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isSignedIn } = useAuth(); const { appUser, isSignedIn } = useAuth();
const orgId = appUser?.organizationId;
const [notifications, setNotifications] = useState<INotification[]>([]); const [notifications, setNotifications] = useState<INotification[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fetchNotifications = useCallback(async () => { const fetchNotifications = useCallback(async () => {
if (!isSignedIn) return; if (!isSignedIn || !orgId) return;
try { try {
if (notifications.length === 0) setLoading(true); setLoading(true);
const response = await api.get('/notifications'); const response = await api.get<INotification[]>('/notifications');
setNotifications(response.data); setNotifications(response.data);
} catch (error) { } catch (error) {
console.error('Failed to fetch notifications', error); console.error('Error fetching notifications:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isSignedIn, notifications.length]); }, [isSignedIn, orgId]);
useEffect(() => {
if (isSignedIn && orgId) {
fetchNotifications();
const interval = setInterval(fetchNotifications, 60000);
return () => clearInterval(interval);
}
}, [isSignedIn, orgId, fetchNotifications]);
const markAsRead = async (id: string) => { const markAsRead = async (id: string) => {
try { try {
await api.put(`/notifications/${id}/read`); await api.patch(`/notifications/${id}/read`);
setNotifications(prev => prev.map(n => n._id === id ? { ...n, isRead: true } : n)); setNotifications(prev => prev.map(n => n._id === id ? { ...n, isRead: true } : n));
} catch (error) { } catch (error) {
console.error('Failed to mark as read', error); console.error('Error marking notification as read:', error);
} }
}; };
const markAllAsRead = async () => { const markAllAsRead = async () => {
try { try {
await api.put('/notifications/read-all'); await api.post('/notifications/read-all');
setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
} catch (error) { } catch (error) {
console.error('Failed to mark all as read', error); console.error('Error marking all notifications as read:', error);
}
}
const clearAll = async () => {
try {
await api.delete('/notifications/clear-all');
setNotifications([]);
} catch (error) {
console.error('Failed to clear all notifications', error);
}
};
const archiveNotification = async (id: string) => {
try {
await api.patch(`/notifications/${id}/archive`);
setNotifications(prev => prev.filter(n => n._id !== id));
} catch (error) {
console.error('Failed to archive notification', error);
} }
}; };
@@ -64,38 +55,21 @@ export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({
await api.delete(`/notifications/${id}`); await api.delete(`/notifications/${id}`);
setNotifications(prev => prev.filter(n => n._id !== id)); setNotifications(prev => prev.filter(n => n._id !== id));
} catch (error) { } catch (error) {
console.error('Failed to delete notification', error); console.error('Error deleting notification:', error);
} }
}; };
// Polling effect const unreadCount = notifications.filter(n => !n.isRead).length;
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;
return ( return (
<NotificationContext.Provider value={{ <NotificationContext.Provider value={{
notifications, notifications,
unreadCount, unreadCount,
loading, loading,
fetchNotifications,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
clearAll, deleteNotification
archiveNotification,
deleteNotification,
fetchNotifications
}}> }}>
{children} {children}
</NotificationContext.Provider> </NotificationContext.Provider>
+2 -2
View File
@@ -3,10 +3,10 @@ import api from '../services/api';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
export interface ActiveUser { export interface ActiveUser {
id: string; _id: string;
name: string; name: string;
email: string; email: string;
logtoId: string; externalId: string;
lastSeenAt: string; lastSeenAt: string;
} }
+6 -21
View File
@@ -1,22 +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';
export function getToken() { createRoot(document.getElementById('root')!).render(
return 'guest-token'; <App />
} );
export function getUser() {
return {
id: 'guest-user',
email: 'guest@gpi.app',
name: 'Guest User',
role: 'user'
};
}
export function setUser(token: string, user: any) {
console.log('User set (no auth):', user);
}
createRoot(document.getElementById('root')!).render(<App />)
+57 -21
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, Terminal } from 'lucide-react'; import { Shield, UserCheck, UserX, Users, Search, RefreshCw, Crown, Eye, User as UserIcon, Upload, Info, Image as ImageIcon, Box, Database } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { AppUser, UserRole } from '../types'; import type { AppUser, UserRole } from '../types';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
@@ -14,7 +14,7 @@ const roleLabels: Record<UserRole, { label: string; color: string; icon: React.R
}; };
export const AdminDashboard: React.FC = () => { export const AdminDashboard: React.FC = () => {
const { isAdmin, appUser: currentUser } = useAuth(); const { appUser, isAdmin } = useAuth();
const [users, setUsers] = useState<AppUser[]>([]); const [users, setUsers] = useState<AppUser[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -24,6 +24,8 @@ export const AdminDashboard: React.FC = () => {
const [logoLoading, setLogoLoading] = useState(false); 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');
@@ -33,15 +35,15 @@ export const AdminDashboard: React.FC = () => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, [appUser]);
useEffect(() => { useEffect(() => {
if (isAdmin()) {
fetchUsers(); fetchUsers();
} }, [fetchUsers]);
}, [isAdmin, 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 });
@@ -57,6 +59,8 @@ export const AdminDashboard: React.FC = () => {
}; };
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 });
@@ -79,8 +83,32 @@ export const AdminDashboard: React.FC = () => {
}); });
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
// Implement Logo Upload via Backend API if needed const file = e.target.files?.[0];
alert('Funcionalidade de upload de logo em migração para o novo sistema.'); 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()) {
@@ -273,7 +301,7 @@ export const AdminDashboard: React.FC = () => {
<tbody className="divide-y divide-border/40"> <tbody className="divide-y divide-border/40">
{filteredUsers.map((u) => { {filteredUsers.map((u) => {
const roleInfo = roleLabels[u.role]; const roleInfo = roleLabels[u.role];
const isCurrentUser = u.email === currentUser?.email; const isCurrentUser = u.email === appUser?.email;
const isActionDisabled = actionLoading === u.id; const isActionDisabled = actionLoading === u.id;
return ( return (
@@ -349,24 +377,32 @@ export const AdminDashboard: React.FC = () => {
</> </>
) : activeTab === 'organization' ? ( ) : activeTab === 'organization' ? (
<div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-300"> <div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-300">
<div className="bg-surface rounded-2xl p-6 border border-border/40 space-y-6"> <div className="bg-surface rounded-2xl p-8 border border-border/40 text-center space-y-4">
<div className="flex items-center gap-3 pb-4 border-b border-border/20"> <ImageIcon size={48} className="mx-auto text-text-muted opacity-20" />
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center"> <h2 className="text-xl font-bold text-text-main">Gestão de Identidade Visual</h2>
<Info size={20} className="text-primary" /> <p className="text-text-muted max-w-md mx-auto">
</div> O gerenciamento nativo de logos está sendo implementado. No momento, o logo atual é gerenciado via configurações do sistema.
<div> </p>
<h2 className="text-lg font-bold text-text-main">Configurações da Organização</h2>
<p className="text-xs text-text-muted">Migrando para o novo sistema Logto</p>
</div>
</div>
<p className="text-text-muted">A gestão de identidade visual e dados da organização está sendo migrada para a API central.</p>
</div> </div>
</div> </div>
) : activeTab === 'settings' ? ( ) : activeTab === 'settings' ? (
<GeometrySettings /> <GeometrySettings />
) : activeTab === 'backup' ? ( ) : activeTab === 'backup' ? (
<BackupRestore /> <BackupRestore />
) : null} ) : (
<div className="bg-surface rounded-2xl border border-border/40 p-6">
<div className="text-center py-10">
<h2 className="text-xl font-bold text-text-main">Gestão de Estoque</h2>
<p className="text-text-muted mt-2">Acesse a nova página dedicada ao controle de estoque.</p>
<a
href="/stock"
className="mt-6 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
Ir para Estoque
</a>
</div>
</div>
)}
</div> </div>
); );
}; };
-23
View File
@@ -1,23 +0,0 @@
import { useHandleSignInCallback } from '@logto/react';
import { useNavigate } from 'react-router-dom';
export const Callback = () => {
const navigate = useNavigate();
const { isLoading } = useHandleSignInCallback(() => {
// Redireciona para a home após o login bem-sucedido
navigate('/');
});
if (isLoading) {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-surface-soft">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-text-muted font-medium">Finalizando autenticação...</p>
</div>
</div>
);
}
return null;
};
+66 -44
View File
@@ -1,30 +1,41 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { Hammer, Lock, ShieldCheck } from "lucide-react"; import { Hammer } from "lucide-react";
import { useAuth } from '../context/useAuth'; import { useAuth } from "../context/useAuth";
import { useNavigate } from 'react-router-dom'; import { getBaseUrl } from "../services/api";
const API_URL = getBaseUrl();
export const Login = () => { export const Login = () => {
const [password, setPassword] = useState(''); const [email, setEmail] = useState("");
const [error, setError] = useState(''); const [password, setPassword] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { signInWithPassword } = useAuth();
const navigate = useNavigate(); const { login } = useAuth();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setErrorMsg("");
setLoading(true); setLoading(true);
try { try {
const success = await signInWithPassword(password); const response = await fetch(`${API_URL}/auth/login`, {
if (success) { method: "POST",
navigate('/'); headers: { "Content-Type": "application/json" },
} else { body: JSON.stringify({ email, password })
setError('Senha incorreta. Acesso negado.'); });
const data = await response.json();
if (!response.ok) {
setErrorMsg(data.error || "Erro ao efetuar login");
setLoading(false);
return;
} }
login(data.token, data.user);
} catch (err) { } catch (err) {
setError('Erro ao processar login.'); setErrorMsg("Falha na conexão com o servidor.");
} finally {
setLoading(false); setLoading(false);
} }
}; };
@@ -38,54 +49,65 @@ export const Login = () => {
<div className="relative z-10 w-full max-w-md px-6 flex flex-col items-center"> <div className="relative z-10 w-full max-w-md px-6 flex flex-col items-center">
{/* Logo Area */} {/* Logo Area */}
<div className="mb-8 flex flex-col items-center text-center"> <div className="mb-8 flex flex-col items-center text-center">
<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"> <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">
G G
</div> </div>
<h1 className="text-3xl font-bold text-text-main tracking-tight mb-1">GPI RESTRICT</h1> <h1 className="text-3xl font-bold text-text-main tracking-tight mb-1">GPI</h1>
<p className="text-text-muted text-[10px] font-black uppercase tracking-[0.3em]">Ambiente de Desenvolvimento</p> <p className="text-text-muted text-sm font-medium uppercase tracking-widest">Gestão de Pintura Industrial</p>
</div> </div>
{/* Login Form */} {/* Custom Login Form */}
<div className="w-full bg-surface rounded-[2.5rem] border border-border/40 shadow-2xl shadow-primary/5 p-10 backdrop-blur-sm"> <div className="w-full bg-surface rounded-[2rem] border border-border/40 shadow-2xl shadow-primary/5 p-8 animate-in slide-in-from-bottom-8 duration-1000">
<div className="flex items-center gap-3 mb-8 text-primary"> <h2 className="text-xl font-bold text-text-main mb-6 text-center">Entrar na sua conta</h2>
<Lock size={20} className="opacity-70" />
<h2 className="text-lg font-bold uppercase tracking-tight">Chave de Acesso</h2>
</div>
<form onSubmit={handleSubmit} className="space-y-6"> {errorMsg && (
<div className="space-y-2"> <div className="mb-4 p-3 rounded-lg bg-error/10 border border-error/20 text-error text-sm text-center">
{errorMsg}
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-text-secondary" htmlFor="email">Email</label>
<input <input
id="email"
type="email"
required
value={email}
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"
placeholder="seu@email.com"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<label className="text-sm font-semibold text-text-secondary" htmlFor="password">Senha</label>
</div>
<input
id="password"
type="password" type="password"
placeholder="Digite a senha mestra..." required
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full h-14 bg-surface-soft border border-border/40 rounded-2xl px-6 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-bold placeholder:font-medium tracking-widest text-center" 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"
required placeholder="••••••••"
autoFocus
/> />
{error && <p className="text-error text-[10px] font-bold uppercase text-center mt-2 tracking-wider">{error}</p>}
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full h-14 bg-primary hover:bg-primary/90 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-primary/20 flex items-center justify-center gap-3 active:scale-95 disabled:opacity-50" 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"
> >
{loading ? ( {loading ? "Entrando..." : "Entrar"}
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<ShieldCheck size={20} />
Entrar no Sistema
</>
)}
</button> </button>
</form> </form>
</div> </div>
<div className="mt-8 flex items-center gap-2 text-text-muted/60 text-[10px] font-black uppercase tracking-widest"> <div className="mt-8 flex items-center gap-2 text-text-muted/60 text-xs font-medium">
<Hammer size={12} /> <Hammer size={14} />
<span>Desenvolvimento Ativo</span> <span>© 2026 GPI - Eficiência Industrial</span>
</div> </div>
</div> </div>
</div> </div>
+49
View File
@@ -0,0 +1,49 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/useAuth';
import { Building2, RefreshCw } from 'lucide-react';
export const OrganizationSelector: React.FC = () => {
const { appUser, isSignedIn } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (isSignedIn && appUser?.organizationId) {
navigate('/');
}
}, [isSignedIn, appUser, navigate]);
if (!isSignedIn) {
return (
<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="w-16 h-16 rounded-2xl bg-amber-500/20 flex items-center justify-center mx-auto mb-4">
<Building2 className="w-8 h-8 text-amber-500" />
</div>
<h1 className="text-2xl font-bold text-text-main mb-2">
Não Conectado
</h1>
<p className="text-text-muted mb-6">
Você precisa estar logado para acessar esta página.
</p>
<button
onClick={() => navigate('/login')}
className="w-full py-3 bg-primary text-white rounded-xl font-bold"
>
Ir para Login
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<RefreshCw className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
<p className="text-text-main font-bold mb-2">Redirecionando...</p>
<p className="text-text-muted text-sm">Carregando sua organização</p>
</div>
</div>
);
};
+3 -2
View File
@@ -49,12 +49,13 @@ export const ProjectList: React.FC = () => {
const [isPrintingGeneral, setIsPrintingGeneral] = useState(false); const [isPrintingGeneral, setIsPrintingGeneral] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { appUser, isAdmin: checkIsAdmin } = useAuth(); const { appUser } = useAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const { settings } = useSystemSettings(); const { settings } = useSystemSettings();
const logoUrl = settings?.appLogoUrl; const logoUrl = settings?.appLogoUrl;
const isAdmin = checkIsAdmin();
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
const fetchProjects = useCallback(async () => { const fetchProjects = useCallback(async () => {
try { try {
+4 -3
View File
@@ -10,7 +10,6 @@ import type { PaintingScheme } from '../types';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
export const SchemesList: React.FC = () => { export const SchemesList: React.FC = () => {
const { isAdmin } = useAuth();
const [schemes, setSchemes] = useState<PaintingScheme[]>([]); const [schemes, setSchemes] = useState<PaintingScheme[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editItem, setEditItem] = useState<PaintingScheme | undefined>(undefined); const [editItem, setEditItem] = useState<PaintingScheme | undefined>(undefined);
@@ -18,6 +17,8 @@ export const SchemesList: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false); const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const { appUser } = useAuth();
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
useEffect(() => { useEffect(() => {
fetchSchemes(); fetchSchemes();
@@ -134,7 +135,7 @@ export const SchemesList: React.FC = () => {
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
{isAdmin() && ( {isAdmin && (
<Button onClick={() => { setEditItem(undefined); setIsModalOpen(true); }} size="lg" className="shadow-primary/30 h-14"> <Button onClick={() => { setEditItem(undefined); setIsModalOpen(true); }} size="lg" className="shadow-primary/30 h-14">
<Plus className="w-5 h-5 mr-2" /> <Plus className="w-5 h-5 mr-2" />
Novo Esquema Novo Esquema
@@ -150,7 +151,7 @@ export const SchemesList: React.FC = () => {
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
titleAccessor="name" titleAccessor="name"
subtitleAccessor={(item) => `${item.manufacturer || ''} ${item.type || ''}`} subtitleAccessor={(item) => `${item.manufacturer || ''} ${item.type || ''}`}
actionRender={(item) => isAdmin() ? ( actionRender={(item) => isAdmin ? (
<div className="flex gap-1 justify-end"> <div className="flex gap-1 justify-end">
<button <button
onClick={() => { setCloneItem(item); setIsCloneModalOpen(true); }} onClick={() => { setCloneItem(item); setIsCloneModalOpen(true); }}
+17 -18
View File
@@ -10,6 +10,7 @@ import { useAuth } from '../context/useAuth';
import { useSystemSettings } from '../context/SystemSettingsContext'; import { useSystemSettings } from '../context/SystemSettingsContext';
export const StockDashboard: React.FC = () => { export const StockDashboard: React.FC = () => {
// ... rest of component
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { settings } = useSystemSettings(); const { settings } = useSystemSettings();
@@ -79,12 +80,11 @@ export const StockDashboard: React.FC = () => {
await Promise.all( await Promise.all(
items.map(async (item) => { items.map(async (item) => {
try { try {
const itemId = item.id || (item as any)._id; const movements = await stockService.getMovements(item._id!);
if (!itemId) return; movementsMap.set(item._id!, movements);
const movements = await stockService.getMovements(itemId);
movementsMap.set(itemId, movements);
} catch (error) { } catch (error) {
console.error(`Error fetching movements for ${item.id}:`, error); console.error(`Error fetching movements for ${item._id}:`, error);
movementsMap.set(item._id!, []);
} }
}) })
); );
@@ -107,19 +107,19 @@ export const StockDashboard: React.FC = () => {
const filteredItems = items.filter(item => { const filteredItems = items.filter(item => {
const searchLower = searchTerm.toLowerCase(); const searchLower = searchTerm.toLowerCase();
// Handle type checking carefully. If type is missing, assume PAINT. // Handle type checking carefully. If type is missing, assume PAINT.
const type = (typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).type : '') || 'PAINT'; const type = (typeof item.dataSheetId === 'object' ? item.dataSheetId.type : '') || 'PAINT';
const isThinner = type === 'THINNER' || type === 'DILUENTE'; const isThinner = type === 'THINNER' || type === 'DILUENTE';
// Tab Filter // Tab Filter
if (activeTab === 'THINNER' && !isThinner) return false; if (activeTab === 'THINNER' && !isThinner) return false;
if (activeTab === 'PAINT' && isThinner) return false; if (activeTab === 'PAINT' && isThinner) return false;
const productName = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).name : ''; const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : '';
const manufacturer = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).manufacturer : ''; const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
return ( return (
(item.rrNumber || '').toLowerCase().includes(searchLower) || item.rrNumber.toLowerCase().includes(searchLower) ||
(item.batchNumber || '').toLowerCase().includes(searchLower) || item.batchNumber.toLowerCase().includes(searchLower) ||
productName.toLowerCase().includes(searchLower) || productName.toLowerCase().includes(searchLower) ||
manufacturer.toLowerCase().includes(searchLower) manufacturer.toLowerCase().includes(searchLower)
); );
@@ -129,10 +129,9 @@ export const StockDashboard: React.FC = () => {
const groups = new Map<string, { items: StockItem[], totalQty: number, minStock: number, unit: string, productName: string, color: string, manufacturer: string }>(); const groups = new Map<string, { items: StockItem[], totalQty: number, minStock: number, unit: string, productName: string, color: string, manufacturer: string }>();
filteredItems.forEach(item => { filteredItems.forEach(item => {
const productName = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).name : 'Unknown'; const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Unknown';
const manufacturer = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).manufacturer : ''; const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
const dsId = (item.dataSheetId as any).id || (item.dataSheetId as any)._id || item.dataSheetId; const key = `${item.dataSheetId._id || item.dataSheetId}-${item.color}`;
const key = `${dsId}-${item.color}`;
if (!groups.has(key)) { if (!groups.has(key)) {
groups.set(key, { groups.set(key, {
@@ -297,7 +296,7 @@ export const StockDashboard: React.FC = () => {
<td className="px-6 py-4 font-bold text-lg"> <td className="px-6 py-4 font-bold text-lg">
<span className={isLowStock ? 'text-red-500 animate-blink flex items-center gap-2' : 'text-green-500'}> <span className={isLowStock ? 'text-red-500 animate-blink flex items-center gap-2' : 'text-green-500'}>
{isLowStock && <AlertCircle size={16} />} {isLowStock && <AlertCircle size={16} />}
{(group.totalQty || 0).toFixed(1)} {group.unit} {group.totalQty.toFixed(1)} {group.unit}
</span> </span>
{group.minStock > 0 && ( {group.minStock > 0 && (
<span className="block text-[10px] text-text-muted font-normal"> <span className="block text-[10px] text-text-muted font-normal">
@@ -315,11 +314,11 @@ export const StockDashboard: React.FC = () => {
{/* Expanded Item Rows */} {/* Expanded Item Rows */}
{isExpanded && group.items.map(item => { {isExpanded && group.items.map(item => {
const itemId = item.id || (item as any)._id;
const isExpired = item.expirationDate && new Date(item.expirationDate) < new Date(); const isExpired = item.expirationDate && new Date(item.expirationDate) < new Date();
// Check individual item min stock for legacy reasons? No, rely on group.
return ( return (
<tr key={itemId} className="bg-surface-soft/50 hover:bg-surface-hover/80 transition-colors border-l-4 border-l-primary/20"> <tr key={item._id} className="bg-surface-soft/50 hover:bg-surface-hover/80 transition-colors border-l-4 border-l-primary/20">
<td className="px-6 py-3"></td> {/* Indentation */} <td className="px-6 py-3"></td> {/* Indentation */}
<td className="px-6 py-3 font-mono text-xs text-text-muted"> <td className="px-6 py-3 font-mono text-xs text-text-muted">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -373,7 +372,7 @@ export const StockDashboard: React.FC = () => {
<Edit size={16} /> <Edit size={16} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDelete(itemId!); }} onClick={(e) => { e.stopPropagation(); handleDelete(item._id!); }}
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg" className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg"
title="Excluir" title="Excluir"
> >
+22 -22
View File
@@ -287,7 +287,7 @@ export const YieldStudyDashboard: React.FC = () => {
const dilutionFactor = 100 - study.dilutionPercent; const dilutionFactor = 100 - study.dilutionPercent;
const svFactor = sv * dilutionFactor; const svFactor = sv * dilutionFactor;
const calculatedEpu = svFactor > 0 const calculatedEpu = svFactor > 0
? Number((((study.targetDft || 0) * 10000) / svFactor).toFixed(1)) ? Number((study.targetDft * 10000 / svFactor).toFixed(1))
: 0; : 0;
let totalWeight = 0; let totalWeight = 0;
@@ -320,8 +320,8 @@ export const YieldStudyDashboard: React.FC = () => {
return { return {
...cat, ...cat,
litrosPeso: Number((litrosPeso || 0).toFixed(2)), litrosPeso: Number(litrosPeso.toFixed(2)),
litrosArea: litrosArea > 0 ? Number((litrosArea || 0).toFixed(2)) : undefined litrosArea: litrosArea > 0 ? Number(litrosArea.toFixed(2)) : undefined
}; };
}); });
@@ -332,10 +332,10 @@ export const YieldStudyDashboard: React.FC = () => {
...study, ...study,
categories: updatedCategories, categories: updatedCategories,
totalWeight, totalWeight,
estimatedPaintVolume: Number((totalVolumeByWeight || 0).toFixed(2)), estimatedPaintVolume: Number(totalVolumeByWeight.toFixed(2)),
estimatedReducerVolume: Number((reducerVolByWeight || 0).toFixed(2)), estimatedReducerVolume: Number(reducerVolByWeight.toFixed(2)),
estimatedPaintVolumeByArea: Number((totalVolumeByArea || 0).toFixed(2)), estimatedPaintVolumeByArea: Number(totalVolumeByArea.toFixed(2)),
estimatedReducerVolumeByArea: Number((reducerVolByArea || 0).toFixed(2)), estimatedReducerVolumeByArea: Number(reducerVolByArea.toFixed(2)),
calculatedEpu: calculatedEpu calculatedEpu: calculatedEpu
} as YieldStudy & { calculatedEpu: number }); } as YieldStudy & { calculatedEpu: number });
}; };
@@ -353,11 +353,11 @@ export const YieldStudyDashboard: React.FC = () => {
// Data for deviation projection // Data for deviation projection
// Data for deviation projection - Lógica Direta (Mais DFT = Mais Tinta) // Data for deviation projection - Lógica Direta (Mais DFT = Mais Tinta)
const projectionData = selectedStudy ? [ const projectionData = selectedStudy ? [
{ dft: ((selectedStudy.targetDft || 0) * 0.8).toFixed(0), vol: Number(((selectedStudy.estimatedPaintVolume || 0) * 0.8).toFixed(1)), label: `-20%` }, { dft: (selectedStudy.targetDft * 0.8).toFixed(0), vol: Number((selectedStudy.estimatedPaintVolume * 0.8).toFixed(1)), label: `-20%` },
{ dft: ((selectedStudy.targetDft || 0) * 0.9).toFixed(0), vol: Number(((selectedStudy.estimatedPaintVolume || 0) * 0.9).toFixed(1)), label: `-10%` }, { dft: (selectedStudy.targetDft * 0.9).toFixed(0), vol: Number((selectedStudy.estimatedPaintVolume * 0.9).toFixed(1)), label: `-10%` },
{ dft: (selectedStudy.targetDft || 0).toFixed(0), vol: (selectedStudy.estimatedPaintVolume || 0), label: 'ALVO' }, { dft: selectedStudy.targetDft.toFixed(0), vol: selectedStudy.estimatedPaintVolume, label: 'ALVO' },
{ dft: ((selectedStudy.targetDft || 0) * 1.1).toFixed(0), vol: Number(((selectedStudy.estimatedPaintVolume || 0) * 1.1).toFixed(1)), label: '+10%' }, { dft: (selectedStudy.targetDft * 1.1).toFixed(0), vol: Number((selectedStudy.estimatedPaintVolume * 1.1).toFixed(1)), label: '+10%' },
{ dft: ((selectedStudy.targetDft || 0) * 1.3).toFixed(0), vol: Number(((selectedStudy.estimatedPaintVolume || 0) * 1.3).toFixed(1)), label: '+30%' }, { dft: (selectedStudy.targetDft * 1.3).toFixed(0), vol: Number((selectedStudy.estimatedPaintVolume * 1.3).toFixed(1)), label: '+30%' },
] : []; ] : [];
if (loading) return <div className="p-8 text-center text-text-muted">Carregando estudos...</div>; if (loading) return <div className="p-8 text-center text-text-muted">Carregando estudos...</div>;
@@ -466,11 +466,11 @@ export const YieldStudyDashboard: React.FC = () => {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[9px] font-black text-text-muted uppercase tracking-[0.2em] mb-1">Carga Total</span> <span className="text-[9px] font-black text-text-muted uppercase tracking-[0.2em] mb-1">Carga Total</span>
<span className="text-sm font-black text-text-main">{(study.totalWeight || 0).toFixed(1)} t</span> <span className="text-sm font-black text-text-main">{study.totalWeight.toFixed(1)} t</span>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[9px] font-black text-text-muted uppercase tracking-[0.2em] mb-1">Target DFT</span> <span className="text-[9px] font-black text-text-muted uppercase tracking-[0.2em] mb-1">Target DFT</span>
<span className="text-sm font-black text-text-main">{(study.targetDft || 0)} <span className="text-[10px] text-text-muted">μm</span></span> <span className="text-sm font-black text-text-main">{study.targetDft} <span className="text-[10px] text-text-muted">μm</span></span>
</div> </div>
</div> </div>
</div> </div>
@@ -558,9 +558,9 @@ export const YieldStudyDashboard: React.FC = () => {
const sheet = findSheet(selectedStudy.dataSheetId); const sheet = findSheet(selectedStudy.dataSheetId);
let sv = sheet?.solidsVolume || 60; let sv = sheet?.solidsVolume || 60;
if (sv <= 1) sv *= 100; if (sv <= 1) sv *= 100;
const dilFactor = 100 - (selectedStudy.dilutionPercent || 0); const dilFactor = 100 - selectedStudy.dilutionPercent;
const svFactor = sv * dilFactor; const svFactor = sv * dilFactor;
return svFactor > 0 ? ((selectedStudy.targetDft || 0) * 10000 / svFactor).toFixed(1) : '0'; return svFactor > 0 ? (selectedStudy.targetDft * 10000 / svFactor).toFixed(1) : '0';
})() })()
} <span className="text-xs">µm</span> } <span className="text-xs">µm</span>
</div> </div>
@@ -578,7 +578,7 @@ export const YieldStudyDashboard: React.FC = () => {
SV da Tinta {hasRealSV ? '✓' : '⚠️'} SV da Tinta {hasRealSV ? '✓' : '⚠️'}
</span> </span>
<div className={`text-2xl font-black leading-none ${hasRealSV ? 'text-text-main' : 'text-amber-500'}`}> <div className={`text-2xl font-black leading-none ${hasRealSV ? 'text-text-main' : 'text-amber-500'}`}>
{(sv || 0).toFixed(0)} <span className="text-xs">%</span> {sv.toFixed(0)} <span className="text-xs">%</span>
</div> </div>
<span className="text-[8px] text-text-muted"> <span className="text-[8px] text-text-muted">
{hasRealSV ? 'Sólidos por Volume' : 'Valor padrão (edite a ficha)'} {hasRealSV ? 'Sólidos por Volume' : 'Valor padrão (edite a ficha)'}
@@ -617,13 +617,13 @@ export const YieldStudyDashboard: React.FC = () => {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-[10px] font-bold text-text-muted uppercase">Taxa Média</span> <span className="text-[10px] font-bold text-text-muted uppercase">Taxa Média</span>
<span className="text-sm font-black text-text-main"> <span className="text-sm font-black text-text-main">
{selectedStudy.totalWeight > 0 ? (((selectedStudy.estimatedPaintVolume || 0) / selectedStudy.totalWeight).toFixed(2)) : '0.00'} <span className="text-[10px] text-text-muted">L/t</span> {selectedStudy.totalWeight > 0 ? ((selectedStudy.estimatedPaintVolume / selectedStudy.totalWeight).toFixed(2)) : '0.00'} <span className="text-[10px] text-text-muted">L/t</span>
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-[10px] font-bold text-text-muted uppercase">Peso Total</span> <span className="text-[10px] font-bold text-text-muted uppercase">Peso Total</span>
<span className="text-sm font-black text-primary"> <span className="text-sm font-black text-primary">
{(selectedStudy.totalWeight || 0).toFixed(2)} TON {selectedStudy.totalWeight.toFixed(2)} TON
</span> </span>
</div> </div>
</div> </div>
@@ -926,7 +926,7 @@ export const YieldStudyDashboard: React.FC = () => {
<div className="grid grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-4 gap-4 mb-8">
<div className="border border-gray-300 rounded-xl p-4 space-y-1"> <div className="border border-gray-300 rounded-xl p-4 space-y-1">
<span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Peso Total (Ton)</span> <span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Peso Total (Ton)</span>
<div className="text-xl font-black">{(selectedStudy.totalWeight || 0).toFixed(2)}</div> <div className="text-xl font-black">{selectedStudy.totalWeight.toFixed(2)}</div>
<p className="text-[7px] text-gray-400 font-bold uppercase">Soma das categorias</p> <p className="text-[7px] text-gray-400 font-bold uppercase">Soma das categorias</p>
</div> </div>
<div className="border border-gray-300 rounded-xl p-4 space-y-1"> <div className="border border-gray-300 rounded-xl p-4 space-y-1">
@@ -941,7 +941,7 @@ export const YieldStudyDashboard: React.FC = () => {
</div> </div>
<div className="border border-gray-300 rounded-xl p-4 space-y-1 border-black bg-gray-50"> <div className="border border-gray-300 rounded-xl p-4 space-y-1 border-black bg-gray-50">
<span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Taxa Média</span> <span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Taxa Média</span>
<div className="text-xl font-black">{selectedStudy.totalWeight > 0 ? ((selectedStudy.estimatedPaintVolume || 0) / selectedStudy.totalWeight).toFixed(2) : '0.00'} <span className="text-[10px]">L/t</span></div> <div className="text-xl font-black">{selectedStudy.totalWeight > 0 ? (selectedStudy.estimatedPaintVolume / selectedStudy.totalWeight).toFixed(2) : '0.00'} <span className="text-[10px]">L/t</span></div>
<p className="text-[7px] text-gray-400 font-bold uppercase">Rendimento Global</p> <p className="text-[7px] text-gray-400 font-bold uppercase">Rendimento Global</p>
</div> </div>
</div> </div>
@@ -969,7 +969,7 @@ export const YieldStudyDashboard: React.FC = () => {
<td className="py-3 pr-4"> <td className="py-3 pr-4">
<div className="text-[11px] font-black text-gray-800">{cat.name}</div> <div className="text-[11px] font-black text-gray-800">{cat.name}</div>
</td> </td>
<td className="py-3 text-center text-[10px] font-bold text-amber-700">{(cat.weight || 0).toFixed(2)}</td> <td className="py-3 text-center text-[10px] font-bold text-amber-700">{cat.weight.toFixed(2)}</td>
<td className="py-3 text-center text-[10px] font-bold text-blue-700">{cat.area ? Math.round(cat.area) : '--'}</td> <td className="py-3 text-center text-[10px] font-bold text-blue-700">{cat.area ? Math.round(cat.area) : '--'}</td>
<td className="py-3 text-center text-[10px] font-bold text-amber-700">{cat.historicalYield}</td> <td className="py-3 text-center text-[10px] font-bold text-amber-700">{cat.historicalYield}</td>
<td className="py-3 text-center text-[10px] font-bold text-blue-700">{cat.efficiency}%</td> <td className="py-3 text-center text-[10px] font-bold text-blue-700">{cat.efficiency}%</td>
+25 -6
View File
@@ -1,9 +1,9 @@
// API service configuration v2.0 - Logto Auth // API service configuration v1.4 - with auth and error interceptors
import axios from 'axios'; import axios from 'axios';
import { triggerGuestWarning } from '../utils/toastHandler'; import { triggerGuestWarning } from '../utils/toastHandler';
import { getToken } from '../main';
export const getBaseUrl = () => { export const getBaseUrl = () => {
// Priority: Env var -> Relative path (handled by Vite proxy in dev, or Nginx/Vercel in prod)
if (import.meta.env.VITE_API_URL) { if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL; return import.meta.env.VITE_API_URL;
} }
@@ -17,26 +17,43 @@ const api = axios.create({
}, },
}); });
let currentToken: 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
export const setApiToken = (token: string | null) => {
currentToken = token;
};
// Function to set the organization ID and Name (called from Layout/Context)
export const setApiOrgData = (orgId: string | null, orgName: string | null = null) => { export const setApiOrgData = (orgId: string | null, orgName: string | null = null) => {
currentOrgId = orgId; currentOrgId = orgId;
currentOrgName = orgName; currentOrgName = orgName;
}; };
export const setApiOrganizationId = setApiOrgData; // Legacy support
export const setApiOrgId = (orgId: string | null) => {
setApiOrgData(orgId, null);
};
// Alias for consistency
export const setApiOrganizationId = setApiOrgId;
// Request interceptor to add clerk user ID and Org ID headers
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
const token = getToken(); console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`, {
if (token) { orgId: currentOrgId
config.headers['Authorization'] = `Bearer ${token}`; });
if (currentToken) {
config.headers['Authorization'] = `Bearer ${currentToken}`;
} }
if (currentOrgId) { if (currentOrgId) {
config.headers['x-organization-id'] = currentOrgId; config.headers['x-organization-id'] = currentOrgId;
} }
if (currentOrgName) { if (currentOrgName) {
// Encode to handle special characters
config.headers['x-organization-name'] = encodeURIComponent(currentOrgName); config.headers['x-organization-name'] = encodeURIComponent(currentOrgName);
} }
return config; return config;
@@ -46,10 +63,12 @@ api.interceptors.request.use(
} }
); );
// Response interceptor to handle 403 errors (guest access denied)
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 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();
+2 -2
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 handle organization-id headers // Axios interceptors in api.ts automatically handle x-auth-user-id and x-organization-id headers
const response = await api.put('/system-settings', settings); const response = await api.put('/system-settings', settings);
return response.data; return response.data;
}, },
@@ -51,7 +51,7 @@ export const systemSettingsService = {
export interface GlobalUser { export interface GlobalUser {
_id: string; _id: string;
id: string; externalId: string;
name: string; name: string;
email: string; email: string;
role: string; role: string;
+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;
logtoId?: string; externalId: string;
email: string; email: string;
name: string; name: string;
role: UserRole; role: UserRole;
+52 -20
View File
@@ -11,6 +11,7 @@ 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';
@@ -22,31 +23,64 @@ import path from 'path';
const app = express(); const app = express();
app.use(cors({ app.use(cors({
origin: '*', 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', 'x-organization-id']
})); }));
app.use(express.json());
import { extractUser } from './middleware/roleMiddleware.js';
app.use(express.json({ limit: '50mb' })); // LOG DE DEPURAÇÃO PARA CONEXÃO
app.use(express.urlencoded({ limit: '50mb', extended: true })); app.use((req, res, next) => {
import { extractUser } from './middleware/authMiddleware.js'; console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
import { authMiddleware } from './middleware/auth.js';
app.use(authMiddleware);
app.use(extractUser); app.use(extractUser);
// Static Uploads // Static Uploads
import fs from 'fs'; import fs from 'fs';
const uploadsPath = path.join(process.cwd(), 'uploads'); const uploadsPath = path.join(process.cwd(), 'uploads');
// Ensure uploads directory exists
if (!fs.existsSync(uploadsPath)) { if (!fs.existsSync(uploadsPath)) {
fs.mkdirSync(uploadsPath, { recursive: true }); fs.mkdirSync(uploadsPath, { recursive: true });
} }
app.use('/uploads', express.static(uploadsPath)); app.use('/uploads', express.static(uploadsPath));
// Serve frontend static files // Migration Route (Temporary)
const distPath = path.join(process.cwd(), 'dist'); import pool from './config/postgres.js';
app.use(express.static(distPath)); app.get('/api/admin/migrate-schema', async (req, res) => {
const TABLES = [
"organizations", "users", "user_organizations", "projects",
"parts", "painting_schemes", "inspections", "instruments",
"stock_items", "stock_movements"
];
try {
const client = await pool.connect();
await client.query("CREATE SCHEMA IF NOT EXISTS gpi;");
const results = [];
for (const table of TABLES) {
try {
// Try to move from public to gpi
await client.query(`ALTER TABLE public."${table}" SET SCHEMA gpi;`);
results.push({ table, status: 'moved to gpi' });
} catch (err: any) {
results.push({ table, status: 'error', error: err.message });
}
}
client.release();
res.json({ message: "Schema migration finished", results });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Routes // Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
app.use('/api/projects', projectRoutes); app.use('/api/projects', projectRoutes);
app.use('/api/parts', partRoutes); app.use('/api/parts', partRoutes);
@@ -66,22 +100,20 @@ app.use('/api/messages', messageRoutes);
app.use('/api/backup', backupRoutes); app.use('/api/backup', backupRoutes);
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date(), auth: 'logto' }); res.json({ status: 'ok', timestamp: new Date() });
}); });
app.get('/api/test', (req, res) => { // Serve frontend static files
res.json({ status: 'ok', message: 'Test endpoint working' }); const clientPath = path.join(process.cwd(), 'dist', 'client');
}); if (fs.existsSync(clientPath)) {
app.use(express.static(clientPath));
// SPA fallback - must be last
app.use((req, res, next) => { app.use((req, res, next) => {
res.sendFile(path.join(distPath, 'index.html')); if (!req.path.startsWith('/api') && !req.path.startsWith('/uploads')) {
}); res.sendFile(path.join(clientPath, 'index.html'));
} else {
// Error handler next();
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { }
console.error('Express Error:', err);
res.status(500).json({ error: 'Internal server error' });
}); });
}
export default app; export default app;
+38 -8
View File
@@ -1,16 +1,46 @@
import { supabase } from './supabase.js'; import mongoose from 'mongoose';
import { GridFSBucket } from 'mongodb';
export let bucket: GridFSBucket;
export const connectDB = async () => { export const connectDB = async () => {
try { try {
const { data, error } = await supabase.from('users').select('count'); const uri = process.env.MONGODB_URI;
if (!uri) {
if (error) { throw new Error('MONGODB_URI is not defined in environment variables');
console.error('❌ Erro ao conectar no Supabase:', error);
throw error;
} }
console.log('✅ Conectado ao Supabase (schema: gpi)'); 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('❌ Erro de conexão:', error); console.error('❌ MongoDB connection error:', error);
console.warn('⚠️ Server will continue running for debugging, but database features will be unavailable.');
// process.exit(1);
} }
}; };
+18
View File
@@ -0,0 +1,18 @@
import pg from 'pg';
const { Pool } = pg;
import dotenv from 'dotenv';
dotenv.config();
const pool = new Pool({
host: process.env.DB_HOST || 'supabase-db',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'Xz0oyb6ArGYG5uAVTVwcvJxRrMuT7EIJ',
database: process.env.DB_NAME || 'postgres',
ssl: false // Internal network usually doesn't need SSL
});
export const query = (text: string, params?: any[]) => pool.query(text, params);
export default pool;
-72
View File
@@ -1,72 +0,0 @@
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
dotenv.config();
const supabaseUrl = process.env.SUPABASE_URL || 'https://supabase.reifonas.cloud';
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseServiceKey) {
throw new Error('❌ SUPABASE_SERVICE_ROLE_KEY is missing in environment variables');
}
export const GPI_SCHEMA = 'public';
export const supabase = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
export async function queryGpi(table: string, query?: any) {
let dbQuery = supabase.from(table).select('*');
if (query) {
if (query.filter) {
Object.entries(query.filter).forEach(([key, value]) => {
dbQuery = dbQuery.eq(key, value);
});
}
if (query.order) {
dbQuery = dbQuery.order(query.order.by || 'created_at', { ascending: query.order.asc ?? false });
}
if (query.limit) {
dbQuery = dbQuery.limit(query.limit);
}
if (query.offset) {
dbQuery = dbQuery.range(query.offset, query.offset + (query.limit || 10) - 1);
}
}
return await dbQuery;
}
export async function insertGpi(table: string, data: any) {
return await supabase.from(table).insert(data).select();
}
export async function updateGpi(table: string, id: string, data: any) {
return await supabase.from(table).update(data).eq('id', id).select();
}
export async function deleteGpi(table: string, id: string) {
return await supabase.from(table).delete().eq('id', id);
}
export async function findOneGpi(table: string, filters: Record<string, any>) {
let query = supabase.from(table).select('*');
Object.entries(filters).forEach(([key, value]) => {
query = query.eq(key, value);
});
const { data, error } = await query.single();
if (error && error.code !== 'PGRST116') {
throw error;
}
return data;
}
console.log('✅ Supabase client initialized');
+1 -5
View File
@@ -128,12 +128,8 @@ export const getProjectAnalysis = async (req: Request, res: Response) => {
res.json(analysis); res.json(analysis);
} catch (error: unknown) { } catch (error: unknown) {
console.error('CRITICAL: Error in getProjectAnalysis controller:', error);
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ res.status(500).json({ error: message });
error: message,
stack: error instanceof Error ? error.stack : undefined
});
} }
}; };
@@ -1,11 +1,11 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as appRecordService from '../services/applicationRecordService.js'; import * as appRecordService from '../services/applicationRecordService.js';
import '../middleware/authMiddleware.js'; // Ensure type augmentation import '../middleware/roleMiddleware.js'; // Ensure type augmentation
export const createApplicationRecord = async (req: Request, res: Response) => { export const createApplicationRecord = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const createdBy = req.appUser?.email || 'guest'; const createdBy = req.appUser?.externalId;
const record = await appRecordService.createApplicationRecord({ ...req.body, organizationId, createdBy }); const record = await appRecordService.createApplicationRecord({ ...req.body, organizationId, createdBy });
res.status(201).json(record); res.status(201).json(record);
} catch (error: unknown) { } catch (error: unknown) {
@@ -29,10 +29,17 @@ export const getApplicationRecordsByProject = async (req: Request, res: Response
export const updateApplicationRecord = async (req: Request, res: Response) => { export const updateApplicationRecord = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.externalId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const record = await appRecordService.updateApplicationRecord( const record = await appRecordService.updateApplicationRecord(
req.params.id as string, req.params.id as string,
req.body req.body,
organizationId,
userId,
userRole as any,
isDeveloper
); );
if (!record) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' }); if (!record) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.json(record); res.json(record);
@@ -44,8 +51,17 @@ export const updateApplicationRecord = async (req: Request, res: Response) => {
export const deleteApplicationRecord = async (req: Request, res: Response) => { export const deleteApplicationRecord = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.externalId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const success = await appRecordService.deleteApplicationRecord( const success = await appRecordService.deleteApplicationRecord(
req.params.id as string req.params.id as string,
organizationId,
userId,
userRole as any,
isDeveloper
); );
if (!success) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' }); if (!success) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.status(204).send(); res.status(204).send();
+129
View File
@@ -0,0 +1,129 @@
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import User, { IUser } from '../models/User.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
import { v4 as uuidv4 } from 'uuid';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod';
export const register = async (req: Request, res: Response): Promise<void> => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
res.status(400).json({ error: 'Todos os campos são obrigatórios' });
return;
}
const existingUser = await User.findOne({ email });
if (existingUser) {
res.status(400).json({ error: 'Email já cadastrado' });
return;
}
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
// Gere um externalId falso apenas para manter retrocompatibilidade no banco
const fakeAuthId = `user_${uuidv4().replace(/-/g, '')}`;
const newUser = new User({
name,
email,
passwordHash,
externalId: fakeAuthId,
role: 'member',
isBanned: false
});
await newUser.save();
const token = jwt.sign(
{ userId: newUser._id.toString(), externalId: newUser.externalId, role: newUser.role, organizationId: newUser.organizationId },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({
message: 'Usuário criado com sucesso',
token,
user: { id: newUser._id, name: newUser.name, email: newUser.email, role: newUser.role, externalId: newUser.externalId }
});
} catch (error) {
console.error('Register Error:', error);
res.status(500).json({ error: 'Erro no servidor' });
}
};
export const login = async (req: Request, res: Response): Promise<void> => {
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({ error: 'Email e senha são obrigatórios' });
return;
}
const user = await User.findOne({ email });
if (!user) {
res.status(400).json({ error: 'Usuário não encontrado' });
return;
}
if (!user.passwordHash) {
res.status(400).json({ error: 'Usuário do sistema antigo. Por favor, solicite a redefinição de senha ou recrie sua conta se possível.' });
return;
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
res.status(400).json({ error: 'Credenciais inválidas' });
return;
}
const token = jwt.sign(
{ userId: user._id.toString(), externalId: user.externalId, role: user.role, organizationId: user.organizationId },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(200).json({
message: 'Login realizado com sucesso',
token,
user: {
id: user._id,
name: user.name,
email: user.email,
role: user.role,
externalId: user.externalId,
organizationId: user.organizationId
}
});
} catch (error) {
console.error('Login Error:', error);
res.status(500).json({ error: 'Erro no servidor' });
}
};
export const getMe = async (req: Request, res: Response): Promise<void> => {
try {
// O usuário é extraído pelo middleware extractUser e colocado em req.appUser
if (!req.appUser) {
res.status(401).json({ error: 'Não autorizado' });
return;
}
res.status(200).json({
id: req.appUser._id,
name: req.appUser.name,
email: req.appUser.email,
role: req.appUser.role,
externalId: req.appUser.externalId,
organizationId: req.appUser.organizationId
});
} catch (error) {
console.error('GetMe Error:', error);
res.status(500).json({ error: 'Erro no servidor' });
}
};
+235 -17
View File
@@ -1,7 +1,9 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as dataSheetService from '../services/dataSheetService.js'; import * as dataSheetService from '../services/dataSheetService.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js'; import fs from 'fs';
import { IAppUser } from '../middleware/authMiddleware.js'; import * as pdfExtractionService from '../services/pdfExtractionService.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
import { notificationService } from '../services/notificationService.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: IAppUser; appUser?: IAppUser;
@@ -10,10 +12,13 @@ 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);
res.json(toCamelCase(sheets)); console.log(`Backend: Found ${sheets.length} sheets`);
res.json(sheets);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
@@ -23,47 +28,260 @@ export const extractData = async (req: AuthRequest, res: Response) => {
if (!file) { if (!file) {
return res.status(400).json({ error: 'File is required' }); return res.status(400).json({ error: 'File is required' });
} }
res.json({ extracted: true });
const fileBuffer = fs.readFileSync(file.path);
const data = await pdfExtractionService.extractDataFromPdf(fileBuffer);
// Return extracted data AND the file path so we don't need to re-upload
res.json({
...data,
tempFilePath: file.path
});
} catch (error: unknown) { } catch (error: unknown) {
res.json({ extracted: false }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const createDataSheet = async (req: AuthRequest, res: Response) => { export const createDataSheet = async (req: AuthRequest, res: Response) => {
try { try {
const file = req.file;
const {
name, manufacturer, type, solidsVolume, density,
mixingRatio, mixingRatioWeight, mixingRatioVolume,
yieldTheoretical, dftReference, yieldFactor,
wftMin, wftMax, dftMin, dftMax, reducer, dilution,
notes, fileUrl,
manufacturerCode, minStock, typicalApplication
} = req.body;
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const payload = { ...req.body, organization_id: organizationId };
const newSheet = await dataSheetService.createDataSheet(toSnakeCase(payload)); // Note: New logic prefers 'file' upload which we store in DB.
res.status(201).json(toCamelCase(newSheet)); // If fileUrl is provided (legacy or external link), we use that but don't store binary.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let fileId: any = undefined;
let finalFileUrl = fileUrl || '';
if (file) {
// Read file buffer
const buffer = fs.readFileSync(file.path);
// Save to StoredFile collection
const { default: StoredFile } = await import('../models/StoredFile.js');
const newFile = await StoredFile.create({
filename: file.originalname,
contentType: file.mimetype,
data: buffer,
size: file.size,
uploadDate: new Date()
});
fileId = newFile._id;
finalFileUrl = newFile._id.toString(); // Use ID as URL reference for consistency with frontend expectations if possible, or we might need to adjust frontend to use /api/datasheets/file/:id
// Clean up temp file
try {
fs.unlinkSync(file.path);
} catch (error) {
console.warn('Failed to delete temp file:', file.path, error);
}
}
if (!fileId && !finalFileUrl) {
// Check if fileUrl allows empty. The schema says optional now, but logically a datasheet usually has a file.
// However, for simplified Diluent registration, we might not have one.
// If the user didn't send a file and didn't send a URL, and schema is optional, we can proceed.
// But let's check if we want to enforce it.
// If manufacturerCode (Diluent indicator?) is present, maybe skip check?
// Actually, I removed 'required' from schema, so I should probably relax this check too.
// return res.status(400).json({ error: 'File is required' });
}
const newSheet = await dataSheetService.createDataSheet({
name,
manufacturer,
manufacturerCode,
type,
minStock: minStock ? Number(minStock) : undefined,
typicalApplication,
fileUrl: finalFileUrl,
fileId: fileId,
solidsVolume: solidsVolume ? Number(solidsVolume) : undefined,
density: density ? Number(density) : undefined,
mixingRatio,
mixingRatioWeight,
mixingRatioVolume,
yieldTheoretical: yieldTheoretical ? Number(yieldTheoretical) : undefined,
dftReference: dftReference ? Number(dftReference) : undefined,
yieldFactor: yieldFactor ? Number(yieldFactor) : undefined,
wftMin: wftMin ? Number(wftMin) : undefined,
wftMax: wftMax ? Number(wftMax) : undefined,
dftMin: dftMin ? Number(dftMin) : undefined,
dftMax: dftMax ? Number(dftMax) : undefined,
reducer,
dilution: dilution ? Number(dilution) : undefined,
notes,
organizationId
});
// Notificação de Nova Ficha Técnica
if (organizationId) {
await notificationService.create({
organizationId,
title: 'Nova Ficha Técnica',
message: `A ficha técnica "${name}" (${manufacturer}) foi adicionada à biblioteca.`,
type: 'info',
metadata: { dataSheetId: newSheet._id, triggerType: 'datasheet_created' }
});
}
res.status(201).json(newSheet);
} catch (error: unknown) { } catch (error: unknown) {
res.status(400).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error creating datasheet:', error);
res.status(500).json({ error: message });
} }
}; };
export const deleteDataSheet = async (req: AuthRequest, res: Response) => { export const deleteDataSheet = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
await dataSheetService.deleteDataSheet(id as string); const organizationId = req.appUser?.organizationId;
// Find sheet to delete file if exists
// (Optional: Implement file deletion logic here if strict cleanup needed)
const success = await dataSheetService.deleteDataSheet(id as string, organizationId);
if (success) {
res.status(204).send(); res.status(204).send();
} else {
res.status(404).json({ error: 'Data sheet not found' });
}
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updateDataSheet = async (req: AuthRequest, res: Response) => { export const updateDataSheet = async (req: AuthRequest, res: Response) => {
try { try {
const id = req.params.id as string; const id = req.params.id as string;
const updatedSheet = await dataSheetService.updateDataSheet(id, toSnakeCase(req.body)); const file = req.file;
res.json(toCamelCase(updatedSheet || req.body)); const organizationId = req.appUser?.organizationId;
const {
name, manufacturer, type, solidsVolume, density,
mixingRatio, mixingRatioWeight, mixingRatioVolume,
yieldTheoretical, dftReference, yieldFactor,
wftMin, wftMax, dftMin, dftMax, reducer, dilution,
notes, fileUrl
} = req.body;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updates: Record<string, any> = {
name,
manufacturer,
type,
notes,
solidsVolume: solidsVolume ? Number(solidsVolume) : undefined,
density: density ? Number(density) : undefined,
yieldTheoretical: yieldTheoretical ? Number(yieldTheoretical) : undefined,
dftReference: dftReference ? Number(dftReference) : undefined,
yieldFactor: yieldFactor ? Number(yieldFactor) : undefined,
wftMin: wftMin ? Number(wftMin) : undefined,
wftMax: wftMax ? Number(wftMax) : undefined,
dftMin: dftMin ? Number(dftMin) : undefined,
dftMax: dftMax ? Number(dftMax) : undefined,
reducer,
dilution: dilution ? Number(dilution) : undefined,
mixingRatio,
mixingRatioWeight,
mixingRatioVolume
};
if (file) {
// Read file buffer
const buffer = fs.readFileSync(file.path);
// Save to StoredFile collection
const { default: StoredFile } = await import('../models/StoredFile.js');
const newFile = await StoredFile.create({
filename: file.originalname,
contentType: file.mimetype,
data: buffer,
size: file.size,
uploadDate: new Date()
});
updates.fileId = newFile._id;
updates.fileUrl = newFile._id.toString();
// Clean up temp file
try {
fs.unlinkSync(file.path);
} catch (error) {
console.warn('Failed to delete temp file:', file.path, error);
}
} else if (fileUrl) {
updates.fileUrl = String(fileUrl);
// If fileUrl is being updated but not file, we might lose fileId reference?
// If the user sends the same fileUrl (which is the ID), it's fine.
// But if they send a new external URL, we should probably unset fileId.
// For now, let's assume if it's an external URL, fileId should remain unless explicitly cleared?
// Safer: if fileUrl is explicitly sent and doesn't match an ID format, maybe clear fileId?
// Actually, keep it simple.
}
const updatedSheet = await dataSheetService.updateDataSheet(id, updates, organizationId);
if (updatedSheet) {
res.json(updatedSheet);
} else {
res.status(404).json({ error: 'Data sheet not found' });
}
} catch (error: unknown) { } catch (error: unknown) {
res.status(400).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error updating datasheet:', error);
res.status(500).json({ error: message });
} }
}; };
export const getFile = async (req: AuthRequest, res: Response) => { export const getFile = async (req: Request, res: Response) => {
try { try {
const id_or_filename = req.params.id as string;
// Check if it's a MongoDB ObjectId (24 hex chars)
if (/^[0-9a-fA-F]{24}$/.test(id_or_filename)) {
const { default: StoredFile } = await import('../models/StoredFile.js');
const fileDoc = await StoredFile.findById(id_or_filename);
if (fileDoc) {
res.set('Content-Type', fileDoc.contentType || 'application/pdf');
res.set('Content-Disposition', `inline; filename="${fileDoc.filename}"`);
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=3600');
return res.send(fileDoc.data);
}
}
// Fallback to file system (legacy)
const stream = dataSheetService.getFileStream(id_or_filename);
stream.on('file', (file) => {
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${file.filename}"`);
});
stream.on('error', () => {
res.status(404).json({ error: 'File not found' }); res.status(404).json({ error: 'File not found' });
});
stream.pipe(res);
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: 'File not found' }); const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error getting file:', error);
res.status(500).json({ error: message });
} }
}; };
+116 -35
View File
@@ -1,7 +1,12 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import GeometryType from '../models/GeometryType.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js'; import { IAppUser } from '../middleware/roleMiddleware.js';
interface AuthRequest extends Request {
appUser?: IAppUser;
}
// Default geometry types to seed if none exist
const DEFAULT_TYPES = [ const DEFAULT_TYPES = [
{ name: 'Guarda-corpo/escada', efficiencyLoss: 20 }, { name: 'Guarda-corpo/escada', efficiencyLoss: 20 },
{ name: 'Vigas leves', efficiencyLoss: 20 }, { name: 'Vigas leves', efficiencyLoss: 20 },
@@ -17,63 +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 { data, error } = await supabase.from('geometry_types').select('*'); const organizationId = req.appUser?.organizationId;
if (error && error.code !== '42P01') throw error; const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
res.json(toCamelCase(data || [])); console.log(`[GeometryType] Fetching for org: ${organizationId}, globalAdmin: ${isGlobalAdmin}`);
if (!organizationId && !isGlobalAdmin) {
return res.status(400).json({ error: 'Organization ID missing' });
}
// Search for org-specific types OR orphan types (legacy)
const query = isGlobalAdmin
? {}
: { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
let types = await GeometryType.find(query).sort({ name: 1 });
// Auto-seed if empty AND we HAVE an organization (don't seed for global view)
if (types.length === 0 && organizationId) {
console.log(`[GeometryType] No types found. Seeding defaults...`);
try {
const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId }));
types = await GeometryType.insertMany(seedData) as any;
console.log(`[GeometryType] Seeded ${types.length} types successfully.`);
} catch (seedError) {
console.error('[GeometryType] Seeding failed:', seedError);
return res.json([]);
}
}
res.json(types);
} catch (error: unknown) { } catch (error: unknown) {
res.json(DEFAULT_TYPES); console.error('[GeometryType] Error in getAllnames:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const restoreDefaults = async (req: Request, res: Response) => { export const restoreDefaults = async (req: AuthRequest, res: Response) => {
try { try {
res.json(DEFAULT_TYPES); const organizationId = req.appUser?.organizationId;
if (!organizationId) {
return res.status(400).json({ error: 'Organization ID missing' });
}
// Delete all existing types for this org
await GeometryType.deleteMany({ organizationId });
// Insert defaults
const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId }));
const types = await GeometryType.insertMany(seedData);
res.json(types);
} catch (error: unknown) { } catch (error: unknown) {
res.json(DEFAULT_TYPES); 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 payload = toSnakeCase({ const organizationId = req.appUser?.organizationId;
...req.body, const { name, efficiencyLoss } = req.body;
organizationId: (req as any).appUser?.organizationId
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const newType = new GeometryType({
name,
efficiencyLoss: Number(efficiencyLoss) || 0,
organizationId
}); });
const { data, error } = await supabase const saved = await newType.save();
.from('geometry_types') res.status(201).json(saved);
.insert(payload)
.select()
.single();
if (error) throw error;
res.status(201).json(toCamelCase(data));
} catch (error: unknown) { } catch (error: unknown) {
res.json(req.body); 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 { data, error } = await supabase const { id } = req.params;
.from('geometry_types') const organizationId = req.appUser?.organizationId;
.update(toSnakeCase(req.body)) const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
.eq('id', req.params.id) const { name, efficiencyLoss } = req.body;
.select()
.single(); const query = isGlobalAdmin
if (error) throw error; ? { _id: id }
res.json(toCamelCase(data)); : { _id: id, organizationId };
const updated = await GeometryType.findOneAndUpdate(
query,
{ name, efficiencyLoss: Number(efficiencyLoss) },
{ new: true }
);
if (!updated) {
return res.status(404).json({ error: 'Record not found' });
}
res.json(updated);
} catch (error: unknown) { } catch (error: unknown) {
res.json(req.body); 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 {
await supabase.from('geometry_types').delete().eq('id', req.params.id); const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const query = isGlobalAdmin
? { _id: id }
: { _id: id, organizationId };
const deleted = await GeometryType.findOneAndDelete(query);
if (!deleted) {
return res.status(404).json({ error: 'Record not found' });
}
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
res.status(204).send(); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
+43 -21
View File
@@ -1,34 +1,30 @@
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 { toCamelCase, toSnakeCase } from '../utils/caseMapper.js'; import '../middleware/roleMiddleware.js'; // Ensure type augmentation
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?.email || 'guest'; const createdBy = req.appUser?.externalId;
const inspection = await inspectionService.createInspection({
const payload = toSnakeCase({
...req.body, ...req.body,
organizationId, organizationId,
createdBy createdBy
}); });
const inspection = await inspectionService.createInspection(payload); // Notificação de Inspeção Reprovada
if (req.body.appearance === 'rejected' && organizationId) { if (req.body.appearance === 'rejected' && organizationId) {
try {
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' }
}); });
} catch (e) { /* ignore notification errors */ }
} }
res.status(201).json(toCamelCase(inspection)); res.status(201).json(inspection);
} 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 });
@@ -40,20 +36,30 @@ export const getInspectionsByProject = async (req: Request, res: Response) => {
const { projectId } = req.params; const { projectId } = req.params;
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const inspections = await inspectionService.getInspectionsByProject(projectId as string, organizationId); const inspections = await inspectionService.getInspectionsByProject(projectId as string, organizationId);
res.json(toCamelCase(inspections || [])); res.json(inspections);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updateInspection = async (req: Request, res: Response) => { export const updateInspection = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.externalId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const inspection = await inspectionService.updateInspection( const inspection = await inspectionService.updateInspection(
req.params.id as string, req.params.id as string,
toSnakeCase(req.body) req.body,
organizationId,
userId,
userRole as any,
isDeveloper
); );
if (!inspection) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' }); if (!inspection) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.json(toCamelCase(inspection)); res.json(inspection);
} 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 });
@@ -62,22 +68,34 @@ 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 {
await inspectionService.deleteInspection(req.params.id as string); const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.externalId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const success = await inspectionService.deleteInspection(
req.params.id as string,
organizationId,
userId,
userRole as any,
isDeveloper
);
if (!success) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
res.status(204).send(); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const getAllInspections = async (req: Request, res: Response) => { export const getAllInspections = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const inspections = organizationId const inspections = await inspectionService.getAllInspections(organizationId);
? await inspectionService.getInspectionsByOrganization(organizationId) res.json(inspections);
: await inspectionService.getInspectionStats();
res.json(toCamelCase(inspections || []));
} catch (error: unknown) { } catch (error: unknown) {
res.json({ total: 0, inspections: [] }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
@@ -86,7 +104,11 @@ export const uploadPhoto = async (req: Request, res: Response) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' }); return res.status(400).json({ error: 'No file uploaded' });
} }
// Return the public URL for the file
// Assuming 'uploads' is served statically at /uploads
const fileUrl = `/uploads/${req.file.filename}`; const fileUrl = `/uploads/${req.file.filename}`;
res.json({ url: fileUrl }); res.json({ url: fileUrl });
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
+84 -28
View File
@@ -1,50 +1,106 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import Instrument from '../models/Instrument.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
export const createInstrument = async (req: Request, res: Response) => { interface AuthRequest extends Request {
appUser?: IAppUser;
}
export const createInstrument = async (req: AuthRequest, res: Response) => {
try { try {
const { data, error } = await supabase const organizationId = req.appUser?.organizationId;
.from('instruments') const { name, type, manufacturer, modelName, serialNumber, calibrationDate, calibrationExpirationDate, certificateUrl, notes } = req.body;
.insert({ ...req.body, organization_id: req.appUser?.organizationId })
.select() const existing = await Instrument.findOne({ organizationId, serialNumber });
.single(); if (existing) {
if (error) throw error; return res.status(400).json({ error: 'Já existe um instrumento com este número de série.' });
res.status(201).json(data); }
// Determinar status inicial baseado na validade
let status = 'active';
if (calibrationExpirationDate && new Date(calibrationExpirationDate) < new Date()) {
status = 'expired';
}
const instrument = await Instrument.create({
organizationId,
name,
type,
manufacturer,
modelName,
serialNumber,
calibrationDate,
calibrationExpirationDate,
certificateUrl,
status,
notes
});
res.status(201).json(instrument);
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const getInstruments = async (req: Request, res: Response) => { export const getInstruments = async (req: AuthRequest, res: Response) => {
try { try {
const { data, error } = await supabase.from('instruments').select('*'); const organizationId = req.appUser?.organizationId;
if (error && error.code !== '42P01') throw error; const { status } = req.query;
res.json(data || []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const query: any = { organizationId };
if (status) query.status = status;
const instruments = await Instrument.find(query).sort({ name: 1 });
res.json(instruments);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updateInstrument = async (req: Request, res: Response) => { export const updateInstrument = async (req: AuthRequest, res: Response) => {
try { try {
const { data, error } = await supabase const { id } = req.params;
.from('instruments') const organizationId = req.appUser?.organizationId;
.update(req.body)
.eq('id', req.params.id) // Recalcular status se data de validade mudar
.select() const updates = { ...req.body };
.single(); if (updates.calibrationExpirationDate) {
if (error) throw error; if (new Date(updates.calibrationExpirationDate) < new Date()) {
res.json(data); updates.status = 'expired';
} else if (updates.status === 'expired') {
// Se estava expirado e a data é futura, reativar (se o usuário não setou outro status)
updates.status = 'active';
}
}
const instrument = await Instrument.findOneAndUpdate(
{ _id: id, organizationId },
updates,
{ new: true }
);
if (!instrument) return res.status(404).json({ error: 'Instrumento não encontrado.' });
res.json(instrument);
} catch (error: unknown) { } catch (error: unknown) {
res.json(req.body); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const deleteInstrument = async (req: Request, res: Response) => { export const deleteInstrument = async (req: AuthRequest, res: Response) => {
try { try {
await supabase.from('instruments').delete().eq('id', req.params.id); const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const deleted = await Instrument.findOneAndDelete({ _id: id, organizationId });
if (!deleted) return res.status(404).json({ error: 'Instrumento não encontrado.' });
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
res.status(204).send(); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
+151 -99
View File
@@ -1,50 +1,57 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import Message from '../models/Message.js';
import OrganizationMember from '../models/OrganizationMember.js';
const TABLE_NOT_FOUND_CODES = ['42P01', 'PGRST116'];
const safeSupabaseQuery = async (table: string, query: any) => {
try {
return await query;
} catch (error: any) {
if (error.code && TABLE_NOT_FOUND_CODES.includes(error.code)) {
console.log(`Table ${table} not found, returning empty result`);
return { data: [], error: null };
}
throw error;
}
};
// Send a message // Send a message
export const sendMessage = async (req: Request, res: Response) => { export const sendMessage = async (req: Request, res: Response) => {
try { try {
const { toUserId, message } = req.body; const { toUserId, message } = req.body;
const fromUserId = req.appUser?.id; const fromUserId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId) { if (!organizationId) {
return res.status(400).json({ error: 'Organização não selecionada.' }); return res.status(400).json({ error: 'Organização não selecionada.' });
} }
if (!fromUserId) {
return res.status(401).json({ error: 'Usuário não autenticado.' });
}
if (!toUserId || !message) { if (!toUserId || !message) {
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.' });
} }
const { data, error } = await supabase if (message.length > 255) {
.from('messages') return res.status(400).json({ error: 'Mensagem muito longa (máximo 255 caracteres).' });
.insert({ }
organization_id: organizationId,
from_user_id: fromUserId,
to_user_id: toUserId,
message,
is_read: false
})
.select()
.single();
if (error) throw error; // Check if there's already a pending (unread) message from this user to that user
res.status(201).json(data); const existingMessage = await Message.findOne({
} catch (error: any) { organizationId,
fromUserId,
toUserId,
isRead: false,
});
if (existingMessage) {
// Update existing message instead of creating a new one
existingMessage.message = message;
existingMessage.updatedAt = new Date();
await existingMessage.save();
return res.json(existingMessage);
}
// Create new message
const newMessage = new Message({
organizationId,
fromUserId,
toUserId,
message,
});
await newMessage.save();
res.status(201).json(newMessage);
} catch (error) {
console.error('Error sending message:', error); console.error('Error sending message:', error);
res.status(500).json({ error: 'Erro ao enviar mensagem.' }); res.status(500).json({ error: 'Erro ao enviar mensagem.' });
} }
@@ -53,24 +60,38 @@ 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: Request, res: Response) => {
try { try {
const toUserId = req.appUser?.id; const toUserId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId) { if (!organizationId) {
return res.status(400).json({ error: 'Organização não selecionada.' }); return res.status(400).json({ error: 'Organização não selecionada.' });
} }
const { data, error } = await supabase if (!toUserId) {
.from('messages') return res.status(401).json({ error: 'Usuário não autenticado.' });
.select('*') }
.eq('organization_id', organizationId)
.eq('to_user_id', toUserId)
.eq('is_read', false)
.eq('is_archived', false);
if (error && error.code !== '42P01') throw error; const messages = await Message.find({
res.json(data || []); organizationId,
} catch (error: any) { toUserId,
isRead: false,
isArchived: false,
isDeletedByRecipient: false,
}).sort({ createdAt: -1 });
// Populate sender info
const messagesWithSender = await Promise.all(
messages.map(async (msg) => {
const sender = await OrganizationMember.findOne({ userId: msg.fromUserId });
return {
...msg.toObject(),
fromUser: sender ? { name: sender.name, email: sender.email } : null,
};
})
);
res.json(messagesWithSender);
} catch (error) {
console.error('Error getting unread messages:', error); 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.' });
} }
@@ -80,21 +101,33 @@ export const getUnreadMessages = async (req: Request, res: Response) => {
export const markMessageAsRead = async (req: Request, res: Response) => { export const markMessageAsRead = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.id; const userId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase if (!organizationId) {
.from('messages') return res.status(400).json({ error: 'Organização não selecionada.' });
.update({ is_read: true, read_at: new Date().toISOString() }) }
.eq('id', id)
.eq('to_user_id', userId)
.eq('organization_id', organizationId)
.select()
.single();
if (error) throw error; if (!userId) {
res.json(data); return res.status(401).json({ error: 'Usuário não autenticado.' });
} catch (error: any) { }
const message = await Message.findOne({
_id: id,
organizationId,
toUserId: userId,
});
if (!message) {
return res.status(404).json({ error: 'Mensagem não encontrada.' });
}
message.isRead = true;
message.readAt = new Date();
await message.save();
res.json(message);
} catch (error) {
console.error('Error marking message as read:', error); 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.' });
} }
@@ -103,66 +136,90 @@ 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: Request, res: Response) => {
try { try {
const fromUserId = req.appUser?.id; const fromUserId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase if (!organizationId) {
.from('messages') return res.status(400).json({ error: 'Organização não selecionada.' });
.select('*') }
.eq('organization_id', organizationId)
.eq('from_user_id', fromUserId)
.eq('is_read', false);
if (error && error.code !== '42P01') throw error; if (!fromUserId) {
res.json(data || []); return res.status(401).json({ error: 'Usuário não autenticado.' });
} catch (error: any) { }
const messages = await Message.find({
organizationId,
fromUserId,
isRead: false,
}).sort({ createdAt: -1 });
// Populate recipient info
const messagesWithRecipient = await Promise.all(
messages.map(async (msg) => {
const recipient = await OrganizationMember.findOne({ userId: msg.toUserId });
return {
...msg.toObject(),
toUser: recipient ? { name: recipient.name, email: recipient.email } : null,
};
})
);
res.json(messagesWithRecipient);
} catch (error) {
console.error('Error getting pending messages:', error); 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.' });
} }
}; };
// Delete a message // 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: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.id; const userId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const { error } = await supabase if (!organizationId) {
.from('messages') return res.status(400).json({ error: 'Organização não selecionada.' });
.delete() }
.eq('id', id)
.eq('from_user_id', userId)
.eq('organization_id', organizationId)
.eq('is_read', false);
if (error) throw error; if (!userId) {
res.status(204).send(); return res.status(401).json({ error: 'Usuário não autenticado.' });
} catch (error: any) { }
const message = await Message.findOne({
_id: id,
organizationId,
fromUserId: userId,
isRead: false, // Can only delete unread messages
});
if (!message) {
return res.status(404).json({ error: 'Mensagem não encontrada ou já foi lida.' });
}
await message.deleteOne();
res.json({ message: 'Mensagem deletada com sucesso.' });
} catch (error) {
console.error('Error deleting message:', error); console.error('Error deleting message:', error);
res.status(500).json({ error: 'Erro ao deletar mensagem.' }); res.status(500).json({ error: 'Erro ao deletar mensagem.' });
} }
}; };
// Recipient archives a message // Recipient deletes/archives a message
export const archiveMessage = async (req: Request, res: Response) => { export const archiveMessage = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.id; const userId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
.from('messages') if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
.update({ is_archived: true, is_read: true })
.eq('id', id)
.eq('to_user_id', userId)
.eq('organization_id', organizationId)
.select()
.single();
if (error) throw error; message.isArchived = true;
res.json(data); message.isRead = true; // Arquivar implica ler
} catch (error: any) { await message.save();
res.json(message);
} 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.' });
} }
@@ -171,21 +228,16 @@ export const archiveMessage = async (req: Request, res: Response) => {
export const recipientDeleteMessage = async (req: Request, res: Response) => { export const recipientDeleteMessage = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.id; const userId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
.from('messages') if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
.update({ 'is_deleted_by_recipient': true })
.eq('id', id)
.eq('to_user_id', userId)
.eq('organization_id', organizationId)
.select()
.single();
if (error) throw error; message.isDeletedByRecipient = true;
await message.save();
res.json({ message: 'Mensagem excluída com sucesso.' }); res.json({ message: 'Mensagem excluída com sucesso.' });
} catch (error: any) { } catch (error) {
console.error('Error deleting message:', error); console.error('Error deleting message:', error);
res.status(500).json({ error: 'Erro ao excluir mensagem.' }); res.status(500).json({ error: 'Erro ao excluir mensagem.' });
} }
@@ -1,116 +1,97 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import { notificationService } from '../services/notificationService.js';
export const notificationController = { export const notificationController = {
getUserNotifications: async (req: Request, res: Response) => { getUserNotifications: async (req: Request, res: Response) => {
try { try {
const organizationId = req.headers['x-organization-id'] as string || req.appUser?.organizationId; const organizationId = req.headers['x-organization-id'] as string;
const userId = req.headers['x-user-id'] as string; // Assumindo que temos o ID do usuário (externalId ou email)
// Se não tiver userId no header (ainda não implementado auth full), tentar pegar do query ou usar um fallback
// Nota: Idealmente o middleware de auth popula req.user. Vamos assumir que passamos x-user-id no frontend por enquanto.
if (!organizationId) { if (!organizationId) {
return res.json([]); // Return empty instead of error return res.status(400).json({ error: 'Organization ID is required' });
} }
const { data: notifications, error } = await supabase const notifications = await notificationService.getUserNotifications(
.from('notifications') userId,
.select('*') organizationId,
.eq('organization_id', organizationId) req.query.includeArchived === 'true'
.order('created_at', { ascending: false }); );
res.json(notifications);
if (error && error.code !== '42P01') throw error;
res.json(notifications || []);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json([]); res.status(500).json({ error: 'Error fetching notifications' });
} }
}, },
markAsRead: async (req: Request, res: Response) => { markAsRead: async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { error } = await supabase const notification = await notificationService.markAsRead(id as string);
.from('notifications') res.json(notification);
.update({ is_read: true })
.eq('id', id);
if (error && error.code !== '42P01') throw error;
res.json({ success: true });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ success: true }); res.status(500).json({ error: 'Error marking notification as read' });
} }
}, },
markAllAsRead: async (req: Request, res: Response) => { markAllAsRead: async (req: Request, res: Response) => {
try { try {
const organizationId = req.headers['x-organization-id'] as string || req.appUser?.organizationId; const organizationId = req.headers['x-organization-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!organizationId) { if (!organizationId) {
return res.json({ success: true }); return res.status(400).json({ error: 'Organization ID is required' });
} }
const { error } = await supabase await notificationService.markAllAsRead(userId, organizationId);
.from('notifications')
.update({ is_read: true })
.eq('organization_id', organizationId);
if (error && error.code !== '42P01') throw error;
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ success: true }); res.status(500).json({ error: 'Error marking all as read' });
} }
}, },
clearAll: async (req: Request, res: Response) => { clearAll: async (req: Request, res: Response) => {
try { try {
const organizationId = req.headers['x-organization-id'] as string || req.appUser?.organizationId; const organizationId = req.headers['x-organization-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!organizationId) { if (!organizationId) {
return res.json({ success: true }); return res.status(400).json({ error: 'Organization ID is required' });
} }
const { error } = await supabase await notificationService.clearAll(userId, organizationId);
.from('notifications')
.delete()
.eq('organization_id', organizationId);
if (error && error.code !== '42P01') throw error;
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ success: true }); res.status(500).json({ error: 'Error clearing all notifications' });
} }
}, },
archive: async (req: Request, res: Response) => { archive: async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { error } = await supabase const userId = req.headers['x-user-id'] as string;
.from('notifications') const notification = await notificationService.archive(id as string, userId);
.update({ is_archived: true }) res.json(notification);
.eq('id', id);
if (error && error.code !== '42P01') throw error;
res.json({ success: true });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ success: true }); res.status(500).json({ error: 'Error archiving notification' });
} }
}, },
delete: async (req: Request, res: Response) => { delete: async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { error } = await supabase const userId = req.headers['x-user-id'] as string;
.from('notifications') await notificationService.softDelete(id as string, userId);
.delete()
.eq('id', id);
if (error && error.code !== '42P01') throw error;
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ success: true }); res.status(500).json({ error: 'Error deleting notification' });
} }
} }
}; };
@@ -1,43 +1,63 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as paintingSchemeService from '../services/paintingSchemeService.js'; import * as paintingSchemeService from '../services/paintingSchemeService.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js';
export const createPaintingScheme = async (req: Request, res: Response) => { export const createPaintingScheme = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const schemeData = toSnakeCase({ ...req.body, organizationId }); console.log("Creating scheme with payload:", req.body);
const scheme = await paintingSchemeService.createPaintingScheme(schemeData); const scheme = await paintingSchemeService.createPaintingScheme({ ...req.body, organizationId });
res.status(201).json(toCamelCase(scheme)); console.log("Created scheme result:", scheme);
res.status(201).json(scheme);
} catch (error: unknown) { } catch (error: unknown) {
res.status(400).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const getPaintingSchemesByProject = async (req: Request, res: Response) => { export const getPaintingSchemesByProject = async (req: Request, res: Response) => {
try { try {
const { projectId } = req.params; const { projectId } = req.params;
const schemes = await paintingSchemeService.getPaintingSchemesByProject(projectId as string); const organizationId = req.appUser?.organizationId;
res.json(toCamelCase(schemes || [])); const schemes = await paintingSchemeService.getPaintingSchemesByProject(projectId as string, organizationId);
res.json(schemes);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updatePaintingScheme = async (req: Request, res: Response) => { export const updatePaintingScheme = async (req: Request, res: Response) => {
try { try {
const scheme = await paintingSchemeService.updatePaintingScheme(req.params.id as string, toSnakeCase(req.body)); const organizationId = req.appUser?.organizationId;
res.json(toCamelCase(scheme || req.body)); console.log("---------------------------------------------------");
console.log(`UPDATE REQUEST: ID=${req.params.id}`);
console.log(`User Org ID: ${organizationId}`);
console.log(`Payload keys: ${Object.keys(req.body)}`);
const scheme = await paintingSchemeService.updatePaintingScheme(req.params.id as string, req.body, organizationId);
console.log(`UPDATE RESULT: ${scheme ? 'SUCCESS' : 'NULL (Doc not found or not matched)'}`);
if (scheme) {
console.log(`Updated Doc Coat: ${scheme.coat}`);
}
console.log("---------------------------------------------------");
res.json(scheme);
} catch (error: unknown) { } catch (error: unknown) {
res.status(400).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const deletePaintingScheme = async (req: Request, res: Response) => { export const deletePaintingScheme = async (req: Request, res: Response) => {
try { try {
await paintingSchemeService.deletePaintingScheme(req.params.id as string); const organizationId = req.appUser?.organizationId;
await paintingSchemeService.deletePaintingScheme(req.params.id as string, organizationId);
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
@@ -45,8 +65,9 @@ export const getAllPaintingSchemes = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const schemes = await paintingSchemeService.getAllSchemes(organizationId); const schemes = await paintingSchemeService.getAllSchemes(organizationId);
res.json(toCamelCase(schemes || [])); res.json(schemes);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
+15 -12
View File
@@ -1,7 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as partService from '../services/partService.js'; import * as partService from '../services/partService.js';
import { IAppUser } from '../middleware/authMiddleware.js'; import { IAppUser } from '../middleware/roleMiddleware.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: IAppUser; appUser?: IAppUser;
@@ -9,12 +8,14 @@ interface AuthRequest extends Request {
export const createPart = async (req: AuthRequest, res: Response) => { export const createPart = async (req: AuthRequest, res: Response) => {
try { try {
console.log('[CREATE PART] Received data:', JSON.stringify(req.body, null, 2));
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const payload = toSnakeCase({ ...req.body, organizationId }); const part = await partService.createPart({ ...req.body, organizationId });
const part = await partService.createPart(payload); console.log('[CREATE PART] Success:', part);
res.status(201).json(toCamelCase(part)); res.status(201).json(part);
} 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('[CREATE PART] Error:', message);
res.status(500).json({ error: message }); res.status(500).json({ error: message });
} }
}; };
@@ -25,7 +26,7 @@ export const getPartsByProject = async (req: AuthRequest, res: Response) => {
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';
const parts = await partService.getPartsByProject(projectId as string, organizationId, isGlobalAdmin); const parts = await partService.getPartsByProject(projectId as string, organizationId, isGlobalAdmin);
res.json(toCamelCase(parts || [])); res.json(parts);
} 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 });
@@ -36,8 +37,8 @@ export const updatePart = 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';
const part = await partService.updatePart(req.params.id as string, toSnakeCase(req.body), organizationId, isGlobalAdmin); const part = await partService.updatePart(req.params.id as string, req.body, organizationId, isGlobalAdmin);
res.json(toCamelCase(part)); res.json(part);
} 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 });
@@ -46,7 +47,9 @@ export const updatePart = async (req: AuthRequest, res: Response) => {
export const deletePart = async (req: AuthRequest, res: Response) => { export const deletePart = async (req: AuthRequest, res: Response) => {
try { try {
await partService.deletePart(req.params.id as string); const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
await partService.deletePart(req.params.id as string, organizationId, isGlobalAdmin);
res.status(204).send(); 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';
@@ -57,9 +60,9 @@ export const deletePart = async (req: AuthRequest, res: Response) => {
export const getAllParts = async (req: AuthRequest, res: Response) => { export const getAllParts = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
if (!organizationId) return res.json([]); const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const parts = await partService.getPartsByOrganization(organizationId); const parts = await partService.getAllParts(organizationId, isGlobalAdmin);
res.json(toCamelCase(parts || [])); res.json(parts);
} 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 });
+24 -16
View File
@@ -1,8 +1,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as projectService from '../services/projectService.js'; import * as projectService from '../services/projectService.js';
import { IAppUser } from '../middleware/authMiddleware.js'; import { IAppUser } from '../middleware/roleMiddleware.js';
import { notificationService } from '../services/notificationService.js'; import { notificationService } from '../services/notificationService.js';
import { toCamelCase } from '../utils/caseMapper.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: IAppUser; appUser?: IAppUser;
@@ -10,9 +9,11 @@ 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 });
res.status(201).json(toCamelCase(project)); console.log('Project created successfully:', project._id);
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';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
@@ -25,10 +26,9 @@ export const getAllProjects = async (req: AuthRequest, res: Response) => {
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com'; const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const { status } = req.query; const { status } = req.query;
const projects = await projectService.getAllProjects(organizationId, isGlobalAdmin, status as string); const projects = await projectService.getAllProjects(organizationId, isGlobalAdmin, status as string);
res.json(toCamelCase(projects)); res.json(projects);
} catch (error: unknown) { } catch (error: unknown) {
console.error('Error in getAllProjects controller:', error); const message = error instanceof Error ? error.message : 'Unknown error';
const message = error instanceof Error ? error.message : JSON.stringify(error);
res.status(500).json({ error: message }); res.status(500).json({ error: message });
} }
}; };
@@ -38,7 +38,7 @@ export const archiveProject = async (req: AuthRequest, res: Response) => {
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';
const project = await projectService.archiveProject(req.params.id as string, organizationId, isGlobalAdmin); const project = await projectService.archiveProject(req.params.id as string, organizationId, isGlobalAdmin);
res.json(toCamelCase(project)); res.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';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
@@ -49,7 +49,7 @@ export const getDashboardProjects = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const projects = await projectService.getDashboardProjects(organizationId); const projects = await projectService.getDashboardProjects(organizationId);
res.json(toCamelCase(projects)); res.json(projects);
} 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 });
@@ -58,8 +58,10 @@ export const getDashboardProjects = async (req: AuthRequest, res: Response) => {
export const getProjectById = async (req: AuthRequest, res: Response) => { export const getProjectById = async (req: AuthRequest, res: Response) => {
try { try {
const project = await projectService.getProjectById(req.params.id as string); const organizationId = req.appUser?.organizationId;
res.json(toCamelCase(project)); const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const project = await projectService.getProjectById(req.params.id as string, organizationId, isGlobalAdmin);
res.json(project);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(404).json({ error: message }); res.status(404).json({ error: message });
@@ -69,19 +71,23 @@ export const getProjectById = async (req: AuthRequest, res: Response) => {
export const updateProject = async (req: AuthRequest, res: Response) => { export const updateProject = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const project = await projectService.updateProject(req.params.id as string, req.body); const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const project = await projectService.updateProject(req.params.id as string, req.body, organizationId, isGlobalAdmin);
if (req.body.weightKg !== undefined && organizationId && project) { // Notificação se Peso mudar (Exemplo simplificado, idealmente compararíamos com valor anterior)
// Como o update retorna o objeto atualizado, podemos assumir que se o body tem weightKg, houve intenção de mudar.
// Para ser mais preciso, deveríamos buscar o antigo antes, mas para MVP vamos notificar se houver o campo no body.
if (req.body.weightKg !== undefined && organizationId) {
await notificationService.create({ 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.weight_kg}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' }
}); });
} }
res.json(toCamelCase(project)); res.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';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
@@ -90,7 +96,9 @@ export const updateProject = async (req: AuthRequest, res: Response) => {
export const deleteProject = async (req: AuthRequest, res: Response) => { export const deleteProject = async (req: AuthRequest, res: Response) => {
try { try {
await projectService.deleteProject(req.params.id as string); const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
await projectService.deleteProject(req.params.id as string, organizationId, isGlobalAdmin);
res.status(204).send(); 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';
+455 -105
View File
@@ -1,152 +1,502 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import StockItem from '../models/StockItem.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js'; import StockMovement from '../models/StockMovement.js';
import { IAppUser } from '../middleware/authMiddleware.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
import { notificationService } from '../services/notificationService.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: IAppUser; appUser?: IAppUser;
} }
export const getStockItems = async (req: AuthRequest, res: Response) => {
try {
const { data: items, error: itemsError } = await supabase.from('stock_items').select('*');
if (itemsError && itemsError.code !== '42P01') throw itemsError;
if (!items || items.length === 0) return res.json([]);
// Get unique data sheet IDs
const dsIds = [...new Set(items.map(i => i.data_sheet_id).filter(Boolean))];
let dataSheets: any[] = [];
if (dsIds.length > 0) {
const { data: sheets, error: dsError } = await supabase
.from('technical_data_sheets')
.select('*')
.in('id', dsIds);
if (!dsError) dataSheets = sheets || [];
}
// Map data sheets to a lookup object
const dsMap = Object.fromEntries(dataSheets.map(ds => [ds.id, ds]));
// Merge and convert to camelCase
const enrichedItems = items.map(item => ({
...item,
data_sheet_id: dsMap[item.data_sheet_id] || item.data_sheet_id
}));
res.json(toCamelCase(enrichedItems));
} catch (error: unknown) {
console.error('Error fetching stock items:', error);
res.json([]);
}
};
export const getStockItemById = async (req: AuthRequest, res: Response) => {
try {
const { data, error } = await supabase.from('stock_items').select('*').eq('id', req.params.id).single();
if (error) throw error;
res.json(toCamelCase(data));
} catch (error: unknown) {
res.json(null);
}
};
export const getStockMovements = async (req: AuthRequest, res: Response) => {
try {
const { data, error } = await supabase.from('stock_movements').select('*').eq('stock_item_id', req.params.id);
if (error && error.code !== '42P01') throw error;
res.json(toCamelCase(data || []));
} catch (error: unknown) {
res.json([]);
}
};
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
try {
const { data, error } = await supabase.from('stock_audit_logs').select('*').eq('stock_item_id', req.params.id);
if (error && error.code !== '42P01') throw error;
res.json(toCamelCase(data || []));
} catch (error: unknown) {
res.json([]);
}
};
export const createStockItem = async (req: AuthRequest, res: Response) => { export const createStockItem = async (req: AuthRequest, res: Response) => {
try { try {
const itemData = toSnakeCase({ ...req.body, organizationId: req.appUser?.organizationId }); const organizationId = req.appUser?.organizationId;
const { data, error } = await supabase.from('stock_items').insert(itemData).select().single(); const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
if (error) throw error; const {
res.status(201).json(toCamelCase(data)); dataSheetId,
rrNumber,
batchNumber,
quantity,
unit,
expirationDate,
notes,
color,
invoiceNumber,
receivedBy,
minStock
} = req.body;
// Validation
if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) {
return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' });
}
// Check for duplicate RR within Org
const existing = await StockItem.findOne({ organizationId, rrNumber });
if (existing) {
return res.status(400).json({ error: `Já existe um item com o RR ${rrNumber}.` });
}
// --- Min Stock Inheritance Logic ---
let finalMinStock = Number(minStock) || 0;
// If user didn't provide a specific minStock (or provided 0), try to inherit from existing group
if (finalMinStock === 0) {
const existingGroupItem = await StockItem.findOne({
organizationId,
dataSheetId,
color
}).sort({ updatedAt: -1 }); // Get latest active config
if (existingGroupItem && existingGroupItem.minStock > 0) {
finalMinStock = existingGroupItem.minStock;
}
} else {
// If user DID provide a minStock, update all existing items in that group to match?
// User requested: "a regra de estoque minimo definido no cadastro precisa estar clonado para novos cadastros"
// And "soma dessas 'mesmas' tintas sejam comparadas com o estoque minimo cadastrado a elas"
// This implies the rule is a Property of the Group. So create/update should enforce consistency.
if (finalMinStock > 0) {
await StockItem.updateMany(
{ organizationId, dataSheetId, color },
{ $set: { minStock: finalMinStock } }
);
}
}
const newItem = new StockItem({
organizationId,
createdBy: req.appUser?.externalId,
dataSheetId,
rrNumber,
batchNumber,
quantity: Number(quantity),
unit,
minStock: finalMinStock,
expirationDate,
notes,
color,
invoiceNumber,
receivedBy
});
const savedItem = await newItem.save();
// Create Initial Movement (ENTRY)
await StockMovement.create({
organizationId,
createdBy: req.appUser?.externalId,
stockItemId: savedItem._id,
movementNumber: 1,
type: 'ENTRY',
quantity: Number(quantity),
responsible: userName,
notes: 'Abertura de Lote / Entrada Inicial'
});
// Notificação de Recebimento
if (organizationId) {
await notificationService.create({
organizationId,
title: 'Recebimento de Material',
message: `Recebido: ${quantity}${unit} de ${savedItem.rrNumber} (Lote: ${batchNumber}).`,
type: 'info',
metadata: { stockItemId: savedItem._id, triggerType: 'stock_received' }
});
}
// Check Low Stock immediately
await notificationService.checkLowStock(savedItem._id.toString());
res.status(201).json(savedItem);
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: 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 });
} }
}; };
export const updateStockItem = async (req: AuthRequest, res: Response) => { export const updateStockItem = async (req: AuthRequest, res: Response) => {
try { try {
const updateData = toSnakeCase(req.body); const { id } = req.params;
const { data, error } = await supabase.from('stock_items').update(updateData).eq('id', req.params.id).select().single(); const organizationId = req.appUser?.organizationId;
if (error) throw error; // Only allow updating metadata, NOT quantity directly (quantity must be via adjustments)
res.json(toCamelCase(data)); // Adjusting logic: Admin might need to fix typo in quantity without movement record?
// Better enforcing movements. If quantity changes, user should use "Adjustment".
// Here we create a general update for details like Notes, Dates, etc.
const { quantity, ...otherData } = req.body; // Separate quantity
if (quantity !== undefined) {
return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' });
}
// Check if Min Stock is being updated
if (otherData.minStock !== undefined) {
const item = await StockItem.findOne({ _id: id, organizationId });
if (item) {
// Propagate to all siblings (same Product + Color)
await StockItem.updateMany(
{
organizationId,
dataSheetId: item.dataSheetId,
color: item.color
},
{ $set: { minStock: otherData.minStock } }
);
}
}
const updated = await StockItem.findOneAndUpdate(
{ _id: id, organizationId },
otherData,
{ new: true }
);
if (!updated) return res.status(404).json({ error: 'Item não encontrado.' });
// Check Low Stock (in case minStock changed)
await notificationService.checkLowStock(updated._id.toString());
res.json(updated);
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const deleteStockItem = async (req: AuthRequest, res: Response) => {
try {
await supabase.from('stock_items').delete().eq('id', req.params.id);
res.status(204).send();
} catch (error: unknown) {
res.status(204).send();
}
};
export const adjustStock = async (req: AuthRequest, res: Response) => { export const adjustStock = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const updateData = toSnakeCase(req.body); const organizationId = req.appUser?.organizationId;
const { data, error } = await supabase.from('stock_items').update(updateData).eq('id', id).select().single(); const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
if (error) throw error; const { quantityDelta, reason } = req.body; // quantityDelta: +10 or -5
res.json(toCamelCase(data));
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
if (!quantityDelta || isNaN(quantityDelta)) return res.status(400).json({ error: 'Quantidade inválida.' });
const item = await StockItem.findOne({ _id: id, organizationId });
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
// Calculate new quantity
const newQuantity = Number(item.quantity) + Number(quantityDelta);
if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' });
item.quantity = newQuantity;
await item.save();
// Calculate next movement number
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
const count = await StockMovement.countDocuments({ stockItemId: item._id });
const movementNumber = (lastMov?.movementNumber || count) + 1;
// Register Movement
await StockMovement.create({
organizationId,
createdBy: req.appUser?.externalId,
stockItemId: item._id,
movementNumber,
type: 'ADJUSTMENT',
quantity: Number(quantityDelta),
responsible: userName,
reason
});
// Check Low Stock
await notificationService.checkLowStock(item._id.toString());
res.json(item);
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const consumeStock = async (req: AuthRequest, res: Response) => { export const consumeStock = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { quantity } = req.body; const organizationId = req.appUser?.organizationId;
const { data: item, error: fetchError } = await supabase.from('stock_items').select('quantity').eq('id', id).single(); const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
if (fetchError) throw fetchError; const { quantityConsumed, requester, date } = req.body;
const newQuantity = (item.quantity || 0) - (quantity || 0); if (!requester) return res.status(400).json({ error: 'Solicitante é obrigatório.' });
const { data, error } = await supabase.from('stock_items').update({ quantity: newQuantity }).eq('id', id).select().single(); if (!quantityConsumed || Number(quantityConsumed) <= 0) return res.status(400).json({ error: 'Quantidade deve ser maior que zero.' });
if (error) throw error;
const item = await StockItem.findOne({ _id: id, organizationId });
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' });
item.quantity -= Number(quantityConsumed);
await item.save();
// Calculate next movement number
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
const count = await StockMovement.countDocuments({ stockItemId: item._id });
const movementNumber = (lastMov?.movementNumber || count) + 1;
// Register Movement (Negative quantity for consumption)
await StockMovement.create({
organizationId,
createdBy: req.appUser?.externalId,
stockItemId: item._id,
movementNumber,
type: 'CONSUMPTION',
quantity: -Number(quantityConsumed), // Negative
responsible: userName,
requester,
date: date || new Date()
});
// Check Low Stock
await notificationService.checkLowStock(item._id.toString());
res.json(item);
res.json(toCamelCase(data));
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updateStockMovement = async (req: AuthRequest, res: Response) => { export const deleteStockItem = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { data, error } = await supabase.from('stock_movements').update(req.body).eq('id', id).select().single(); const organizationId = req.appUser?.organizationId;
if (error) throw error;
res.json(toCamelCase(data)); // Optional: Block delete if there are movements other than ENTRY?
// For simplicity allow Admin to nuke it.
const deleted = await StockItem.findOneAndDelete({ _id: id, organizationId });
if (!deleted) return res.status(404).json({ error: 'Item não encontrado.' });
// Cleanup movements & logs
await StockMovement.deleteMany({ stockItemId: id });
await StockAuditLog.deleteMany({ stockItemId: id });
res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getStockItems = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const { dataSheetId } = req.query;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const query: any = { organizationId };
if (dataSheetId) query.dataSheetId = dataSheetId;
// Sort by Expiration Date ASC (First to expire first)
const items = await StockItem.find(query)
.populate('dataSheetId', 'name manufacturer type')
.sort({ expirationDate: 1 });
res.json(items);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getStockItemById = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const item = await StockItem.findOne({ _id: id, organizationId })
.populate('dataSheetId', 'name manufacturer type');
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
res.json(item);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getStockMovements = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params; // StockItem ID
const organizationId = req.appUser?.organizationId;
const movements = await StockMovement.find({ stockItemId: id, organizationId })
.sort({ date: -1 });
res.json(movements);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
// ------------------------------------------------------------------
// CRUD & Auditing for Movements
// ------------------------------------------------------------------
import StockAuditLog from '../models/StockAuditLog.js';
export const updateStockMovement = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params; // Movement ID
const organizationId = req.appUser?.organizationId;
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
const userId = req.appUser?.externalId || 'system';
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
if (!isAdmin) {
return res.status(403).json({ error: 'Apenas administradores podem editar movimentações.' });
}
const { date, quantity, notes } = req.body;
const movement = await StockMovement.findOne({ _id: id, organizationId });
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
// Calculate Delta
// If quantity changed, we need to adjust the item balance
// Note: 'quantity' in movement is signed (+ for entry, - for consumption)
// If the user edits a Consumption (-10) to (-15), the val passed in body might be absolute or signed?
// Let's assume the frontend sends the SIGNED value consistent with the movement type?
// Actually best to stick to specific logic:
// If movement type is ENTRY/ADJUSTMENT, quantity is usually positive (unless neg adjustment).
// If CONSUMPTION, quantity is stored negative.
// Let's expect the frontend to send the 'raw' new value.
// Be careful: if frontend sends positive 10 for a consumption, we must flip it?
// Let's assume frontend sends the value exactly as it should be stored.
// HOWEVER, it's safer if we check type.
const newQuantitySigned = Number(quantity);
// Validation: Consumption should generally be negative, Entry positive.
// But for flexibility let's just trust the arithmetic diff for now,
// but warn if sign flips unexpectedly?
const oldQuantity = Number(movement.quantity);
const quantityDiff = newQuantitySigned - oldQuantity;
// Update Item
const newStockLevel = Number(item.quantity) + quantityDiff;
if (newStockLevel < 0) {
return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
}
item.quantity = newStockLevel;
await item.save();
// Audit Log
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
const typeLabel = typeMap[movement.type] || movement.type;
await StockAuditLog.create({
organizationId,
stockItemId: item._id,
movementId: movement._id,
movementNumber: movement.movementNumber,
userId,
userName,
action: 'UPDATE',
details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`,
oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes },
newValues: { date, quantity: newQuantitySigned, notes }
});
// Update Movement
movement.quantity = newQuantitySigned;
if (date) movement.date = date;
if (notes !== undefined) movement.notes = notes;
await movement.save();
res.json(movement);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error updating movement:', error);
res.status(500).json({ error: message });
} }
}; };
export const deleteStockMovement = async (req: AuthRequest, res: Response) => { export const deleteStockMovement = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
await supabase.from('stock_movements').delete().eq('id', id); const organizationId = req.appUser?.organizationId;
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
const userId = req.appUser?.externalId || 'system';
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
if (!isAdmin) {
return res.status(403).json({ error: 'Apenas administradores podem excluir movimentações.' });
}
const movement = await StockMovement.findOne({ _id: id, organizationId });
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
// Reverse the effect
// If we delete an Entry (+10), we MUST subtract 10 from Item.
// If we delete a Consumption (-10), we MUST add 10 (subtract -10) to Item.
// So: Item.quantity -= movement.quantity
const reverseQty = Number(movement.quantity);
const newStockLevel = Number(item.quantity) - reverseQty;
if (newStockLevel < 0) {
return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
}
item.quantity = newStockLevel;
await item.save();
// Audit Log
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
const typeLabel = typeMap[movement.type] || movement.type;
await StockAuditLog.create({
organizationId,
stockItemId: item._id,
movementId: movement._id,
movementNumber: movement.movementNumber,
userId,
userName,
action: 'DELETE',
details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`,
oldValues: movement.toObject()
});
await StockMovement.deleteOne({ _id: id });
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: 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 });
}
};
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params; // StockItem ID
const organizationId = req.appUser?.organizationId;
const logs = await StockAuditLog.find({ stockItemId: id, organizationId })
.sort({ timestamp: -1 });
res.json(logs);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
@@ -1,32 +1,29 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.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';
const DEFAULT_SETTINGS = { export const getSettings = async (req: Request, res: Response) => {
try {
let settings = await SystemSettings.findOne({ settingsId: 'global' });
if (!settings) {
// Create default if not exists
settings = await SystemSettings.create({
settingsId: 'global', settingsId: 'global',
appName: 'GPI', appName: 'GPI',
appSubtitle: 'Gestão de Pintura Industrial' appSubtitle: 'Gestão de Pintura Industrial'
}; });
export const getSettings = async (req: Request, res: Response) => {
try {
const { data: settings, error } = await supabase
.from('system_settings')
.select('*')
.eq('settings_id', 'global')
.single();
if (error && error.code !== 'PGRST116') {
console.log('System settings table not found, returning defaults');
return res.json(DEFAULT_SETTINGS);
} }
res.json(settings || DEFAULT_SETTINGS); res.json(settings);
} catch (error) { } catch (error) {
console.error('Error fetching system settings:', error); console.error('Error fetching system settings:', error);
res.json(DEFAULT_SETTINGS); res.status(500).json({ error: 'Erro ao buscar configurações do sistema' });
} }
}; };
@@ -34,44 +31,16 @@ export const updateSettings = async (req: Request, res: Response) => {
try { try {
const { appName, appSubtitle, appLogoUrl } = req.body; const { appName, appSubtitle, appLogoUrl } = req.body;
const { data: existing, error: fetchError } = await supabase const settings = await SystemSettings.findOneAndUpdate(
.from('system_settings') { settingsId: 'global' },
.select('*') {
.eq('settings_id', 'global') appName,
.single(); appSubtitle,
appLogoUrl,
let settings; updatedBy: req.appUser?.email
if (fetchError || !existing) { },
const { data, error } = await supabase { new: true, upsert: true } // Create if not exists
.from('system_settings') );
.insert({
settings_id: 'global',
app_name: appName,
app_subtitle: appSubtitle,
app_logo_url: appLogoUrl,
updated_by: req.appUser?.email
})
.select()
.single();
if (error) throw error;
settings = data;
} else {
const { data, error } = await supabase
.from('system_settings')
.update({
app_name: appName,
app_subtitle: appSubtitle,
app_logo_url: appLogoUrl,
updated_by: req.appUser?.email
})
.eq('id', existing.id)
.select()
.single();
if (error) throw error;
settings = data;
}
console.log(`⚙️ System Settings updated by ${req.appUser?.email}`); console.log(`⚙️ System Settings updated by ${req.appUser?.email}`);
res.json(settings); res.json(settings);
@@ -81,10 +50,14 @@ export const updateSettings = async (req: Request, res: Response) => {
} }
}; };
export const serveLogo = async (req: Request, res: Response) => { export const serveLogo = async (req: Request, res: Response) => {
try { try {
const { filename } = req.params as { filename: string }; const { filename } = req.params as { filename: string };
// Check tmp dir first (Serverless/Netlify uploads)
const tmpPath = path.join(os.tmpdir(), 'uploads', filename); const tmpPath = path.join(os.tmpdir(), 'uploads', filename);
// Check local dir (Development)
const localPath = path.join(process.cwd(), 'uploads', filename); const localPath = path.join(process.cwd(), 'uploads', filename);
if (fs.existsSync(tmpPath)) { if (fs.existsSync(tmpPath)) {
@@ -107,7 +80,10 @@ export const uploadLogo = async (req: Request, res: Response) => {
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);
@@ -115,15 +91,11 @@ export const uploadLogo = async (req: Request, res: Response) => {
} }
}; };
// Global Admin Functions
export const getGlobalUsers = async (req: Request, res: Response) => { export const getGlobalUsers = async (req: Request, res: Response) => {
try { try {
const { data: users, error } = await supabase const users = await User.find({}).sort({ createdAt: -1 });
.from('users') res.json(users);
.select('*')
.order('created_at', { ascending: false });
if (error && error.code !== '42P01') throw error;
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.' });
@@ -132,32 +104,51 @@ export const getGlobalUsers = async (req: Request, res: Response) => {
export const getGlobalOrganizations = async (req: Request, res: Response) => { export const getGlobalOrganizations = async (req: Request, res: Response) => {
try { try {
const { data: organizations, error } = await supabase // Aggregate members to group by org and get full member lists
.from('organizations') const organizations = await OrganizationMember.aggregate([
.select('*'); {
$group: {
if (error && error.code !== '42P01') throw error; _id: '$organizationId',
members: {
if (!organizations || organizations.length === 0) { $push: {
return res.json([]); name: '$name',
email: '$email',
role: '$role',
userId: '$userId',
isBanned: '$isBanned'
} }
},
lastActive: { $max: '$updatedAt' }
}
},
{
$lookup: {
from: 'organizations', // Ensure this matches the collection name of Organization model
localField: '_id',
foreignField: 'externalId',
as: 'orgDetails'
}
},
{
$unwind: {
path: '$orgDetails',
preserveNullAndEmptyArrays: true
}
},
{
$project: {
_id: 1,
lastActive: 1,
members: 1,
memberCount: { $size: '$members' },
isBanned: { $ifNull: ['$orgDetails.isBanned', false] },
name: { $ifNull: ['$orgDetails.name', ''] }
}
},
{ $sort: { memberCount: -1 } }
]);
const orgsWithMembers = await Promise.all( res.json(organizations);
organizations.map(async (org) => {
const { data: members } = await supabase
.from('user_organizations')
.select('*')
.eq('organization_id', org.id);
return {
...org,
members: members || [],
memberCount: members?.length || 0
};
})
);
res.json(orgsWithMembers);
} 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.' });
@@ -172,14 +163,13 @@ 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 { data: org, error } = await supabase // Upsert the Organization record
.from('organizations') const org = await Organization.findOneAndUpdate(
.update({ is_banned: isBanned }) { externalId: organizationId },
.eq('id', organizationId) { isBanned: isBanned },
.select() { new: true, upsert: true }
.single(); );
if (error) throw error;
console.log(`Organization ${organizationId} ban status set to ${isBanned} by ${req.appUser?.email}`); console.log(`Organization ${organizationId} ban status set to ${isBanned} by ${req.appUser?.email}`);
res.json(org); res.json(org);
} catch (error) { } catch (error) {
+232 -107
View File
@@ -1,194 +1,319 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import User, { IUser } from '../models/User.js';
import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js';
// Define locally to avoid import cycle risks
interface IAppUser extends IUser {
organizationId?: string;
organizationRole?: OrgRole;
organizationBanned?: boolean;
}
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: any; appUser?: IAppUser;
} }
export const syncUser = async (req: AuthRequest, res: Response) => { /**
* Sync user from Auth to MongoDB
* Creates user if doesn't exist, updates if exists
* Also creates/updates OrganizationMember for the current organization
*/
export const syncUser = async (req: Request, res: Response) => {
console.log('--- syncUser called ---', req.body);
try { try {
if (req.appUser) { const { externalId, email, name, organizationId, clerkRole } = req.body;
return res.json(req.appUser);
if (!externalId || !email || !name) {
return res.status(400).json({ error: 'externalId, email e name são obrigatórios.' });
} }
const { email, name, logto_id } = req.body; // 1. Upsert the global User record
let user = await User.findOne({ externalId });
if (!email) { if (user) {
return res.status(400).json({ error: 'Email é obrigatório para sincronização.' }); user.email = email;
} user.name = name;
await user.save();
const { data: existingUser } = await supabase } else {
.from('users') user = await User.create({
.select('*') externalId,
.eq('email', email)
.single();
let user;
if (!existingUser) {
const { data, error } = await supabase
.from('users')
.insert({
email, email,
name: name || email.split('@')[0], name,
logto_id, role: 'guest', // Default global role
role: 'guest' isBanned: false
}) });
.select()
.single();
if (error) throw error;
user = data;
} else {
if (logto_id && !existingUser.logto_id) {
const { data } = await supabase
.from('users')
.update({ logto_id })
.eq('id', existingUser.id)
.select()
.single();
user = data;
} else {
user = existingUser;
} }
if (organizationId) {
// Map Auth role to our app role
let appRole: OrgRole = 'guest';
if (clerkRole === 'org:admin') {
appRole = 'admin';
} else if (clerkRole === 'org:member') {
appRole = 'user';
}
// Use findOneAndUpdate with upsert to handle race conditions atomically
// This avoids the need for try/catch on create and handles existing members too
const member = await OrganizationMember.findOneAndUpdate(
{ userId: externalId, organizationId },
{
$set: {
name,
email,
// Only update role if it's the first time (creation)
// Or we can optionally update it if needed.
// For now, let's NOT overwrite role on update to preserve local changes,
// UNLESS we want to force sync with Auth.
// Let's use $setOnInsert for fields we only want to set on creation.
},
$setOnInsert: {
role: appRole,
isBanned: false
}
},
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
// Return combined info
return res.json({
...user.toObject(),
organizationRole: member.role,
organizationBanned: member.isBanned
});
} }
res.json(user); res.json(user);
} catch (error: any) { } catch (error) {
console.error('Error syncing user:', error); console.error('Error syncing user:', error);
res.status(500).json({ error: 'Erro ao sincronizar usuário: ' + error.message }); // Retornar 200 mesmo com erro para não travar o frontend se for algo não crítico,
// mas aqui é crítico. Vamos logar melhor.
res.status(500).json({ error: 'Erro ao sincronizar usuário: ' + (error instanceof Error ? error.message : String(error)) });
} }
}; };
/**
* Get current user data with organization context
*/
export const getCurrentUser = async (req: AuthRequest, res: Response) => { export const getCurrentUser = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.appUser) { if (!req.appUser) {
return res.status(404).json({ error: 'Usuário não encontrado.' });
}
const organizationId = req.headers['x-organization-id'] as string;
if (organizationId) {
const member = await OrganizationMember.findOne({
userId: req.appUser.externalId,
organizationId
});
if (member) {
return res.json({ return res.json({
id: '00000000-0000-0000-0000-000000000000', ...req.appUser.toObject(),
email: 'guest@gpi.app', role: member.role,
name: 'Guest User', isBanned: member.isBanned,
role: 'user' organizationId
}); });
} }
}
res.json(req.appUser); res.json(req.appUser);
} catch (error: any) { } catch (error) {
console.error('Error getting current user:', error); console.error('Error getting current user:', error);
res.json(req.appUser || { id: 'guest-user', email: 'guest@gpi.app', role: 'user' }); 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 {
// Always return all users from users table for now const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase
.from('users')
.select('*');
if (error) { console.log('getAllUsers called with organizationId:', organizationId);
console.log('Error fetching users:', error.message);
return res.json([]); if (!organizationId) {
return res.status(400).json({ error: 'Organização não selecionada.' });
} }
return res.json(data || []); const members = await OrganizationMember.find({ organizationId }).sort({ createdAt: -1 });
} catch (error: any) { console.log(`Found ${members.length} members for org ${organizationId}:`, members.map(m => ({ name: m.name, email: m.email, externalId: m.userId })));
res.json(members);
} catch (error) {
console.error('Error getting users:', error); console.error('Error getting users:', error);
res.json([]); res.status(500).json({ error: 'Erro ao buscar usuários.' });
} }
}; };
/**
* Update user role within organization (admin only)
*/
export const updateUserRole = async (req: AuthRequest, res: Response) => { export const updateUserRole = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { role } = req.body; const { role } = req.body;
const organizationId = req.headers['x-organization-id'] as string;
if (!['guest', 'user', 'admin'].includes(role)) { if (!organizationId) {
return res.status(400).json({ error: 'Role inválido.' }); return res.status(400).json({ error: 'Organização não selecionada.' });
} }
const { data, error } = await supabase if (!['guest', 'user', 'admin'].includes(role)) {
.from('user_organizations') return res.status(400).json({ error: 'Role inválido. Use: guest, user ou admin.' });
.update({ role }) }
.eq('id', id)
.select()
.single();
if (error && error.code !== '42P01') throw error; const member = await OrganizationMember.findById(id);
res.json(data || { message: 'Role atualizado' }); if (!member || member.organizationId !== organizationId) {
} catch (error: any) { return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
console.error('Error updating role:', error); }
res.json({ message: 'Role atualizado' });
// Prevent removing the last admin
if (member.role === 'admin' && role !== 'admin') {
const adminCount = await OrganizationMember.countDocuments({ organizationId, role: 'admin' });
if (adminCount <= 1) {
return res.status(400).json({ error: 'Não é possível remover o último administrador.' });
}
}
member.role = role as OrgRole;
await member.save();
res.json(member);
} catch (error) {
console.error('Error toggling ban:', error);
res.status(500).json({ error: 'Erro ao alterar status de banimento.' });
} }
}; };
/**
* Ban or unban user within organization (admin only)
*/
export const toggleBanUser = async (req: AuthRequest, res: Response) => { export const toggleBanUser = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { isBanned } = req.body; const { isBanned } = req.body;
const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase if (!organizationId) {
.from('user_organizations') return res.status(400).json({ error: 'Organização não selecionada.' });
.update({ is_banned: isBanned }) }
.eq('id', id)
.select()
.single();
if (error && error.code !== '42P01') throw error; const member = await OrganizationMember.findById(id);
res.json(data || { message: 'Ban atualizado' }); if (!member || member.organizationId !== organizationId) {
} catch (error: any) { return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
}
// Prevent banning yourself
if (req.appUser && member.userId === req.appUser.externalId) {
return res.status(400).json({ error: 'Você não pode banir a si mesmo.' });
}
// Prevent banning another admin
if (member.role === 'admin') {
return res.status(400).json({ error: 'Não é possível banir um administrador.' });
}
member.isBanned = isBanned;
await member.save();
res.json(member);
} catch (error) {
console.error('Error toggling ban:', error); console.error('Error toggling ban:', error);
res.json({ message: 'Ban atualizado' }); res.status(500).json({ error: 'Erro ao alterar status de banimento.' });
} }
}; };
/**
* Update current user's lastSeenAt timestamp
*/
export const heartbeat = async (req: AuthRequest, res: Response) => { export const heartbeat = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.appUser) { if (!req.appUser) {
return res.status(200).send(); return res.status(401).json({ error: 'Não autenticado.' });
} }
try { // Update User model
await supabase await User.findByIdAndUpdate(req.appUser._id, { lastSeenAt: new Date() });
.from('users')
.update({ last_seen_at: new Date().toISOString() }) // Also update Organization Member for tighter query
.eq('id', req.appUser.id); // But for now User model is enough if we join correctly, or just use User model for presence.
} catch (e) { /* ignore */ } // Actually, since we want to show users per organization, we should filter by Org.
// Our 'User.ts' has organizationId, but it might be just the 'default' one.
// Let's rely on OrganizationMember for the list, but we need to update lastSeenAt there too?
// Strategy: Update User (global), and when querying active users, join or filter.
// Better: Update OrganizationMember too if we want org-specific presence?
// Simpler: Just update User. When fetching active users, we fetch OrganizationMembers and populate User details, filtering by User.lastSeenAt.
res.status(200).send(); res.status(200).send();
} catch (error) { } catch (error) {
// Silent fail for heartbeat
console.error('Heartbeat error:', error); console.error('Heartbeat error:', error);
res.status(200).send(); res.status(500).send();
} }
}; };
/**
* Get active users in the same organization (seen in last 2 mins)
*/
export const getActiveUsers = async (req: AuthRequest, res: Response) => { export const getActiveUsers = async (req: AuthRequest, res: Response) => {
try { try {
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString(); const organizationId = req.headers['x-organization-id'] as string;
const currentUserId = req.appUser?._id;
const { data, error } = await supabase if (!organizationId) {
.from('users') return res.status(400).json([]);
.select('id, email, name, last_seen_at') }
.gte('last_seen_at', twoMinutesAgo);
if (error && error.code !== '42P01') throw error; // Find members of this org
res.json(data || []); const members = await OrganizationMember.find({ organizationId });
} catch (error: any) {
// Get their Auth IDs
const externalIds = members.map(m => m.userId);
// Find Users who were seen recently (2 minutes)
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
const activeUsers = await User.find({
externalId: { $in: externalIds },
lastSeenAt: { $gte: twoMinutesAgo },
_id: { $ne: currentUserId } // Optional: exclude self
}).select('name email lastSeenAt externalId'); // Only needed fields
res.json(activeUsers);
} catch (error) {
console.error('Error getting active users:', error); console.error('Error getting active users:', error);
res.json([]); res.status(500).json([]);
} }
}; };
// 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;
const organizationId = req.headers['x-organization-id'] as string;
const { error } = await supabase if (!organizationId) {
.from('user_organizations') return res.status(400).json({ error: 'Organização não selecionada.' });
.delete() }
.eq('id', id);
if (error && error.code !== '42P01') throw error; console.log(`Deleting member ${id} from organization ${organizationId}`);
res.json({ message: 'Membro removido com sucesso.' });
} catch (error: any) { // Delete from OrganizationMember collection
const result = await OrganizationMember.findByIdAndDelete(id);
if (!result) {
return res.status(404).json({ error: 'Membro não encontrado.' });
}
console.log(`Member ${result.name} deleted successfully`);
res.json({ message: 'Membro removido com sucesso.', deletedMember: result });
} catch (error) {
console.error('Error deleting user:', error); console.error('Error deleting user:', error);
res.json({ message: 'Membro removido com sucesso.' }); res.status(500).json({ error: 'Erro ao remover membro.' });
} }
}; };
+31 -26
View File
@@ -1,52 +1,57 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import * as yieldStudyService from '../services/yieldStudyService.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js';
export const getAllStudies = async (req: Request, res: Response) => { export const getAllStudies = async (req: Request, res: Response) => {
try { try {
const { data, error } = await supabase.from('yield_studies').select('*'); const organizationId = req.appUser?.organizationId;
if (error && error.code !== '42P01') throw error; const studies = await yieldStudyService.getAllStudies(organizationId);
res.json(toCamelCase(data || [])); res.json(studies);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const createStudy = async (req: Request, res: Response) => { export const createStudy = async (req: Request, res: Response) => {
try { try {
const payload = { ...req.body, organization_id: req.appUser?.organizationId }; const organizationId = req.appUser?.organizationId;
const { data, error } = await supabase const study = await yieldStudyService.createStudy({ ...req.body, organizationId });
.from('yield_studies') res.status(201).json(study);
.insert(toSnakeCase(payload))
.select()
.single();
if (error) throw error;
res.status(201).json(toCamelCase(data));
} catch (error: unknown) { } catch (error: unknown) {
res.status(400).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updateStudy = async (req: Request, res: Response) => { export const updateStudy = async (req: Request, res: Response) => {
try { try {
const { data, error } = await supabase const id = req.params.id as string;
.from('yield_studies') const organizationId = req.appUser?.organizationId;
.update(toSnakeCase(req.body)) const study = await yieldStudyService.updateStudy(id, req.body, organizationId);
.eq('id', req.params.id) if (study) {
.select() res.json(study);
.single(); } else {
if (error) throw error; res.status(404).json({ error: 'Study not found' });
res.json(toCamelCase(data)); }
} catch (error: unknown) { } catch (error: unknown) {
res.status(400).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const deleteStudy = async (req: Request, res: Response) => { export const deleteStudy = async (req: Request, res: Response) => {
try { try {
await supabase.from('yield_studies').delete().eq('id', req.params.id); const id = req.params.id as string;
const organizationId = req.appUser?.organizationId;
const success = await yieldStudyService.deleteStudy(id, organizationId);
if (success) {
res.status(204).send(); res.status(204).send();
} else {
res.status(404).json({ error: 'Study not found' });
}
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
+24 -19
View File
@@ -1,36 +1,41 @@
import dotenv from 'dotenv';
dotenv.config();
import app from './app.js'; import app from './app.js';
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();
const startServer = async () => { const startServer = async () => {
try { try {
console.log('🔄 Connecting to database...');
await connectDB(); await connectDB();
const PORT = parseInt(process.env.PORT || '3000', 10); const PORT = process.env.PORT || 3000;
app.listen(Number(PORT), '0.0.0.0', async () => {
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));
const server = app.listen(PORT, '0.0.0.0', () => { // Agendar verificação de vencimento de estoque (a cada 24 horas)
console.log(`🚀 Server running on port ${PORT}`); console.log('📅 Scheduling stock expiration check...');
console.log('✅ Conectado ao Supabase (GPI schema)'); setInterval(() => {
notificationService.checkStockExpirations();
}, 24 * 60 * 60 * 1000);
// Executar uma vez no início para garantir (opcional, bom para dev)
notificationService.checkStockExpirations();
} else {
console.warn('⚠️ MongoDB is not connected. Skipping migrations.');
}
}); });
server.timeout = 60000;
} catch (error) { } catch (error) {
console.error('Failed to start server:', error); console.error('Failed to start server:', error);
process.exit(1);
} }
}; };
startServer(); startServer();
process.on('uncaughtException', (err) => { // Force keep-alive to debug why it exits
console.error('Uncaught Exception:', err); setInterval(() => { }, 1000);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
});
-159
View File
@@ -1,159 +0,0 @@
import { supabase, findOneGpi, queryGpi, insertGpi, updateGpi, deleteGpi } from '../config/supabase.js';
/**
* Mongoose Compatibility Layer for Supabase (v2025)
* Translates Mongoose-style calls to Supabase PostgREST queries.
* Automatically handles camelCase (JS) to snake_case (DB) mapping for core fields.
*/
function createModel(tableName: string) {
const mapToDb = (data: any) => {
const mapped: any = { ...data };
if (mapped.organizationId) { mapped.organization_id = mapped.organizationId; delete mapped.organizationId; }
if (mapped.projectId) { mapped.project_id = mapped.projectId; delete mapped.projectId; }
if (mapped.createdBy) { mapped.created_by = mapped.createdBy; delete mapped.createdBy; }
if (mapped.createdAt) { mapped.created_at = mapped.createdAt; delete mapped.createdAt; }
if (mapped.updatedAt) { mapped.updated_at = mapped.updatedAt; delete mapped.updatedAt; }
return mapped;
};
const mapFromDb = (data: any) => {
if (!data) return data;
if (Array.isArray(data)) return data.map(mapFromDb);
const mapped: any = { ...data, id: data.id || data._id };
if (mapped.organization_id) mapped.organizationId = mapped.organization_id;
if (mapped.project_id) mapped.projectId = mapped.project_id;
if (mapped.created_by) mapped.createdBy = mapped.created_by;
return mapped;
};
return {
find: function(query: any = {}) {
const dbQuery = mapToDb(query);
const promise = (async () => {
const { data, error } = await queryGpi(tableName, { filter: dbQuery });
if (error) throw error;
return mapFromDb(data || []);
})();
// Mock methods for chainability
(promise as any).sort = () => promise;
(promise as any).populate = () => promise;
(promise as any).lean = () => promise;
(promise as any).limit = () => promise;
(promise as any).select = () => promise;
return promise;
},
findOne: async (query: any) => {
const data = await findOneGpi(tableName, mapToDb(query));
return mapFromDb(data);
},
findById: async (id: string) => {
const data = await findOneGpi(tableName, { id });
return mapFromDb(data);
},
create: async (data: any) => {
const dbData = mapToDb(data);
const result = await insertGpi(tableName, dbData);
return mapFromDb(result.data?.[0] || result.data);
},
insertMany: async (dataArray: any[]) => {
const dbDataArray = dataArray.map(mapToDb);
const { data, error } = await supabase.from(tableName).insert(dbDataArray).select();
if (error) throw error;
return mapFromDb(data);
},
deleteMany: async (query: any) => {
const dbQuery = mapToDb(query);
let q = supabase.from(tableName).delete();
Object.keys(dbQuery).forEach(key => {
q = q.eq(key, dbQuery[key]);
});
const { error } = await q;
if (error) throw error;
return { deletedCount: 0 }; // Supabase doesn't return count easily here
},
countDocuments: async (query: any = {}) => {
const dbQuery = mapToDb(query);
let q = supabase.from(tableName).select('*', { count: 'exact', head: true });
Object.keys(dbQuery).forEach(key => {
q = q.eq(key, dbQuery[key]);
});
const { count, error } = await q;
if (error) throw error;
return count || 0;
},
findOneAndUpdate: async (query: any, update: any) => {
const existing = await findOneGpi(tableName, mapToDb(query));
if (!existing) return null;
const result = await updateGpi(tableName, existing.id, mapToDb(update));
return mapFromDb(result.data?.[0]);
},
findByIdAndUpdate: async (id: string, update: any) => {
const result = await updateGpi(tableName, id, mapToDb(update));
return mapFromDb(result.data?.[0]);
},
findByIdAndDelete: async (id: string) => {
await deleteGpi(tableName, id);
return { id };
},
findOneAndDelete: async (query: any) => {
const dbQuery = mapToDb(query);
const existing = await findOneGpi(tableName, dbQuery);
if (!existing) return null;
await deleteGpi(tableName, existing.id);
return mapFromDb(existing);
},
updateMany: async (query: any, update: any) => {
const dbQuery = mapToDb(query);
const dbUpdate = mapToDb(update.$set || update);
let q = supabase.from(tableName).update(dbUpdate);
Object.keys(dbQuery).forEach(key => {
q = q.eq(key, dbQuery[key]);
});
const { error } = await q;
if (error) throw error;
return { acknowledged: true, modifiedCount: 0 };
},
deleteOne: async (query: any) => {
const existing = await findOneGpi(tableName, mapToDb(query));
if (!existing) return null;
await deleteGpi(tableName, existing.id);
return existing;
},
aggregate: (pipeline: any[]) => ({ toArray: async () => [] }),
// For "new Model()" usage
new: function(data: any) {
const instance = { ...data };
(instance as any).save = async () => {
return await insertGpi(tableName, mapToDb(instance));
};
(instance as any).toObject = () => instance;
return instance;
}
};
}
export const Project = createModel('projects');
export const Part = createModel('parts');
export const PaintingScheme = createModel('painting_schemes');
export const ApplicationRecord = createModel('application_records');
export const Inspection = createModel('inspections');
export const User = createModel('users');
export const Organization = createModel('organizations');
export const OrganizationMember = createModel('user_organizations');
export const StockItem = createModel('stock_items');
export const StockMovement = createModel('stock_movements');
export const StockAuditLog = createModel('stock_audit_logs');
export const Instrument = createModel('instruments');
export const TechnicalDataSheet = createModel('technical_data_sheets');
export const SystemSettings = createModel('system_settings');
export const Notification = createModel('notifications');
export const Message = createModel('messages');
export const GeometryType = createModel('geometry_types');
export const YieldStudy = createModel('yield_studies');
export const StoredFile = createModel('stored_files');
export { queryGpi, findOneGpi, insertGpi, updateGpi, deleteGpi };
console.log('✅ Mongoose Compatibility Layer load complete (Extended Mode)');
-71
View File
@@ -1,71 +0,0 @@
import { supabase, findOneGpi, queryGpi, insertGpi, updateGpi, deleteGpi } from '../config/supabase.js';
export { supabase, findOneGpi, queryGpi, insertGpi, updateGpi, deleteGpi };
export async function getModel(tableName: string) {
return {
find: async (query: any = {}) => {
const { data, error } = await queryGpi(tableName, query);
if (error) throw error;
return data || [];
},
findOne: async (query: any) => {
return await findOneGpi(tableName, query);
},
findById: async (id: string) => {
return await findOneGpi(tableName, { id });
},
create: async (data: any) => {
const result = await insertGpi(tableName, data);
return result.data?.[0];
},
findOneAndUpdate: async (query: any, data: any) => {
const existing = await findOneGpi(tableName, query);
if (!existing) return null;
const result = await updateGpi(tableName, existing.id, data);
return result.data?.[0];
},
findByIdAndUpdate: async (id: string, data: any) => {
const result = await updateGpi(tableName, id, data);
return result.data?.[0];
},
findOneAndDelete: async (query: any) => {
const existing = await findOneGpi(tableName, query);
if (!existing) return null;
await deleteGpi(tableName, existing.id);
return existing;
},
countDocuments: async (query: any = {}) => {
const { data, error } = await supabase.from(tableName).select('*', { count: 'exact', head: true });
if (error) throw error;
return data?.length || 0;
}
};
}
export function getModelById(tableName: string, idField: string = 'id') {
return {
find: async (query: any = {}) => {
const { data, error } = await queryGpi(tableName, { filter: query });
if (error) throw error;
return data || [];
},
findOne: async (query: any) => {
return await findOneGpi(tableName, query);
},
create: async (data: any) => {
const result = await insertGpi(tableName, data);
return result.data?.[0];
},
findByIdAndUpdate: async (id: string, data: any) => {
const result = await updateGpi(tableName, id, data);
return result.data?.[0];
},
findByIdAndDelete: async (id: string) => {
await deleteGpi(tableName, id);
return { id };
}
};
}
console.log('✅ DB Compatibility Layer initialized');
+26
View File
@@ -0,0 +1,26 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod';
export const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
// Se não houver token autêntico JWT, prossegue limpo
return next();
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET) as any;
// Injeta o externalId no header para que o extractUser (roleMiddleware)
// continue seu trabalho de carregar o usuário do banco instanciado e popular req.appUser
req.headers['x-auth-user-id'] = decoded.externalId;
next();
} catch (error) {
console.error('Auth Middleware Error:', error);
res.status(401).json({ error: 'Token inválido ou expirado' });
}
};
-48
View File
@@ -1,48 +0,0 @@
import { Request, Response, NextFunction } from 'express';
export interface IAppUser {
id: string;
logtoId: string;
email: string;
name: string;
role: string;
organizationId?: string;
organizationRole?: string;
}
declare module 'express-serve-static-core' {
interface Request {
appUser?: IAppUser;
}
}
export const extractUser = async (req: Request, res: Response, next: NextFunction) => {
req.appUser = {
id: '00000000-0000-0000-0000-000000000000',
logtoId: 'guest',
email: 'guest@gpi.app',
name: 'Guest User',
role: 'user',
organizationId: req.headers['x-organization-id'] as string || 'e47e6210-4879-4e5b-bf21-9285d2713123',
organizationRole: 'user'
};
next();
};
export const requireRole = (allowedRoles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
// No authentication required - allow all requests
next();
};
};
export const requireAdmin = requireRole(['admin']);
export const requireUser = requireRole(['user', 'admin']);
export const canEdit = (req: Request, res: Response, next: NextFunction) => {
next();
};
export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => {
next();
};
-116
View File
@@ -1,116 +0,0 @@
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { supabase, findOneGpi } from '../config/supabase.js';
import { IAppUser } from './authMiddleware.js';
const LOGTO_URL = process.env.LOGTO_URL || 'https://logto-admin-bzlued1boxl3t8ewsyn99an9.187.77.227.172.sslip.io';
const APP_ID = process.env.LOGTO_APP_ID || 'gpi-app-final';
const jwks = createRemoteJWKSet(new URL(`${LOGTO_URL}/oidc/jwks`));
export type AppUser = IAppUser;
export async function authenticateRequest(req: any): Promise<IAppUser | null> {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.substring(7);
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: `${LOGTO_URL}/oidc`,
audience: APP_ID
});
const logtoId = payload.sub as string;
// Primeiro tenta pelo Logto ID
let user = await findOneGpi('users', { logto_id: logtoId });
// Se não encontrar, tenta pelo email (se houver no payload do token)
if (!user && payload.email) {
const email = payload.email as string;
user = await findOneGpi('users', { email });
if (user) {
// Vincula o Logto ID ao usuário existente
await supabase
.from('users')
.update({ logto_id: logtoId })
.eq('id', user.id);
user.logto_id = logtoId;
console.log(`[Auth] Usuário ${email} vinculado ao Logto ID ${logtoId}`);
}
}
// Auto-registro se não encontrar
if (!user) {
console.log(`[Auth] Usuário Logto ${logtoId} sem registro no GPI. Criando...`);
const email = (payload.email as string) || '';
const name = (payload.name as string) || (payload.username as string) || email.split('@')[0];
const { data: newUser, error: createError } = await supabase
.from('users')
.insert({
email,
name,
logto_id: logtoId,
role: 'user'
})
.select()
.single();
if (createError) {
console.error('[Auth] Erro ao auto-registrar usuário:', createError);
return null;
}
user = newUser;
console.log(`[Auth] Novo usuário auto-registrado: ${email}`);
}
return {
id: user.id || user._id,
logtoId: user.logto_id,
email: user.email,
name: user.name,
role: user.role
};
} catch (error) {
console.error('[Auth] Erro ao verificar token:', error);
return null;
}
}
export function requireAuth() {
return async (req: any, res: any, next: any) => {
const user = await authenticateRequest(req);
if (!user) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.appUser = user;
next();
};
}
export function requireRole(roles: string[]) {
return async (req: any, res: any, next: any) => {
const user = await authenticateRequest(req);
if (!user) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!roles.includes(user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
req.appUser = user;
next();
};
}
+174
View File
@@ -0,0 +1,174 @@
import { Request, Response, NextFunction } from 'express';
import User, { IUser } from '../models/User.js';
import OrganizationMember, { OrgRole } from '../models/OrganizationMember.js';
import Organization from '../models/Organization.js';
// Extended user info with organization context
export interface IAppUser extends IUser {
organizationId?: string;
organizationRole?: OrgRole;
organizationBanned?: boolean;
}
// Module augmentation for Express Request
declare module 'express-serve-static-core' {
interface Request {
appUser?: IAppUser;
}
}
/**
* Middleware to extract and verify user from Auth ID header
* Also loads organization-specific role if organization context is provided
*/
export const extractUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const externalId = req.headers['x-auth-user-id'] as string;
const organizationId = req.headers['x-organization-id'] as string;
if (!externalId) {
return next(); // No user, continue without
}
const user = await User.findOne({ externalId });
if (user) {
if (user.isBanned) {
return res.status(403).json({ error: 'Conta bloqueada. Entre em contato com o administrador.' });
}
// Create extended user object
const appUser: IAppUser = user.toObject() as IAppUser;
appUser.organizationId = organizationId || user.organizationId;
// If organization context, get org-specific role
if (organizationId) {
// Check if Organization is globally banned (subscription specific, etc.)
const orgStatus = await Organization.findOne({ externalId: organizationId });
const orgName = req.headers['x-organization-name'] ? decodeURIComponent(req.headers['x-organization-name'] as string) : undefined;
if (orgStatus) {
// Update name if different and present
if (orgName && orgStatus.name !== orgName) {
try {
await Organization.updateOne(
{ externalId: organizationId },
{ name: orgName }
);
} catch (err) {
console.warn('Failed to update organization name', err);
}
}
if (orgStatus.isBanned) {
return res.status(403).json({
error: 'Acesso bloqueado: Esta organização está suspensa. Entre em contato com o suporte.'
});
}
} else {
// Create new org with name if present
try {
await Organization.create({
externalId: organizationId,
name: orgName
});
} catch (_e) {
console.warn('Organization auto-create race condition', _e);
}
}
const member = await OrganizationMember.findOne({ userId: externalId, organizationId });
if (member) {
if (member.isBanned) {
return res.status(403).json({ error: 'Acesso bloqueado nesta organização.' });
}
appUser.organizationRole = member.role;
appUser.role = member.role; // Override global role with org role
} else {
// User exists but is not a member of this org yet
appUser.organizationRole = 'guest';
appUser.role = 'guest';
}
}
req.appUser = appUser;
// console.log(`✅ Request authenticated as: ${appUser.name} (${appUser.role})`);
} else {
console.warn(`⚠️ User with Auth ID ${externalId} not found in MongoDB. Sync required.`);
}
next();
} catch (error) {
console.error('Error extracting user:', error);
next();
}
};
/**
* Middleware to require specific roles for a route
* @param allowedRoles Array of roles that can access the route
*/
export const requireRole = (allowedRoles: OrgRole[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.appUser) {
return res.status(401).json({ error: 'Autenticação necessária.' });
}
// DEV Bypass: Developer has full power
if (req.appUser.email === 'admtracksteel@gmail.com') {
return next();
}
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
if (!allowedRoles.includes(effectiveRole as OrgRole)) {
return res.status(403).json({ error: 'Acesso negado. Permissões insuficientes.' });
}
next();
};
};
/**
* Middleware to require admin role
*/
export const requireAdmin = requireRole(['admin']);
/**
* Middleware to require at least user role (user or 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) => {
if (!req.appUser) {
return res.status(401).json({ error: 'Autenticação necessária.' });
}
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
if (effectiveRole === 'guest') {
return res.status(403).json({ error: 'Convidados não podem editar. Solicite acesso ao administrador.' });
}
next();
};
/**
* Middleware to require Developer (Super Admin) access
* Hardcoded to specific email for security
*/
export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => {
if (!req.appUser) {
return res.status(401).json({ error: 'Autenticação necessária.' });
}
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.' });
}
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; // Auth User ID
projectId: mongoose.Types.ObjectId;
type: 'painting' | 'surface_treatment';
// Common
date?: Date | null;
inspector?: string | null;
appearance?: 'approved' | 'rejected' | 'notes' | null; // Unified status
defects?: string | null; // Observations
photos?: string[]; // URLs
partTemperature?: number | null;
weightKg?: number | null;
// Painting Specific
pieceDescription?: string | null;
epsPoints?: (number | null)[];
adhesionTest?: string | null;
// Surface Treatment Specific
batch?: string | null; // Lote
treatmentExecutor?: string | null;
treatmentType?: string | null; // Jateamento, Mecânica...
cleaningDegree?: string | null; // Sa 2.5, St 3...
roughnessReadings?: (number | null)[]; // 5 measurements
flashRust?: string | null;
temperature?: number | null;
relativeHumidity?: number | null;
period?: 'morning' | 'afternoon' | 'night' | null;
applicationRecordId?: mongoose.Types.ObjectId; // Link to specific painting batch
stockItemId?: mongoose.Types.ObjectId; // Link to Stock Item (Paint used)
instrumentId?: mongoose.Types.ObjectId; // Link to Instrument used
}
const InspectionSchema: Schema = new Schema({
organizationId: { type: String, index: true },
createdBy: { type: String, index: true },
projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true },
applicationRecordId: { type: Schema.Types.ObjectId, ref: 'ApplicationRecord' },
stockItemId: { type: Schema.Types.ObjectId, ref: 'StockItem' },
instrumentId: { type: Schema.Types.ObjectId, ref: 'Instrument' },
type: { type: String, enum: ['painting', 'surface_treatment'], default: 'painting', index: true },
// Common
date: { type: Date },
inspector: { type: String },
appearance: { type: String }, // approved, rejected, notes
defects: { type: String },
photos: [{ type: String }],
partTemperature: { type: Number },
weightKg: { type: Number },
// Painting
pieceDescription: { type: String },
epsPoints: [{ type: Number }],
adhesionTest: { type: String },
// Surface Treatment
batch: { type: String },
treatmentExecutor: { type: String },
treatmentType: { type: String },
cleaningDegree: { type: String },
roughnessReadings: [{ type: Number }],
flashRust: { type: String },
temperature: { type: Number },
relativeHumidity: { type: Number },
period: { type: String },
}, { timestamps: true });
export default mongoose.models.Inspection || mongoose.model<IInspection>('Inspection', InspectionSchema);
+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; // externalId do remetente
toUserId: string; // externalId do destinatário
message: string;
isRead: boolean;
readAt?: Date;
isArchived: boolean;
isDeletedByRecipient: boolean;
createdAt: Date;
updatedAt: Date;
}
const MessageSchema: Schema = new Schema(
{
organizationId: {
type: String,
required: true,
index: true,
},
fromUserId: {
type: String,
required: true,
index: true,
},
toUserId: {
type: String,
required: true,
index: true,
},
message: {
type: String,
required: true,
maxlength: 255,
},
isRead: {
type: Boolean,
default: false,
},
readAt: {
type: Date,
},
isArchived: {
type: Boolean,
default: false,
},
isDeletedByRecipient: {
type: Boolean,
default: false,
}
},
{
timestamps: true,
}
);
// Compound index for efficient queries
MessageSchema.index({ toUserId: 1, isRead: 1 });
MessageSchema.index({ fromUserId: 1, toUserId: 1 });
export default mongoose.model<IMessage>('Message', MessageSchema);
+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 {
externalId: string;
name?: string;
isBanned: boolean;
createdAt: Date;
updatedAt: Date;
}
const OrganizationSchema: Schema = new Schema({
externalId: { type: String, required: true, unique: true, index: true },
name: { type: String },
isBanned: { type: Boolean, default: false },
}, { timestamps: true });
export default mongoose.models.Organization || mongoose.model<IOrganization>('Organization', OrganizationSchema);
+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);
+58
View File
@@ -0,0 +1,58 @@
import mongoose, { Schema, Document } from 'mongoose';
export type UserRole = 'guest' | 'user' | 'admin';
export interface IUser extends Document {
externalId: string;
email: string;
name: string;
role: UserRole;
isBanned: boolean;
passwordHash?: string;
organizationId?: string;
createdAt: Date;
updatedAt: Date;
lastSeenAt?: Date;
}
const UserSchema: Schema = new Schema({
externalId: {
type: String,
required: true,
unique: true,
index: true
},
passwordHash: {
type: String,
required: false
},
organizationId: {
type: String,
index: true
},
email: {
type: String,
required: true
},
name: {
type: String,
required: true
},
role: {
type: String,
enum: ['guest', 'user', 'admin'],
default: 'guest'
},
isBanned: {
type: Boolean,
default: false
},
lastSeenAt: {
type: Date,
default: Date.now
}
}, {
timestamps: true
});
export default mongoose.models.User || mongoose.model<IUser>('User', UserSchema);
+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);
+1 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import * as appRecordController from '../controllers/applicationRecordController.js'; import * as appRecordController from '../controllers/applicationRecordController.js';
import { extractUser, requireUser } from '../middleware/authMiddleware.js'; import { extractUser, requireUser } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
+10
View File
@@ -0,0 +1,10 @@
import express from 'express';
import { login, register, getMe } from '../controllers/authController.js';
const router = express.Router();
router.post('/login', login);
router.post('/register', register);
router.get('/me', getMe);
export default router;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { backupService } from '../services/backupService.js'; import { backupService } from '../services/backupService.js';
import { requireRole } from '../middleware/authMiddleware.js'; import { requireRole } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
+27 -7
View File
@@ -3,22 +3,42 @@ import multer from 'multer';
import path from 'path'; import path from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as dataSheetController from '../controllers/dataSheetController.js'; import * as dataSheetController from '../controllers/dataSheetController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
import os from 'os'; import os from 'os';
const router = Router(); const router = Router();
// Configure Multer
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, os.tmpdir()), destination: (req, file, cb) => {
filename: (req, file, cb) => cb(null, `${Date.now()}-${uuidv4()}${path.extname(file.originalname)}`) cb(null, os.tmpdir());
},
filename: (req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${uuidv4()}`;
cb(null, `${uniqueSuffix}${path.extname(file.originalname)}`);
}
}); });
const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } }); const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf' || file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only PDF and image files are allowed'));
}
}
});
// Public routes (read-only)
router.get('/', dataSheetController.getAllDataSheets); router.get('/', dataSheetController.getAllDataSheets);
router.get('/file/:id', dataSheetController.getFile); router.get('/file/:id', dataSheetController.getFile);
router.post('/', upload.single('file'), dataSheetController.createDataSheet);
router.post('/extract', upload.single('file'), dataSheetController.extractData); // Protected routes (require edit permission)
router.put('/:id', upload.single('file'), dataSheetController.updateDataSheet); router.post('/', extractUser, requireAdmin, upload.single('file'), dataSheetController.createDataSheet);
router.delete('/:id', dataSheetController.deleteDataSheet); router.post('/extract', extractUser, requireAdmin, upload.single('file'), dataSheetController.extractData);
router.put('/:id', extractUser, requireAdmin, upload.single('file'), dataSheetController.updateDataSheet);
router.delete('/:id', extractUser, requireAdmin, dataSheetController.deleteDataSheet);
export default router; export default router;
+9 -5
View File
@@ -1,12 +1,16 @@
import { Router } from 'express'; import { Router } from 'express';
import * as geometryTypeController from '../controllers/geometryTypeController.js'; import * as geometryTypeController from '../controllers/geometryTypeController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
router.get('/', geometryTypeController.getAllnames); // Retrieve all types (public read for authenticated users, auto-seeds)
router.post('/restore', geometryTypeController.restoreDefaults); router.get('/', extractUser, geometryTypeController.getAllnames);
router.post('/', geometryTypeController.createType);
router.put('/:id', geometryTypeController.updateType); // Protected Routes (Edit Only)
router.delete('/:id', geometryTypeController.deleteType); router.post('/restore', extractUser, requireAdmin, geometryTypeController.restoreDefaults);
router.post('/', extractUser, requireAdmin, geometryTypeController.createType);
router.put('/:id', extractUser, requireAdmin, geometryTypeController.updateType);
router.delete('/:id', extractUser, requireAdmin, geometryTypeController.deleteType);
export default router; export default router;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import * as inspectionController from '../controllers/inspectionController.js'; import * as inspectionController from '../controllers/inspectionController.js';
import { extractUser, requireUser } from '../middleware/authMiddleware.js'; import { extractUser, requireUser } from '../middleware/roleMiddleware.js';
import multer from 'multer'; import multer from 'multer';
import path from 'path'; import path from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
+5 -4
View File
@@ -1,11 +1,12 @@
import { Router } from 'express'; import { Router } from 'express';
import * as instrumentController from '../controllers/instrumentController.js'; import * as instrumentController from '../controllers/instrumentController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
router.get('/', instrumentController.getInstruments); router.post('/', extractUser, requireAdmin, instrumentController.createInstrument);
router.post('/', instrumentController.createInstrument); router.get('/', extractUser, instrumentController.getInstruments);
router.put('/:id', instrumentController.updateInstrument); router.put('/:id', extractUser, requireAdmin, instrumentController.updateInstrument);
router.delete('/:id', instrumentController.deleteInstrument); router.delete('/:id', extractUser, requireAdmin, instrumentController.deleteInstrument);
export default router; export default router;

Some files were not shown because too many files have changed in this diff Show More