Compare commits

..

11 Commits

117 changed files with 5089 additions and 4835 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
-55
View File
@@ -1,55 +0,0 @@
import express from 'express';
import cors from 'cors';
import projectRoutes from '../src/server/routes/projectRoutes.js';
import partRoutes from '../src/server/routes/partRoutes.js';
import paintingSchemeRoutes from '../src/server/routes/paintingSchemeRoutes.js';
import applicationRecordRoutes from '../src/server/routes/applicationRecordRoutes.js';
import inspectionRoutes from '../src/server/routes/inspectionRoutes.js';
import analysisRoutes from '../src/server/routes/analysisRoutes.js';
import dataSheetRoutes from '../src/server/routes/dataSheetRoutes.js';
import yieldStudyRoutes from '../src/server/routes/yieldStudyRoutes.js';
import userRoutes from '../src/server/routes/userRoutes.js';
import systemSettingsRoutes from '../src/server/routes/systemSettingsRoutes.js';
import geometryTypeRoutes from '../src/server/routes/geometryTypeRoutes.js';
import stockRoutes from '../src/server/routes/stockRoutes.js';
import notificationRoutes from '../src/server/routes/notificationRoutes.js';
import instrumentRoutes from '../src/server/routes/instrumentRoutes.js';
import { extractUser } from '../src/server/middleware/authMiddleware.js';
import path from 'path';
const app = express();
app.use(cors({
origin: '*', // Be more specific in production
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-organization-id', 'x-organization-name']
}));
app.use(express.json());
// Global Middleware
app.use(extractUser);
// Static Uploads
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
// Routes
app.use('/api/users', userRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/parts', partRoutes);
app.use('/api/painting-schemes', paintingSchemeRoutes);
app.use('/api/application-records', applicationRecordRoutes);
app.use('/api/inspections', inspectionRoutes);
app.use('/api', analysisRoutes);
app.use('/api/datasheets', dataSheetRoutes);
app.use('/api/yield-studies', yieldStudyRoutes);
app.use('/api/system-settings', systemSettingsRoutes);
app.use('/api/geometry-types', geometryTypeRoutes);
app.use('/api/stock', stockRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/instruments', instrumentRoutes);
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date() });
});
export default app;
-27
View File
@@ -1,27 +0,0 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import mongoose from 'mongoose';
export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error('MONGODB_URI is missing from Vercel settings');
await mongoose.connect(uri);
const state = mongoose.connection.readyState;
await mongoose.disconnect();
res.json({
success: true,
message: 'MongoDB Connection verified!',
state: state === 1 ? 'Connected' : state
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
const stack = error instanceof Error ? error.stack : undefined;
res.status(500).json({
success: false,
error: message,
stack: stack
});
}
}
-26
View File
@@ -1,26 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import type { VercelRequest, VercelResponse } from '@vercel/node';
import app from './app.js';
import { connectDB } from '../src/server/config/database.js';
export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
console.log('--- API CALL:', req.url);
// Conecta ao Banco de Dados (Supabase/Postgres)
await connectDB();
// Passa o controle para o Express
return app(req, res);
} catch (error: unknown) {
console.error('SERVERLESS BOOT ERROR:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return res.status(500).json({
error: 'Serverless Boot Error',
message: message,
path: req.url
});
}
}
-10
View File
@@ -1,10 +0,0 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function handler(req: VercelRequest, res: VercelResponse) {
res.json({
status: 'ok',
message: 'Vercel API is alive',
time: new Date().toISOString(),
env_check: process.env.MONGODB_URI ? 'URI present' : 'URI MISSING'
});
}
-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;
+19
View File
@@ -0,0 +1,19 @@
import { query } from './src/server/config/database.js';
async function main() {
try {
console.log("Creating gpi.files...");
await query("CREATE TABLE IF NOT EXISTS gpi.files (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), filename TEXT NOT NULL, content_type TEXT NOT NULL, data BYTEA NOT NULL, size INTEGER NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());");
console.log("Creating gpi.stock_audit_logs...");
await query("CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID, stock_item_id UUID, movement_id UUID, movement_number INTEGER, user_id TEXT, user_name TEXT, action TEXT, details TEXT, old_values JSONB, new_values JSONB, timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW());");
console.log("Done.");
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
main();
-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();
-72
View File
@@ -1,72 +0,0 @@
console.log('Loading Netlify Function...');
import serverless from 'serverless-http';
import express from 'express';
import cors from 'cors';
import mongoose from 'mongoose';
// Import local database connection that sets up GridFS/Bucket
import { connectDB } from '../../src/server/config/database.js';
// Static imports for routes to ensure esbuild bundles them correctly
import projectRoutes from '../../src/server/routes/projectRoutes.js';
import partRoutes from '../../src/server/routes/partRoutes.js';
import paintingSchemeRoutes from '../../src/server/routes/paintingSchemeRoutes.js';
import applicationRecordRoutes from '../../src/server/routes/applicationRecordRoutes.js';
import inspectionRoutes from '../../src/server/routes/inspectionRoutes.js';
import analysisRoutes from '../../src/server/routes/analysisRoutes.js';
import dataSheetRoutes from '../../src/server/routes/dataSheetRoutes.js';
import yieldStudyRoutes from '../../src/server/routes/yieldStudyRoutes.js';
import userRoutes from '../../src/server/routes/userRoutes.js';
import uploadsRoutes from '../../src/server/routes/uploadsRoutes.js';
import systemSettingsRoutes from '../../src/server/routes/systemSettingsRoutes.js';
import geometryTypeRoutes from '../../src/server/routes/geometryTypeRoutes.js';
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-clerk-user-id', 'x-organization-id']
}));
app.use(express.json());
// Routes
// We register them immediately since we use serverless-http's cold start handling
app.use('/api/users', userRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/parts', partRoutes);
app.use('/api/painting-schemes', paintingSchemeRoutes);
app.use('/api/application-records', applicationRecordRoutes);
app.use('/api/inspections', inspectionRoutes);
app.use('/api', analysisRoutes);
app.use('/api/datasheets', dataSheetRoutes);
app.use('/api/yield-studies', yieldStudyRoutes);
app.use('/api/system-settings', systemSettingsRoutes);
app.use('/api/geometry-types', geometryTypeRoutes);
// Serve uploads (from /tmp in serverless or local dir)
app.use('/uploads', uploadsRoutes);
// Simple test endpoint
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date(),
mongoState: mongoose.connection.readyState
});
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const handler = async (event: any, context: any) => {
context.callbackWaitsForEmptyEventLoop = false;
// Ensure DB is connected before processing request
try {
await connectDB();
} catch (e) {
console.error('DB Connection Failed:', e);
// We let it proceed, maybe it's a health check or will fail gracefully later
}
const httpHandler = serverless(app);
return await httpHandler(event, context);
};
+1146 -988
View File
File diff suppressed because it is too large Load Diff
+11 -18
View File
@@ -3,27 +3,20 @@
"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/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vercel/speed-insights": "^1.3.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"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 +24,11 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"enhanced-resolve": "^5.18.4", "enhanced-resolve": "^5.18.4",
"express": "^5.2.1", "express": "^5.2.1",
"jose": "^5.2.0", "jsonwebtoken": "^9.0.3",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mongodb": "^7.1.1",
"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",
@@ -43,22 +36,22 @@
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"search-web": "^1.0.3", "search-web": "^1.0.3",
"serverless-http": "^4.0.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"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", "@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-react": "^4.3.0",
"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 +65,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;
+25 -21
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,36 @@ 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
const distPath = path.join(process.cwd(), 'dist');
app.use(express.static(distPath));
// 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 +72,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;
+20 -10
View File
@@ -1,16 +1,26 @@
import { supabase } from './supabase.js'; import pg from 'pg';
const { Pool } = pg;
import dotenv from 'dotenv';
dotenv.config();
export const pool = new Pool({
host: process.env.DB_HOST || 'supabase-db',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'Xz0oyb6ArGYG5uAVTVwcvJxRrMuT7EIJ',
database: process.env.DB_NAME || 'postgres',
ssl: false
});
export const connectDB = async () => { export const connectDB = async () => {
try { try {
const { data, error } = await supabase.from('users').select('count'); const client = await pool.connect();
console.log('✅ Postgres (Supabase) connected successfully');
if (error) { client.release();
console.error('❌ Erro ao conectar no Supabase:', error);
throw error;
}
console.log('✅ Conectado ao Supabase (schema: gpi)');
} catch (error) { } catch (error) {
console.error('❌ Erro de conexão:', error); console.error('❌ Postgres connection error:', error);
} }
}; };
export const query = (text: string, params?: any[]) => pool.query(text, params);
+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();
+127
View File
@@ -0,0 +1,127 @@
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { query } from '../config/database.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 existingRes = await query('SELECT id FROM gpi.users WHERE email = $1', [email]);
if (existingRes.rows.length > 0) {
res.status(400).json({ error: 'Email já cadastrado' });
return;
}
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
// Gere um fakeAuthId para manter compatibilidade com sistemas que esperam um externalId
const fakeAuthId = `user_${uuidv4().replace(/-/g, '')}`;
const insertRes = await query(
`INSERT INTO gpi.users (name, email, clerk_id, role, is_banned, updated_at)
VALUES ($1, $2, $3, 'user', false, NOW()) RETURNING *`,
[name, email, fakeAuthId]
);
const newUser = insertRes.rows[0];
// Se houver uma coluna de password_hash no banco (precisamos garantir que exista)
try {
await query('UPDATE gpi.users SET password_hash = $1 WHERE id = $2', [passwordHash, newUser.id]);
} catch (e) {
console.warn('Could not update password_hash, check if column exists', e);
}
const token = jwt.sign(
{ userId: newUser.id, externalId: newUser.clerk_id, role: newUser.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({
message: 'Usuário criado com sucesso',
token,
user: { id: newUser.id, name: newUser.name, email: newUser.email, role: newUser.role, externalId: newUser.clerk_id }
});
} catch (error) {
console.error('Register Error:', error);
res.status(500).json({ error: 'Erro no servidor' });
}
};
export const login = async (req: Request, res: Response): Promise<void> => {
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({ error: 'Email e senha são obrigatórios' });
return;
}
const userRes = await query('SELECT * FROM gpi.users WHERE email = $1', [email]);
const user = userRes.rows[0];
if (!user) {
res.status(400).json({ error: 'Usuário não encontrado' });
return;
}
// Recuperar password_hash de algum lugar se não estiver no select principal (alguns schemas escondem)
// No nosso caso a query SELECT * deve trazer se existir.
if (!user.password_hash) {
res.status(400).json({ error: 'Usuário sem senha definida ou método de login externo.' });
return;
}
const isMatch = await bcrypt.compare(password, user.password_hash);
if (!isMatch) {
res.status(400).json({ error: 'Credenciais inválidas' });
return;
}
const token = jwt.sign(
{ userId: user.id, externalId: user.clerk_id || user.logto_id, role: user.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(200).json({
message: 'Login realizado com sucesso',
token,
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
externalId: user.clerk_id || user.logto_id
}
});
} catch (error) {
console.error('Login Error:', error);
res.status(500).json({ error: 'Erro no servidor' });
}
};
export const getMe = async (req: Request, res: Response): Promise<void> => {
try {
if (!req.appUser) {
res.status(401).json({ error: 'Não autorizado' });
return;
}
res.status(200).json(req.appUser);
} catch (error) {
console.error('GetMe Error:', error);
res.status(500).json({ error: 'Erro no servidor' });
}
};
+169 -17
View File
@@ -1,7 +1,10 @@
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';
import * as fileService from '../services/fileService.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: IAppUser; appUser?: IAppUser;
@@ -11,9 +14,10 @@ export const getAllDataSheets = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const sheets = await dataSheetService.getAllDataSheets(organizationId); const sheets = await dataSheetService.getAllDataSheets(organizationId);
res.json(toCamelCase(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 +27,195 @@ 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);
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)); let fileId: string | undefined = undefined;
res.status(201).json(toCamelCase(newSheet)); let finalFileUrl = fileUrl || '';
if (file) {
const buffer = fs.readFileSync(file.path);
const savedFile = await fileService.saveFile(file.originalname, file.mimetype, buffer);
fileId = savedFile.id;
finalFileUrl = savedFile.id; // Using ID as reference
try {
fs.unlinkSync(file.path);
} catch (error) {
console.warn('Failed to delete temp file:', file.path, error);
}
}
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
});
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;
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;
const updates: 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) {
const buffer = fs.readFileSync(file.path);
const savedFile = await fileService.saveFile(file.originalname, file.mimetype, buffer);
updates.fileId = savedFile.id;
updates.fileUrl = savedFile.id;
try {
fs.unlinkSync(file.path);
} catch (error) {
console.warn('Failed to delete temp file:', file.path, error);
}
} else if (fileUrl) {
updates.fileUrl = String(fileUrl);
}
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 = req.params.id as string;
// Check if it's a UUID
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
const fileDoc = await fileService.getFileById(id);
if (fileDoc) {
res.set('Content-Type', fileDoc.content_type || '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);
}
}
res.status(404).json({ error: 'File not found' }); res.status(404).json({ error: 'File not found' });
} 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 });
} }
}; };
@@ -1,7 +1,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import { query } from '../config/database.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js';
// 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 },
@@ -19,61 +19,108 @@ const DEFAULT_TYPES = [
export const getAllnames = async (req: Request, res: Response) => { export const getAllnames = async (req: Request, 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 || []));
} catch (error: unknown) { if (!organizationId && !isGlobalAdmin) {
res.json(DEFAULT_TYPES); return res.status(400).json({ error: 'Organization ID missing' });
}
let sql = 'SELECT * FROM gpi.geometry_types';
const params: any[] = [];
if (!isGlobalAdmin) {
sql += ' WHERE organization_id = $1';
params.push(organizationId);
}
sql += ' ORDER BY name ASC';
const result = await query(sql, params);
let types = result.rows;
// Auto-seed if empty for org
if (types.length === 0 && organizationId && !isGlobalAdmin) {
for (const t of DEFAULT_TYPES) {
await query(
'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
[organizationId, t.name, t.efficiencyLoss]
);
}
const reFetch = await query('SELECT * FROM gpi.geometry_types WHERE organization_id = $1 ORDER BY name ASC', [organizationId]);
types = reFetch.rows;
}
res.json(types);
} catch (error: any) {
console.error('[GeometryType] Error:', error);
res.status(500).json({ error: error.message });
} }
}; };
export const restoreDefaults = async (req: Request, res: Response) => { export const restoreDefaults = async (req: Request, res: Response) => {
try { try {
res.json(DEFAULT_TYPES); const organizationId = req.appUser?.organizationId;
} catch (error: unknown) { if (!organizationId) return res.status(400).json({ error: 'Organization ID missing' });
res.json(DEFAULT_TYPES);
await query('DELETE FROM gpi.geometry_types WHERE organization_id = $1', [organizationId]);
for (const t of DEFAULT_TYPES) {
await query(
'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3)',
[organizationId, t.name, t.efficiencyLoss]
);
}
const result = await query('SELECT * FROM gpi.geometry_types WHERE organization_id = $1 ORDER BY name ASC', [organizationId]);
res.json(result.rows);
} catch (error: any) {
res.status(500).json({ error: error.message });
} }
}; };
export const createType = async (req: Request, res: Response) => { export const createType = async (req: Request, 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
});
const { data, error } = await supabase const result = await query(
.from('geometry_types') 'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3) RETURNING *',
.insert(payload) [organizationId, name, Number(efficiencyLoss) || 0]
.select() );
.single(); res.status(201).json(result.rows[0]);
if (error) throw error; } catch (error: any) {
res.status(201).json(toCamelCase(data)); if (error.code === '23505') return res.status(409).json({ error: 'Already exists' });
} catch (error: unknown) { res.status(500).json({ error: error.message });
res.json(req.body);
} }
}; };
export const updateType = async (req: Request, res: Response) => { export const updateType = async (req: Request, 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 { name, efficiencyLoss } = req.body;
.eq('id', req.params.id)
.select() const result = await query(
.single(); 'UPDATE gpi.geometry_types SET name = $1, efficiency_loss = $2, updated_at = NOW() WHERE id = $3 AND organization_id = $4 RETURNING *',
if (error) throw error; [name, Number(efficiencyLoss), id, organizationId]
res.json(toCamelCase(data)); );
} catch (error: unknown) {
res.json(req.body); if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(result.rows[0]);
} catch (error: any) {
res.status(500).json({ error: error.message });
} }
}; };
export const deleteType = async (req: Request, res: Response) => { export const deleteType = async (req: Request, res: Response) => {
try { try {
await supabase.from('geometry_types').delete().eq('id', req.params.id); const { id } = req.params;
res.status(204).send(); const organizationId = req.appUser?.organizationId;
} catch (error: unknown) {
const result = await query('DELETE FROM gpi.geometry_types WHERE id = $1 AND organization_id = $2', [id, organizationId]);
if (result.rowCount === 0) return res.status(404).json({ error: 'Not found' });
res.status(204).send(); res.status(204).send();
} catch (error: any) {
res.status(500).json({ error: error.message });
} }
}; };
+50 -22
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 * as fileService from '../services/fileService.js';
import fs from 'fs';
export const createInspection = async (req: Request, res: Response) => { export const createInspection = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const createdBy = req.appUser?.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);
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,17 @@ 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' });
} }
const fileUrl = `/uploads/${req.file.filename}`;
const buffer = fs.readFileSync(req.file.path);
const savedFile = await fileService.saveFile(req.file.originalname, req.file.mimetype, buffer);
// Clean up temp file
fs.unlinkSync(req.file.path);
// We'll use /api/datasheets/file/:id to serve these if we use the same endpoint
// or create a new general /api/files/:id
const fileUrl = `/api/datasheets/file/${savedFile.id}`;
res.json({ url: fileUrl }); res.json({ url: fileUrl });
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
+37 -28
View File
@@ -1,50 +1,59 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import * as instrumentService from '../services/instrumentService.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 instrument = await instrumentService.createInstrument({
.insert({ ...req.body, organization_id: req.appUser?.organizationId }) ...req.body,
.select() organizationId
.single(); });
if (error) throw error; res.status(201).json(instrument);
res.status(201).json(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 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 || []); const instruments = await instrumentService.getInstruments(organizationId, status as string);
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) const instrument = await instrumentService.updateInstrument(id, req.body, organizationId);
.eq('id', req.params.id) if (!instrument) return res.status(404).json({ error: 'Instrumento não encontrado.' });
.select() res.json(instrument);
.single();
if (error) throw error;
res.json(data);
} 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 success = await instrumentService.deleteInstrument(id, organizationId);
if (!success) 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 });
} }
}; };
+105 -100
View File
@@ -1,50 +1,47 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import { query } from '../config/database.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 // Check if there's already a pending (unread) message from this user to that user
.from('messages') const existingRes = await query(
.insert({ 'SELECT * FROM gpi.messages WHERE organization_id = $1 AND from_user_id = $2 AND to_user_id = $3 AND is_read = false',
organization_id: organizationId, [organizationId, fromUserId, toUserId]
from_user_id: fromUserId, );
to_user_id: toUserId,
message,
is_read: false
})
.select()
.single();
if (error) throw error; if (existingRes.rows.length > 0) {
res.status(201).json(data); const updatedRes = await query(
} catch (error: any) { 'UPDATE gpi.messages SET message = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
[message, existingRes.rows[0].id]
);
return res.json(updatedRes.rows[0]);
}
const insertRes = await query(
`INSERT INTO gpi.messages (organization_id, from_user_id, to_user_id, message)
VALUES ($1, $2, $3, $4) RETURNING *`,
[organizationId, fromUserId, toUserId, message]
);
res.status(201).json(insertRes.rows[0]);
} 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 +50,29 @@ 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 || !toUserId) {
return res.status(400).json({ error: 'Organização não selecionada.' }); return res.status(400).json({ error: 'Contexto incompleto.' });
} }
const { data, error } = await supabase const sql = `
.from('messages') SELECT m.*, u.name as "fromUserName", u.email as "fromUserEmail"
.select('*') FROM gpi.messages m
.eq('organization_id', organizationId) LEFT JOIN gpi.users u ON m.from_user_id = u.clerk_id OR m.from_user_id = u.logto_id
.eq('to_user_id', toUserId) WHERE m.organization_id = $1 AND m.to_user_id = $2 AND m.is_read = false AND m.is_archived = false AND m.is_deleted_by_recipient = false
.eq('is_read', false) ORDER BY m.created_at DESC
.eq('is_archived', false); `;
const result = await query(sql, [organizationId, toUserId]);
if (error && error.code !== '42P01') throw error; const messages = result.rows.map(m => ({
res.json(data || []); ...m,
} catch (error: any) { fromUser: { name: m.fromUserName, email: m.fromUserEmail }
}));
res.json(messages);
} 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 +82,20 @@ 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 const result = await query(
.from('messages') 'UPDATE gpi.messages SET is_read = true, read_at = NOW() WHERE id = $1 AND organization_id = $2 AND to_user_id = $3 RETURNING *',
.update({ is_read: true, read_at: new Date().toISOString() }) [id, organizationId, userId]
.eq('id', id) );
.eq('to_user_id', userId)
.eq('organization_id', organizationId)
.select()
.single();
if (error) throw error; if (result.rows.length === 0) {
res.json(data); return res.status(404).json({ error: 'Mensagem não encontrada.' });
} catch (error: any) { }
res.json(result.rows[0]);
} 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,42 +104,52 @@ 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 || !fromUserId) {
.from('messages') return res.status(400).json({ error: 'Contexto incompleto.' });
.select('*') }
.eq('organization_id', organizationId)
.eq('from_user_id', fromUserId)
.eq('is_read', false);
if (error && error.code !== '42P01') throw error; const sql = `
res.json(data || []); SELECT m.*, u.name as "toUserName", u.email as "toUserEmail"
} catch (error: any) { FROM gpi.messages m
LEFT JOIN gpi.users u ON m.to_user_id = u.clerk_id OR m.to_user_id = u.logto_id
WHERE m.organization_id = $1 AND m.from_user_id = $2 AND m.is_read = false
ORDER BY m.created_at DESC
`;
const result = await query(sql, [organizationId, fromUserId]);
const messages = result.rows.map(m => ({
...m,
toUser: { name: m.toUserName, email: m.toUserEmail }
}));
res.json(messages);
} 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 const result = await query(
.from('messages') 'DELETE FROM gpi.messages WHERE id = $1 AND from_user_id = $2 AND organization_id = $3 AND is_read = false',
.delete() [id, userId, organizationId]
.eq('id', id) );
.eq('from_user_id', userId)
.eq('organization_id', organizationId)
.eq('is_read', false);
if (error) throw error; if (result.rowCount === 0) {
res.status(204).send(); return res.status(404).json({ error: 'Mensagem não encontrada ou já lida.' });
} catch (error: any) { }
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.' });
} }
@@ -148,21 +159,18 @@ export const deleteMessage = async (req: Request, res: Response) => {
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 result = await query(
.from('messages') 'UPDATE gpi.messages SET is_archived = true, is_read = true WHERE id = $1 AND to_user_id = $2 AND organization_id = $3 RETURNING *',
.update({ is_archived: true, is_read: true }) [id, userId, organizationId]
.eq('id', id) );
.eq('to_user_id', userId)
.eq('organization_id', organizationId)
.select()
.single();
if (error) throw error; if (result.rows.length === 0) return res.status(404).json({ error: 'Mensagem não encontrada.' });
res.json(data);
} catch (error: any) { res.json(result.rows[0]);
} 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 +179,18 @@ 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 result = await query(
.from('messages') 'UPDATE gpi.messages SET is_deleted_by_recipient = true WHERE id = $1 AND to_user_id = $2 AND organization_id = $3 RETURNING *',
.update({ 'is_deleted_by_recipient': true }) [id, userId, organizationId]
.eq('id', id) );
.eq('to_user_id', userId)
.eq('organization_id', organizationId) if (result.rows.length === 0) return res.status(404).json({ error: 'Mensagem não encontrada.' });
.select()
.single();
if (error) throw error;
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 });
+18 -15
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;
@@ -12,7 +11,7 @@ export const createProject = async (req: AuthRequest, res: Response) => {
try { try {
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)); 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 +24,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 +36,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 +47,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 +56,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 +69,20 @@ 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) { 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 +91,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';
+240 -104
View File
@@ -1,152 +1,288 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import * as stockItemService from '../services/stockItemService.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js'; import { IAppUser } from '../middleware/roleMiddleware.js';
import { IAppUser } from '../middleware/authMiddleware.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;
if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) {
return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' });
}
const savedItem = await stockItemService.createStockItem({
...req.body,
organizationId,
createdBy: req.appUser?.externalId,
quantity: Number(quantity),
minStock: Number(minStock) || 0
});
await stockItemService.createStockMovement({
organizationId,
createdBy: req.appUser?.externalId,
stockItemId: savedItem.id,
movementNumber: 1,
type: 'ENTRY',
quantity: Number(quantity),
responsible: userName,
notes: 'Abertura de Lote / Entrada Inicial'
});
if (organizationId) {
await notificationService.create({
organizationId,
title: 'Recebimento de Material',
message: `Recebido: ${quantity}${unit} de ${savedItem.rr_number} (Lote: ${batchNumber}).`,
type: 'info',
metadata: { stockItemId: savedItem.id, triggerType: 'stock_received' }
});
}
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';
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; const { quantity, ...otherData } = req.body;
res.json(toCamelCase(data));
if (quantity !== undefined) {
return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' });
}
const updated = await stockItemService.updateStockItem(id, organizationId!, otherData);
if (!updated) return res.status(404).json({ error: 'Item não encontrado.' });
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;
res.json(toCamelCase(data));
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
const item = await stockItemService.getStockItemById(id, organizationId!);
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
const newQuantity = Number(item.quantity) + Number(quantityDelta);
if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' });
await stockItemService.updateStockItemQuantity(id, newQuantity);
const lastNum = await stockItemService.getLatestMovementNumber(id);
await stockItemService.createStockMovement({
organizationId,
createdBy: req.appUser?.externalId,
stockItemId: id,
movementNumber: lastNum + 1,
type: 'ADJUSTMENT',
quantity: Number(quantityDelta),
responsible: userName,
reason
});
res.json({ ...item, quantity: newQuantity });
} 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); const item = await stockItemService.getStockItemById(id, organizationId!);
const { data, error } = await supabase.from('stock_items').update({ quantity: newQuantity }).eq('id', id).select().single(); if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
if (error) throw error;
res.json(toCamelCase(data)); if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' });
const newQuantity = item.quantity - Number(quantityConsumed);
await stockItemService.updateStockItemQuantity(id, newQuantity);
const lastNum = await stockItemService.getLatestMovementNumber(id);
await stockItemService.createStockMovement({
organizationId,
createdBy: req.appUser?.externalId,
stockItemId: id,
movementNumber: lastNum + 1,
type: 'CONSUMPTION',
quantity: -Number(quantityConsumed),
responsible: userName,
requester,
date: date || new Date()
});
res.json({ ...item, quantity: newQuantity });
} 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 {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const success = await stockItemService.deleteStockItem(id, organizationId!);
if (!success) return res.status(404).json({ error: 'Item não encontrado.' });
res.status(204).send();
} catch (error: unknown) {
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;
const items = await stockItemService.getStockItems(organizationId!, dataSheetId as string);
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 stockItemService.getStockItemById(id, organizationId!);
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;
const organizationId = req.appUser?.organizationId;
const movements = await stockItemService.getStockMovements(id, organizationId!);
res.json(movements);
} catch (error: unknown) {
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 updateStockMovement = 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; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
res.json(toCamelCase(data)); const userId = req.appUser?.externalId || 'system';
const oldMovement = await stockItemService.getStockMovementById(id, organizationId!);
if (!oldMovement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
const item = await stockItemService.getStockItemById(oldMovement.stock_item_id, organizationId!);
const quantityDiff = Number(req.body.quantity) - Number(oldMovement.quantity);
const newStockLevel = Number(item.quantity) + quantityDiff;
if (newStockLevel < 0) return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
await stockItemService.updateStockItemQuantity(item.id, newStockLevel);
await stockItemService.createAuditLog({
organizationId,
stockItemId: item.id,
movementId: id,
movementNumber: oldMovement.movement_number,
userId,
userName,
action: 'UPDATE',
details: `Edição de Movimentação: Qtd ${oldMovement.quantity} -> ${req.body.quantity}`,
oldValues: oldMovement,
newValues: req.body
});
const updated = await stockItemService.updateStockMovement(id, organizationId!, req.body);
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 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 movement = await stockItemService.getStockMovementById(id, organizationId!);
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
const item = await stockItemService.getStockItemById(movement.stock_item_id, organizationId!);
const newStockLevel = Number(item.quantity) - Number(movement.quantity);
if (newStockLevel < 0) return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
await stockItemService.updateStockItemQuantity(item.id, newStockLevel);
await stockItemService.createAuditLog({
organizationId,
stockItemId: item.id,
movementId: id,
movementNumber: movement.movement_number,
userId,
userName,
action: 'DELETE',
details: `Exclusão de Movimentação: Qtd ${movement.quantity}`,
oldValues: movement
});
await stockItemService.deleteStockMovement(id, organizationId!);
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';
res.status(500).json({ error: message });
}
};
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const logs = await stockItemService.getStockAuditLogs(id, organizationId!);
res.json(logs);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
@@ -1,32 +1,26 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import { query } from '../config/database.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 = {
settingsId: 'global',
appName: 'GPI',
appSubtitle: 'Gestão de Pintura Industrial'
};
export const getSettings = async (req: Request, res: Response) => { export const getSettings = async (req: Request, res: Response) => {
try { try {
const { data: settings, error } = await supabase const resSettings = await query('SELECT * FROM gpi.system_settings WHERE settings_id = $1', ['global']);
.from('system_settings') let settings = resSettings.rows[0];
.select('*')
.eq('settings_id', 'global')
.single();
if (error && error.code !== 'PGRST116') { if (!settings) {
console.log('System settings table not found, returning defaults'); const insertRes = await query(
return res.json(DEFAULT_SETTINGS); 'INSERT INTO gpi.system_settings (settings_id, app_name, app_subtitle) VALUES ($1, $2, $3) RETURNING *',
['global', 'GPI', 'Gestão de Pintura Industrial']
);
settings = insertRes.rows[0];
} }
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,47 +28,20 @@ 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 result = await query(
.from('system_settings') `INSERT INTO gpi.system_settings (settings_id, app_name, app_subtitle, app_logo_url, updated_by, updated_at)
.select('*') VALUES ('global', $1, $2, $3, $4, NOW())
.eq('settings_id', 'global') ON CONFLICT (settings_id) DO UPDATE SET
.single(); app_name = EXCLUDED.app_name,
app_subtitle = EXCLUDED.app_subtitle,
app_logo_url = EXCLUDED.app_logo_url,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING *`,
[appName, appSubtitle, appLogoUrl, req.appUser?.email]
);
let settings; res.json(result.rows[0]);
if (fetchError || !existing) {
const { data, error } = await supabase
.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}`);
res.json(settings);
} catch (error) { } catch (error) {
console.error('Error updating system settings:', error); console.error('Error updating system settings:', error);
res.status(500).json({ error: 'Erro ao atualizar configurações do sistema' }); res.status(500).json({ error: 'Erro ao atualizar configurações do sistema' });
@@ -84,15 +51,11 @@ 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 };
const tmpPath = path.join(os.tmpdir(), 'uploads', filename);
const localPath = path.join(process.cwd(), 'uploads', filename); const localPath = path.join(process.cwd(), 'uploads', filename);
if (fs.existsSync(tmpPath)) { if (fs.existsSync(localPath)) {
res.sendFile(tmpPath);
} else if (fs.existsSync(localPath)) {
res.sendFile(localPath); res.sendFile(localPath);
} else { } else {
console.error(`Logo file not found in tmp or local: ${filename}`);
res.status(404).json({ error: 'Imagem não encontrada' }); res.status(404).json({ error: 'Imagem não encontrada' });
} }
} catch (error) { } catch (error) {
@@ -106,7 +69,6 @@ export const uploadLogo = async (req: Request, res: Response) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' }); return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
} }
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) {
@@ -117,13 +79,8 @@ export const uploadLogo = async (req: Request, res: Response) => {
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 resUsers = await query('SELECT * FROM gpi.users ORDER BY created_at DESC');
.from('users') res.json(resUsers.rows);
.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 +89,20 @@ export const getGlobalUsers = async (req: Request, res: Response) => {
export const getGlobalOrganizations = async (req: Request, res: Response) => { export const getGlobalOrganizations = async (req: Request, res: Response) => {
try { try {
const { data: organizations, error } = await supabase const sql = `
.from('organizations') SELECT
.select('*'); o.id as _id,
o.name,
if (error && error.code !== '42P01') throw error; o.is_banned as "isBanned",
COUNT(uo.user_id) as "memberCount",
if (!organizations || organizations.length === 0) { MAX(uo.updated_at) as "lastActive"
return res.json([]); FROM gpi.organizations o
} LEFT JOIN gpi.user_organizations uo ON o.id = uo.organization_id
GROUP BY o.id, o.name, o.is_banned
const orgsWithMembers = await Promise.all( ORDER BY "memberCount" DESC
organizations.map(async (org) => { `;
const { data: members } = await supabase const resOrgs = await query(sql);
.from('user_organizations') res.json(resOrgs.rows);
.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,16 +117,12 @@ export const toggleOrganizationBan = async (req: Request, res: Response) => {
return res.status(400).json({ error: 'ID da organização é obrigatório.' }); return res.status(400).json({ error: 'ID da organização é obrigatório.' });
} }
const { data: org, error } = await supabase const resOrg = await query(
.from('organizations') 'UPDATE gpi.organizations SET is_banned = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
.update({ is_banned: isBanned }) [isBanned, organizationId]
.eq('id', organizationId) );
.select()
.single();
if (error) throw error; res.json(resOrg.rows[0]);
console.log(`Organization ${organizationId} ban status set to ${isBanned} by ${req.appUser?.email}`);
res.json(org);
} catch (error) { } catch (error) {
console.error('Error toggling organization ban:', error); console.error('Error toggling organization ban:', error);
res.status(500).json({ error: 'Erro ao atualizar status da organização.' }); res.status(500).json({ error: 'Erro ao atualizar status da organização.' });
+79 -112
View File
@@ -1,97 +1,66 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import * as userService from '../services/userService.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: any; appUser?: any;
} }
export const syncUser = async (req: AuthRequest, res: Response) => { export const syncUser = async (req: Request, res: Response) => {
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; const user = await userService.syncUser({
externalId,
if (!email) {
return res.status(400).json({ error: 'Email é obrigatório para sincronização.' });
}
const { data: existingUser } = await supabase
.from('users')
.select('*')
.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, organizationId,
role: 'guest' clerkRole
}) });
.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;
}
}
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 }); res.status(500).json({ error: 'Erro ao sincronizar usuário: ' + (error instanceof Error ? error.message : String(error)) });
} }
}; };
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.json({ return res.status(404).json({ error: 'Usuário não encontrado.' });
id: '00000000-0000-0000-0000-000000000000',
email: 'guest@gpi.app',
name: 'Guest User',
role: 'user'
});
} }
res.json(req.appUser);
} catch (error: any) { const organizationId = req.headers['x-organization-id'] as string;
const user = await userService.getCurrentUser(req.appUser.clerk_id || req.appUser.logto_id || req.appUser.externalId, organizationId);
if (!user) {
return res.status(404).json({ error: 'Usuário não encontrado.' });
}
res.json(user);
} 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.' });
} }
}; };
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) { if (!organizationId) {
console.log('Error fetching users:', error.message); return res.status(400).json({ error: 'Organização não selecionada.' });
return res.json([]);
} }
return res.json(data || []); const members = await userService.getAllUsersInOrg(organizationId);
} catch (error: any) { 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.' });
} }
}; };
@@ -99,23 +68,21 @@ 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 const member = await userService.updateUserRole(id, organizationId, role);
.from('user_organizations') if (!member) {
.update({ role }) return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
.eq('id', id) }
.select()
.single();
if (error && error.code !== '42P01') throw error; res.json(member);
res.json(data || { message: 'Role atualizado' }); } catch (error) {
} catch (error: any) {
console.error('Error updating role:', error); console.error('Error updating role:', error);
res.json({ message: 'Role atualizado' }); res.status(500).json({ error: 'Erro ao alterar role.' });
} }
}; };
@@ -123,72 +90,72 @@ 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 userService.toggleBanUser(id, organizationId, isBanned);
res.json(data || { message: 'Ban atualizado' }); if (!member) {
} catch (error: any) { return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
}
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.' });
} }
}; };
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.' });
} }
await userService.heartbeat(req.appUser.id);
try {
await supabase
.from('users')
.update({ last_seen_at: new Date().toISOString() })
.eq('id', req.appUser.id);
} catch (e) { /* ignore */ }
res.status(200).send(); res.status(200).send();
} catch (error) { } catch (error) {
console.error('Heartbeat error:', error); console.error('Heartbeat error:', error);
res.status(200).send(); res.status(500).send();
} }
}; };
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; const activeUsers = await userService.getActiveUsers(organizationId, currentUserId);
res.json(data || []); res.json(activeUsers);
} catch (error: any) { } catch (error) {
console.error('Error getting active users:', error); console.error('Error getting active users:', error);
res.json([]); res.status(500).json([]);
} }
}; };
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);
const success = await userService.deleteUserFromOrg(id, organizationId);
if (!success) {
return res.status(404).json({ error: 'Membro não encontrado.' });
}
if (error && error.code !== '42P01') throw error;
res.json({ message: 'Membro removido com sucesso.' }); res.json({ message: 'Membro removido com sucesso.' });
} catch (error: any) { } 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 });
} }
}; };
+16 -19
View File
@@ -1,36 +1,33 @@
import dotenv from 'dotenv';
dotenv.config();
import app from './app.js'; import app from './app.js';
import dotenv from 'dotenv';
import { connectDB } from './config/database.js'; import { connectDB } from './config/database.js';
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)`);
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();
}); });
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();
};
}
+137
View File
@@ -0,0 +1,137 @@
import { Request, Response, NextFunction } from 'express';
import { query } from '../config/database.js';
export type UserRole = 'guest' | 'user' | 'admin';
export type OrgRole = 'guest' | 'user' | 'admin';
export interface IAppUser {
id: string;
externalId: string;
email: string;
name: string;
role: UserRole;
isBanned: boolean;
organizationId?: string;
organizationRole?: OrgRole;
organizationBanned?: boolean;
}
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();
}
// Buscar usuário (compatível com clerk_id ou logto_id salvos como external_id no futuro,
// ou usando as colunas atuais clerk_id/logto_id)
const userRes = await query(
'SELECT * FROM gpi.users WHERE clerk_id = $1 OR logto_id = $1',
[externalId]
);
const user = userRes.rows[0];
if (user) {
if (user.is_banned) {
return res.status(403).json({ error: 'Conta bloqueada. Entre em contato com o administrador.' });
}
const appUser: IAppUser = {
id: user.id,
externalId: user.clerk_id || user.logto_id || externalId,
email: user.email,
name: user.name,
role: user.role,
isBanned: user.is_banned,
organizationId: organizationId
};
if (organizationId) {
// Verificar organização
const orgRes = await query(
'SELECT * FROM gpi.organizations WHERE id = $1 OR clerk_id = $1 OR logto_id = $1',
[organizationId]
);
const org = orgRes.rows[0];
if (org) {
if (org.is_banned) {
return res.status(403).json({ error: 'Acesso bloqueado: Esta organização está suspensa.' });
}
// Buscar papel na organização
const memberRes = await query(
'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2',
[user.id, org.id]
);
const member = memberRes.rows[0];
if (member) {
if (member.is_banned) {
return res.status(403).json({ error: 'Acesso bloqueado nesta organização.' });
}
appUser.organizationRole = member.role;
appUser.role = member.role; // Override global role
} else {
appUser.organizationRole = 'guest';
appUser.role = 'guest';
}
}
}
req.appUser = appUser;
}
next();
} catch (error) {
console.error('Error extracting user:', error);
next();
}
};
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.' });
}
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();
};
};
export const requireAdmin = requireRole(['admin']);
export const requireUser = requireRole(['user', 'admin']);
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.' });
}
next();
};
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') {
return res.status(403).json({ error: 'Acesso restrito ao desenvolvedor.' });
}
next();
};
+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;
+1 -1
View File
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { sendMessage, getUnreadMessages, markMessageAsRead, getMyPendingMessages, deleteMessage, archiveMessage, recipientDeleteMessage } from '../controllers/messageController.js'; import { sendMessage, getUnreadMessages, markMessageAsRead, getMyPendingMessages, deleteMessage, archiveMessage, recipientDeleteMessage } from '../controllers/messageController.js';
import { extractUser } from '../middleware/authMiddleware.js'; import { extractUser } from '../middleware/roleMiddleware.js';
const router = express.Router(); const router = express.Router();
+7 -3
View File
@@ -1,12 +1,16 @@
import { Router } from 'express'; import { Router } from 'express';
import * as paintingSchemeController from '../controllers/paintingSchemeController.js'; import * as paintingSchemeController from '../controllers/paintingSchemeController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
// Public routes (read-only)
router.get('/', paintingSchemeController.getAllPaintingSchemes); router.get('/', paintingSchemeController.getAllPaintingSchemes);
router.get('/project/:projectId', paintingSchemeController.getPaintingSchemesByProject); router.get('/project/:projectId', paintingSchemeController.getPaintingSchemesByProject);
router.post('/', paintingSchemeController.createPaintingScheme);
router.put('/:id', paintingSchemeController.updatePaintingScheme); // Protected routes (require admin permission)
router.delete('/:id', paintingSchemeController.deletePaintingScheme); router.post('/', extractUser, requireAdmin, paintingSchemeController.createPaintingScheme);
router.put('/:id', extractUser, requireAdmin, paintingSchemeController.updatePaintingScheme);
router.delete('/:id', extractUser, requireAdmin, paintingSchemeController.deletePaintingScheme);
export default router; export default router;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import * as partController from '../controllers/partController.js'; import * as partController from '../controllers/partController.js';
import { extractUser, requireAdmin } from '../middleware/authMiddleware.js'; import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
+1 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import * as projectController from '../controllers/projectController.js'; import * as projectController from '../controllers/projectController.js';
import { extractUser, requireAdmin } from '../middleware/authMiddleware.js'; import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
+33 -11
View File
@@ -1,18 +1,40 @@
import { Router } from 'express'; import { Router } from 'express';
import * as stockController from '../controllers/stockController.js'; import * as stockController from '../controllers/stockController.js';
import { extractUser, requireAdmin, requireUser } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
router.get('/', stockController.getStockItems); // Retrieve all items
router.get('/:id', stockController.getStockItemById); router.get('/', extractUser, stockController.getStockItems);
router.get('/:id/movements', stockController.getStockMovements);
router.get('/:id/logs', stockController.getStockAuditLogs); // Retrieve Item Details
router.post('/', stockController.createStockItem); router.get('/:id', extractUser, stockController.getStockItemById);
router.put('/:id', stockController.updateStockItem);
router.post('/:id/adjust', stockController.adjustStock); // Retrieve movements for a specific item
router.post('/:id/consume', stockController.consumeStock); router.get('/:id/movements', extractUser, stockController.getStockMovements);
router.delete('/:id', stockController.deleteStockItem);
router.put('/movements/:id', stockController.updateStockMovement); // Retrieve logs for a specific item
router.delete('/movements/:id', stockController.deleteStockMovement); router.get('/:id/logs', extractUser, stockController.getStockAuditLogs);
// Create (Entry)
router.post('/', extractUser, requireUser, stockController.createStockItem);
// Update Details (No quantity)
router.put('/:id', extractUser, requireAdmin, stockController.updateStockItem);
// Technical Adjustment (Baixa Técnica / Correção)
router.post('/:id/adjust', extractUser, requireAdmin, stockController.adjustStock);
// Consumption (Baixa por Obra)
router.post('/:id/consume', extractUser, requireUser, stockController.consumeStock);
// Delete Stock Item (and its movements)
router.delete('/:id', extractUser, requireAdmin, stockController.deleteStockItem);
// -----------------------------------------------------------
// Movement CRUD
// -----------------------------------------------------------
router.put('/movements/:id', extractUser, requireAdmin, stockController.updateStockMovement);
router.delete('/movements/:id', extractUser, requireAdmin, stockController.deleteStockMovement);
export default router; export default router;
+1 -1
View File
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { getSettings, updateSettings, uploadLogo, serveLogo } from '../controllers/systemSettingsController.js'; import { getSettings, updateSettings, uploadLogo, serveLogo } from '../controllers/systemSettingsController.js';
import { extractUser, requireDeveloper } from '../middleware/authMiddleware.js'; import { extractUser, requireDeveloper } from '../middleware/roleMiddleware.js';
import { uploadLogoDetails } from '../middleware/uploadMiddleware.js'; import { uploadLogoDetails } from '../middleware/uploadMiddleware.js';
const router = express.Router(); const router = express.Router();
+10 -7
View File
@@ -1,20 +1,23 @@
import express from 'express'; import express from 'express';
import { syncUser, getCurrentUser, getAllUsers, updateUserRole, toggleBanUser, heartbeat, getActiveUsers, deleteUser } from '../controllers/userController.js'; import { syncUser, getCurrentUser, getAllUsers, updateUserRole, toggleBanUser, heartbeat, getActiveUsers, deleteUser } from '../controllers/userController.js';
import { extractUser } from '../middleware/authMiddleware.js'; import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = express.Router(); const router = express.Router();
// Public routes (no auth required) // Sync user from Auth (public - called on login)
router.get('/', getAllUsers); router.post('/sync', syncUser);
// Get current user (requires extractUser middleware)
router.get('/me', extractUser, getCurrentUser); router.get('/me', extractUser, getCurrentUser);
// Heartbeat & Presence // Heartbeat & Presence
router.post('/heartbeat', extractUser, heartbeat); router.post('/heartbeat', extractUser, heartbeat);
router.get('/active', extractUser, getActiveUsers); router.get('/active', extractUser, getActiveUsers);
// Admin routes // Admin-only routes
router.patch('/:id/role', extractUser, updateUserRole); router.get('/', extractUser, requireAdmin, getAllUsers);
router.patch('/:id/ban', extractUser, toggleBanUser); router.patch('/:id/role', extractUser, requireAdmin, updateUserRole);
router.delete('/:id', extractUser, deleteUser); router.patch('/:id/ban', extractUser, requireAdmin, toggleBanUser);
router.delete('/:id', extractUser, requireAdmin, deleteUser);
export default router; export default router;
+8 -4
View File
@@ -1,11 +1,15 @@
import { Router } from 'express'; import { Router } from 'express';
import * as yieldStudyController from '../controllers/yieldStudyController.js'; import * as yieldStudyController from '../controllers/yieldStudyController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
router.get('/', yieldStudyController.getAllStudies); // Public routes (read-only)
router.post('/', yieldStudyController.createStudy); router.get('/', extractUser, yieldStudyController.getAllStudies);
router.put('/:id', yieldStudyController.updateStudy);
router.delete('/:id', yieldStudyController.deleteStudy); // Protected routes (require admin permission)
router.post('/', extractUser, requireAdmin, yieldStudyController.createStudy);
router.put('/:id', extractUser, requireAdmin, yieldStudyController.updateStudy);
router.delete('/:id', extractUser, requireAdmin, yieldStudyController.deleteStudy);
export default router; export default router;
+326
View File
@@ -0,0 +1,326 @@
-- Full Database Schema for GPI (PostgreSQL)
CREATE SCHEMA IF NOT EXISTS gpi;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 1. Organizations
CREATE TABLE IF NOT EXISTS gpi.organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
clerk_id TEXT UNIQUE, -- Legacy
logto_id TEXT UNIQUE,
name TEXT NOT NULL,
is_banned BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 2. Users
CREATE TABLE IF NOT EXISTS gpi.users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
clerk_id TEXT UNIQUE, -- Legacy
logto_id TEXT UNIQUE,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'guest',
is_banned BOOLEAN DEFAULT FALSE,
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 3. many-to-many user_organizations
CREATE TABLE IF NOT EXISTS gpi.user_organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES gpi.users(id) ON DELETE CASCADE,
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'guest',
is_banned BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE (user_id, organization_id)
);
-- 4. Technical Data Sheets (Fichas Técnicas)
CREATE TABLE IF NOT EXISTS gpi.technical_data_sheets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
manufacturer TEXT,
type TEXT,
file_url TEXT,
solids_volume DECIMAL,
density DECIMAL,
mixing_ratio TEXT,
yield_theoretical DECIMAL,
wft_min DECIMAL,
wft_max DECIMAL,
dft_min DECIMAL,
dft_max DECIMAL,
reducer TEXT,
yield_factor DECIMAL DEFAULT 1.0,
min_stock DECIMAL DEFAULT 0,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 5. Projects
CREATE TABLE IF NOT EXISTS gpi.projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client TEXT NOT NULL,
start_date DATE,
end_date DATE,
technician TEXT,
environment TEXT,
weight_kg DECIMAL,
status TEXT DEFAULT 'active',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 6. Parts
CREATE TABLE IF NOT EXISTS gpi.parts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
description TEXT NOT NULL,
dimensions TEXT,
weight DECIMAL,
type TEXT,
area DECIMAL,
complexity INTEGER,
quantity INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 7. Painting Schemes
CREATE TABLE IF NOT EXISTS gpi.painting_schemes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT,
coat TEXT, -- Primer, Intermediate, Finish
solids_volume DECIMAL,
yield_theoretical DECIMAL,
eps_min DECIMAL,
eps_max DECIMAL,
dilution DECIMAL,
manufacturer TEXT,
color TEXT,
notes TEXT,
paint_id UUID REFERENCES gpi.technical_data_sheets(id),
thinner_id UUID REFERENCES gpi.technical_data_sheets(id),
color_hex TEXT,
thinner_symbol TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 8. Application Records (Lotes de Pintura)
CREATE TABLE IF NOT EXISTS gpi.application_records (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
coat_stage TEXT,
piece_description TEXT,
date DATE,
operator TEXT,
real_weight DECIMAL,
volume_used DECIMAL,
area_painted DECIMAL,
wet_thickness_avg DECIMAL,
dry_thickness_calc DECIMAL,
real_yield DECIMAL,
method TEXT,
diluent_used DECIMAL,
notes TEXT,
items JSONB DEFAULT '[]', -- List of {partId, quantity}
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 9. Instruments
CREATE TABLE IF NOT EXISTS gpi.instruments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
serial_number TEXT,
type TEXT,
last_calibration DATE,
calibration_due DATE,
status TEXT DEFAULT 'active',
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 10. Stock Items
CREATE TABLE IF NOT EXISTS gpi.stock_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
data_sheet_id UUID REFERENCES gpi.technical_data_sheets(id),
batch_number TEXT NOT NULL,
manufacturing_date DATE,
expiration_date DATE,
initial_quantity DECIMAL NOT NULL,
current_quantity DECIMAL NOT NULL,
unit TEXT DEFAULT 'L',
location TEXT,
status TEXT DEFAULT 'active',
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 11. Inspections
CREATE TABLE IF NOT EXISTS gpi.inspections (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
application_record_id UUID REFERENCES gpi.application_records(id),
stock_item_id UUID REFERENCES gpi.stock_items(id),
instrument_id UUID REFERENCES gpi.instruments(id),
type TEXT CHECK (type IN ('painting', 'surface_treatment')),
date DATE,
inspector TEXT,
part_temperature DECIMAL,
weight_kg DECIMAL,
appearance TEXT, -- approved, rejected, notes
defects TEXT,
photos TEXT[] DEFAULT '{}',
piece_description TEXT,
eps_points DECIMAL[] DEFAULT '{}',
adhesion_test TEXT,
batch TEXT,
treatment_executor TEXT,
treatment_type TEXT,
cleaning_degree TEXT,
roughness_readings DECIMAL[] DEFAULT '{}',
flash_rust TEXT,
temperature DECIMAL,
relative_humidity DECIMAL,
period TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 12. Stock Movements
CREATE TABLE IF NOT EXISTS gpi.stock_movements (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
stock_item_id UUID REFERENCES gpi.stock_items(id) ON DELETE CASCADE,
type TEXT CHECK (type IN ('in', 'out', 'adjust')),
quantity DECIMAL NOT NULL,
reason TEXT,
project_id UUID REFERENCES gpi.projects(id),
user_id TEXT, -- external_id
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 13. System Settings
CREATE TABLE IF NOT EXISTS gpi.system_settings (
settings_id TEXT PRIMARY KEY DEFAULT 'global',
app_name TEXT DEFAULT 'GPI',
app_subtitle TEXT DEFAULT 'Gestão de Pintura Industrial',
app_logo_url TEXT,
updated_by TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 14. Notifications
CREATE TABLE IF NOT EXISTS gpi.notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
recipient_id UUID REFERENCES gpi.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
message TEXT NOT NULL,
type TEXT CHECK (type IN ('info', 'warning', 'error', 'success')) DEFAULT 'info',
is_read BOOLEAN DEFAULT FALSE,
is_archived BOOLEAN DEFAULT FALSE,
archived_by UUID[] DEFAULT '{}',
deleted_by UUID[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 15. Yield Studies
CREATE TABLE IF NOT EXISTS gpi.yield_studies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
data_sheet_id UUID REFERENCES gpi.technical_data_sheets(id),
name TEXT NOT NULL,
target_dft DECIMAL NOT NULL,
dilution_percent DECIMAL DEFAULT 0,
categories JSONB DEFAULT '[]',
total_weight DECIMAL,
estimated_paint_volume DECIMAL,
estimated_reducer_volume DECIMAL,
estimated_paint_volume_by_area DECIMAL,
estimated_reducer_volume_by_area DECIMAL,
average_complexity DECIMAL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 16. Stock Audit Logs
CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
stock_item_id UUID REFERENCES gpi.stock_items(id) ON DELETE CASCADE,
movement_id UUID,
movement_number INTEGER,
user_id TEXT, -- external_id
user_name TEXT,
action TEXT NOT NULL,
details TEXT,
old_values JSONB,
new_values JSONB,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 17. Messages
CREATE TABLE IF NOT EXISTS gpi.messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
from_user_id TEXT NOT NULL, -- external_id
to_user_id TEXT NOT NULL, -- external_id
message TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
read_at TIMESTAMP WITH TIME ZONE,
is_archived BOOLEAN DEFAULT FALSE,
is_deleted_by_recipient BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 18. Geometry Types
CREATE TABLE IF NOT EXISTS gpi.geometry_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
efficiency_loss DECIMAL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE (organization_id, name)
);
-- 19. Stored Files
CREATE TABLE IF NOT EXISTS gpi.stored_files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT,
mimetype TEXT,
size INTEGER,
path TEXT,
category TEXT,
reference_id TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_projects_org ON gpi.projects(organization_id);
CREATE INDEX IF NOT EXISTS idx_parts_project ON gpi.parts(project_id);
CREATE INDEX IF NOT EXISTS idx_schemes_project ON gpi.painting_schemes(project_id);
CREATE INDEX IF NOT EXISTS idx_records_project ON gpi.application_records(project_id);
CREATE INDEX IF NOT EXISTS idx_inspections_project ON gpi.inspections(project_id);
CREATE INDEX IF NOT EXISTS idx_stock_org ON gpi.stock_items(organization_id);
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON gpi.notifications(recipient_id);
CREATE INDEX IF NOT EXISTS idx_messages_to ON gpi.messages(to_user_id, is_read);
CREATE INDEX IF NOT EXISTS idx_geometry_types_org ON gpi.geometry_types(organization_id);
-121
View File
@@ -1,121 +0,0 @@
import fs from 'fs';
import path from 'path';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
// Import Models
import Project from '../models/Project.js';
import Part from '../models/Part.js';
import PaintingScheme from '../models/PaintingScheme.js';
import ApplicationRecord from '../models/ApplicationRecord.js';
import Inspection from '../models/Inspection.js';
import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
import YieldStudy from '../models/YieldStudy.js';
dotenv.config();
const DATA_DIR = path.join(process.cwd(), 'data');
const readJson = (filename: string) => {
const filePath = path.join(DATA_DIR, filename);
if (!fs.existsSync(filePath)) return [];
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
};
const migrate = async () => {
try {
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error('MONGODB_URI not defined');
await mongoose.connect(uri);
console.log('Connected to MongoDB for migration...');
const idMap = new Map<string, mongoose.Types.ObjectId>();
// 1. TechnicalDataSheets
console.log('Migrating TechnicalDataSheets...');
const datasheets = readJson('datasheets.json');
for (const ds of datasheets) {
const newDs = new TechnicalDataSheet({
...ds,
_id: new mongoose.Types.ObjectId()
});
await newDs.save();
idMap.set(ds.id, newDs._id as mongoose.Types.ObjectId);
}
// 2. Projects
console.log('Migrating Projects...');
const projects = readJson('projects.json');
for (const p of projects) {
const newP = new Project({
...p,
_id: new mongoose.Types.ObjectId()
});
await newP.save();
idMap.set(p.id, newP._id as mongoose.Types.ObjectId);
}
// 3. Parts
console.log('Migrating Parts...');
const parts = readJson('parts.json');
for (const part of parts) {
const projectId = idMap.get(part.projectId);
if (projectId) {
const newPart = new Part({ ...part, projectId });
await newPart.save();
}
}
// 4. PaintingSchemes
console.log('Migrating PaintingSchemes...');
const schemes = readJson('paintingSchemes.json');
for (const s of schemes) {
const projectId = idMap.get(s.projectId);
if (projectId) {
const newS = new PaintingScheme({ ...s, projectId });
await newS.save();
}
}
// 5. ApplicationRecords
console.log('Migrating ApplicationRecords...');
const records = readJson('applicationRecords.json');
for (const r of records) {
const projectId = idMap.get(r.projectId);
if (projectId) {
const newR = new ApplicationRecord({ ...r, projectId });
await newR.save();
}
}
// 6. Inspections
console.log('Migrating Inspections...');
const inspections = readJson('inspections.json');
for (const i of inspections) {
const projectId = idMap.get(i.projectId);
if (projectId) {
const newI = new Inspection({ ...i, projectId });
await newI.save();
}
}
// 7. YieldStudies
console.log('Migrating YieldStudies...');
const studies = readJson('yield_studies.json');
for (const s of studies) {
const dataSheetId = idMap.get(s.dataSheetId);
if (dataSheetId) {
const newS = new YieldStudy({ ...s, dataSheetId });
await newS.save();
}
}
console.log('✅ Migration completed successfully!');
process.exit(0);
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
}
};
migrate();
-41
View File
@@ -1,41 +0,0 @@
import { supabase } from '../config/supabase.js';
const LOGTO_USER_ID = 'i4czsf1m1ns7';
async function migrateUsersToLogto() {
console.log('🔄 Iniciando migração de usuários para Logto...');
const { data: users, error: fetchError } = await supabase
.from('users')
.select('*');
if (fetchError) {
console.error('❌ Erro ao buscar usuários:', fetchError);
return;
}
console.log(`📋 Encontrados ${users.length} usuários`);
for (const user of users) {
if (user.clerk_id && !user.logto_id) {
console.log(`⚠️ Usuário ${user.email} tem clerk_id mas não tem logto_id`);
}
if (!user.logto_id && user.email === 'admtracksteel@gmail.com') {
const { error: updateError } = await supabase
.from('users')
.update({ logto_id: LOGTO_USER_ID })
.eq('id', user.id);
if (updateError) {
console.error(`❌ Erro ao atualizar ${user.email}:`, updateError);
} else {
console.log(`✅ Atualizado ${user.email} com logto_id: ${LOGTO_USER_ID}`);
}
}
}
console.log('✅ Migração concluída!');
}
migrateUsersToLogto().catch(console.error);
-48
View File
@@ -1,48 +0,0 @@
import mongoose from 'mongoose';
const MONGODB_URI = 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0';
const UserSchema = new mongoose.Schema({
clerkId: String,
email: String,
name: String,
role: { type: String, enum: ['guest', 'user', 'admin'], default: 'guest' },
isBanned: { type: Boolean, default: false }
}, { timestamps: true });
async function fixAdmin() {
await mongoose.connect(MONGODB_URI);
console.log('✅ Conectado ao MongoDB');
const User = mongoose.model('User', UserSchema);
// Resetar m.reifonas para guest
await User.updateOne(
{ email: 'm.reifonas@gmail.com' },
{ $set: { role: 'guest' } }
);
console.log('✅ m.reifonas@gmail.com resetado para guest');
// Atualizar admtracksteel para admin (o com clerkId real)
const result = await User.updateOne(
{ email: 'admtracksteel@gmail.com', clerkId: { $ne: 'pending' } },
{ $set: { role: 'admin' } }
);
console.log('✅ admtracksteel@gmail.com atualizado para admin', result.modifiedCount > 0 ? '(sucesso)' : '(não encontrado)');
// Listar todos os usuários
const users = await User.find({});
console.log('\n📋 Usuários atualizados:');
users.forEach((u, i) => {
console.log(` ${i + 1}. ${u.email} | role: ${u.role}`);
});
await mongoose.disconnect();
console.log('\n✅ Desconectado do MongoDB');
process.exit(0);
}
fixAdmin().catch(err => {
console.error('❌ Erro:', err);
process.exit(1);
});
+125 -33
View File
@@ -1,70 +1,162 @@
import { ApplicationRecord } from '../lib/compat.js'; import { query } from '../config/database.js';
interface ApplicationRecordItem {
partId: string;
quantity: number;
}
interface ApplicationRecordData {
organizationId?: string;
createdBy?: string;
projectId: string;
coatStage: string;
pieceDescription?: string;
date?: Date;
operator?: string;
realWeight?: number;
volumeUsed?: number;
areaPainted?: number;
wetThicknessAvg?: number;
dryThicknessCalc?: number;
method?: string;
diluentUsed?: number;
notes?: string;
items?: ApplicationRecordItem[];
}
export const createApplicationRecord = async (data: ApplicationRecordData) => {
const result = await query(
`INSERT INTO gpi.application_records (
organization_id, created_by, project_id, coat_stage, piece_description,
date, operator, real_weight, volume_used, area_painted,
wet_thickness_avg, dry_thickness_calc, method, diluent_used, notes
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING *`,
[
data.organizationId, data.createdBy, data.projectId, data.coatStage, data.pieceDescription,
data.date, data.operator, data.realWeight, data.volumeUsed, data.areaPainted,
data.wetThicknessAvg, data.dryThicknessCalc, data.method, data.diluentUsed, data.notes
]
);
const record = result.rows[0];
if (data.items && data.items.length > 0) {
for (const item of data.items) {
await query(
'INSERT INTO gpi.application_record_items (application_record_id, part_id, quantity) VALUES ($1, $2, $3)',
[record.id, item.partId, item.quantity]
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createApplicationRecord = async (data: any & { organizationId?: string, createdBy?: string }) => {
const record = await ApplicationRecord.create({
...data,
date: data.date ? new Date(data.date).toISOString() : null,
organization_id: data.organizationId,
created_by: data.createdBy,
project_id: data.projectId
});
return record; return record;
}; };
export const getApplicationRecordsByProject = async (projectId: string, organizationId?: string) => { export const getApplicationRecordsByProject = async (projectId: string, organizationId?: string) => {
const filter: any = { project_id: projectId }; let sql = 'SELECT * FROM gpi.application_records WHERE project_id = $1';
const params: any[] = [projectId];
if (organizationId) { if (organizationId) {
filter.organization_id = organizationId; sql += ' AND organization_id = $2';
params.push(organizationId);
} }
return await ApplicationRecord.find(filter);
sql += ' ORDER BY date DESC';
const result = await query(sql, params);
// Fetch items for each record
const records = [];
for (const row of result.rows) {
const itemsResult = await query(
'SELECT part_id as "partId", quantity FROM gpi.application_record_items WHERE application_record_id = $1',
[row.id]
);
records.push({ ...row, items: itemsResult.rows });
}
return records;
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any export const updateApplicationRecord = async (
export const updateApplicationRecord = async (id: string, data: any, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => { id: string,
const existing = await ApplicationRecord.findById(id); data: Partial<ApplicationRecordData>,
if (!existing) return null; organizationId?: string,
userId?: string,
userRole?: string,
isDeveloper: boolean = false
) => {
const checkResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]);
if (checkResult.rows.length === 0) return null;
const existing = checkResult.rows[0];
// Organization Check
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
return null; return null;
} }
// Role/Ownership check
const isPowerUser = userRole === 'admin' || isDeveloper; const isPowerUser = userRole === 'admin' || isDeveloper;
if (!isPowerUser && existing.created_by && existing.created_by !== userId) { if (!isPowerUser && existing.created_by && existing.created_by !== userId) {
console.warn(`Permission Denied: User ${userId} tried to update record ${id} created by ${existing.created_by}`);
return null; return null;
} }
const updateData = { const fields: string[] = [];
...data, const params: any[] = [id];
date: data.date ? new Date(data.date).toISOString() : undefined let paramIdx = 2;
};
if (organizationId && !existing.organization_id) { const updatableFields = [
updateData.organization_id = organizationId; 'coatStage', 'pieceDescription', 'date', 'operator', 'realWeight',
'volumeUsed', 'areaPainted', 'wetThicknessAvg', 'dryThicknessCalc',
'method', 'diluentUsed', 'notes'
];
for (const key of updatableFields) {
if ((data as any)[key] !== undefined) {
// Convert camelCase to snake_case
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
fields.push(`${dbKey} = $${paramIdx++}`);
params.push((data as any)[key]);
}
} }
return await ApplicationRecord.findByIdAndUpdate(id, updateData); if (fields.length > 0) {
await query(
`UPDATE gpi.application_records SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1`,
params
);
}
// Handle items update (replace all)
if (data.items) {
await query('DELETE FROM gpi.application_record_items WHERE application_record_id = $1', [id]);
for (const item of data.items) {
await query(
'INSERT INTO gpi.application_record_items (application_record_id, part_id, quantity) VALUES ($1, $2, $3)',
[id, item.partId, item.quantity]
);
}
}
const finalResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]);
const itemsFinal = await query('SELECT part_id as "partId", quantity FROM gpi.application_record_items WHERE application_record_id = $1', [id]);
return { ...finalResult.rows[0], items: itemsFinal.rows };
}; };
export const deleteApplicationRecord = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => { export const deleteApplicationRecord = async (id: string, organizationId?: string, userId?: string, userRole?: string, isDeveloper: boolean = false) => {
const existing = await ApplicationRecord.findById(id); const checkResult = await query('SELECT * FROM gpi.application_records WHERE id = $1', [id]);
if (!existing) return false; if (checkResult.rows.length === 0) return false;
const existing = checkResult.rows[0];
// Organization Check
if (organizationId && existing.organization_id && existing.organization_id !== organizationId) { if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
return false; return false;
} }
// Role/Ownership check
const isPowerUser = userRole === 'admin' || isDeveloper; const isPowerUser = userRole === 'admin' || isDeveloper;
if (!isPowerUser && existing.created_by && existing.created_by !== userId) { if (!isPowerUser && existing.created_by && existing.created_by !== userId) {
return false; return false;
} }
await ApplicationRecord.findByIdAndDelete(id); await query('DELETE FROM gpi.application_records WHERE id = $1', [id]);
return true; return true;
}; };
+22 -16
View File
@@ -1,8 +1,14 @@
import { import Project from '../models/Project.js';
Project, Inspection, ApplicationRecord, TechnicalDataSheet, PaintingScheme, import Inspection from '../models/Inspection.js';
Part, Instrument, YieldStudy, GeometryType, StockItem, StockMovement import ApplicationRecord from '../models/ApplicationRecord.js';
} from '../lib/compat.js'; import TechnicalDataSheet from '../models/TechnicalDataSheet.js';
import PaintingScheme from '../models/PaintingScheme.js';
import Part from '../models/Part.js';
import Instrument from '../models/Instrument.js';
import YieldStudy from '../models/YieldStudy.js';
import GeometryType from '../models/GeometryType.js';
import StockItem from '../models/StockItem.js';
import StockMovement from '../models/StockMovement.js';
interface BackupData { interface BackupData {
version: string; version: string;
@@ -42,17 +48,17 @@ export const backupService = {
stockItems, stockItems,
stockMovements stockMovements
] = await Promise.all([ ] = await Promise.all([
Project.find({ organizationId }), Project.find({ organizationId }).lean(),
Inspection.find({ organizationId }), Inspection.find({ organizationId }).lean(),
ApplicationRecord.find({ organizationId }), ApplicationRecord.find({ organizationId }).lean(),
TechnicalDataSheet.find({ organizationId }), TechnicalDataSheet.find({ organizationId }).lean(),
PaintingScheme.find({ organizationId }), PaintingScheme.find({ organizationId }).lean(),
Part.find({ organizationId }), Part.find({ organizationId }).lean(),
Instrument.find({ organizationId }), Instrument.find({ organizationId }).lean(),
YieldStudy.find({ organizationId }), YieldStudy.find({ organizationId }).lean(),
GeometryType.find({ organizationId }), GeometryType.find({ organizationId }).lean(),
StockItem.find({ organizationId }), StockItem.find({ organizationId }).lean(),
StockMovement.find({ organizationId }) StockMovement.find({ organizationId }).lean()
]); ]);
const backup: BackupData = { const backup: BackupData = {
+112 -120
View File
@@ -1,141 +1,133 @@
import { supabase } from '../config/supabase.js'; import { query } from '../config/database.js';
import fs from 'fs';
import path from 'path';
const BUCKET_NAME = 'gpi-files'; interface DataSheetData {
organizationId?: string;
export const saveFileToStorage = async (localPath: string, filename: string): Promise<string> => { name: string;
try { manufacturer?: string;
const fileBuffer = fs.readFileSync(localPath); manufacturerCode?: string;
const fileExt = path.extname(filename); type?: string;
const uniqueName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}${fileExt}`; minStock?: number;
typicalApplication?: string;
const { data, error } = await supabase.storage fileId?: string;
.from(BUCKET_NAME) fileUrl?: string;
.upload(uniqueName, fileBuffer, { solidsVolume?: number;
contentType: getContentType(fileExt) density?: number;
}); mixingRatio?: string;
mixingRatioWeight?: string;
if (error) throw error; mixingRatioVolume?: string;
wftMin?: number;
const { data: urlData } = supabase.storage wftMax?: number;
.from(BUCKET_NAME) dftMin?: number;
.getPublicUrl(uniqueName); dftMax?: number;
reducer?: string;
try { fs.unlinkSync(localPath); } catch {} yieldTheoretical?: number;
dftReference?: number;
return urlData.publicUrl; yieldFactor?: number;
} catch (err) { dilution?: number;
console.error('Failed to upload file:', err); notes?: string;
throw err;
} }
};
export const deleteFileFromStorage = async (fileUrl: string): Promise<boolean> => {
try {
const fileName = fileUrl.split('/').pop();
if (!fileName) return false;
const { error } = await supabase.storage
.from(BUCKET_NAME)
.remove([fileName]);
if (error) throw error;
return true;
} catch (err) {
console.error('Failed to delete file:', err);
return false;
}
};
function getContentType(ext: string): string {
const types: Record<string, string> = {
'.pdf': 'application/pdf',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
};
return types[ext.toLowerCase()] || 'application/octet-stream';
}
export const saveFileToGridFS = saveFileToStorage;
export const deleteFileFromGridFS = deleteFileFromStorage;
export const getFileStream = (fileUrl: string) => {
return fileUrl;
};
export const migrateFilesToGridFS = async () => {
console.log('️ File migration skipped - using Supabase Storage');
};
export const getAllDataSheets = async (organizationId?: string) => { export const getAllDataSheets = async (organizationId?: string) => {
try { let sql = 'SELECT * FROM gpi.technical_data_sheets';
let query = supabase.from('technical_data_sheets').select('*'); const params: any[] = [];
if (organizationId) { if (organizationId) {
query = query.eq('organization_id', organizationId); sql += ' WHERE (organization_id = $1 OR organization_id IS NULL)';
params.push(organizationId);
} }
const { data, error } = await query;
if (error && error.code !== '42P01') throw error; sql += ' ORDER BY created_at DESC';
return data || [];
} catch (err) { const result = await query(sql, params);
console.log('Error fetching datasheets:', err); return result.rows;
return []; };
export const matchSheets = async (searchTerm: string, organizationId?: string) => {
let sql = `
SELECT * FROM gpi.technical_data_sheets
WHERE (name ILIKE $1 OR manufacturer ILIKE $1 OR type ILIKE $1)`;
const params: any[] = [`%${searchTerm}%`];
if (organizationId) {
sql += ' AND (organization_id = $2 OR organization_id IS NULL)';
params.push(organizationId);
} }
const result = await query(sql, params);
return result.rows;
}; };
export const uploadDataSheetFile = async (file: any, organizationId: string) => { export const createDataSheet = async (data: DataSheetData) => {
const { data, error } = await supabase const result = await query(
.from('technical_data_sheets') `INSERT INTO gpi.technical_data_sheets (
.insert({ organization_id, name, manufacturer, manufacturer_code, type,
organization_id: organizationId, min_stock, typical_application, file_id, file_url, solids_volume,
name: file.originalname, density, mixing_ratio, mixing_ratio_weight, mixing_ratio_volume,
file_url: file.path wft_min, wft_max, dft_min, dft_max, reducer, yield_theoretical,
}) dft_reference, yield_factor, dilution, notes
.select() ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
.single(); RETURNING *`,
[
if (error) throw error; data.organizationId, data.name, data.manufacturer, data.manufacturerCode, data.type,
return data; data.minStock, data.typicalApplication, data.fileId, data.fileUrl, data.solidsVolume,
data.density, data.mixingRatio, data.mixingRatioWeight, data.mixingRatioVolume,
data.wftMin, data.wftMax, data.dftMin, data.dftMax, data.reducer, data.yieldTheoretical,
data.dftReference, data.yieldFactor, data.dilution, data.notes
]
);
return result.rows[0];
}; };
export const getDataSheets = async (organizationId: string) => { export const updateDataSheet = async (id: string, data: Partial<DataSheetData>, organizationId?: string) => {
return getAllDataSheets(organizationId); const checkResult = await query('SELECT * FROM gpi.technical_data_sheets WHERE id = $1', [id]);
}; if (checkResult.rows.length === 0) return null;
const existing = checkResult.rows[0];
export const createDataSheet = async (data: any) => { if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
const { data: sheet, error } = await supabase return null;
.from('technical_data_sheets') }
.insert(data)
.select()
.single();
if (error) throw error; const fields: string[] = [];
return sheet; const params: any[] = [id];
}; let paramIdx = 2;
export const updateDataSheet = async (id: string, data: any, organizationId?: string) => { const updatableFields = [
const { data: sheet, error } = await supabase 'name', 'manufacturer', 'manufacturerCode', 'type', 'minStock',
.from('technical_data_sheets') 'typicalApplication', 'fileId', 'fileUrl', 'solidsVolume', 'density',
.update(data) 'mixingRatio', 'mixingRatioWeight', 'mixingRatioVolume', 'wftMin',
.eq('id', id) 'wftMax', 'dftMin', 'dftMax', 'reducer', 'yieldTheoretical',
.select() 'dftReference', 'yieldFactor', 'dilution', 'notes'
.single(); ];
if (error && error.code !== '42P01') throw error; for (const key of updatableFields) {
return sheet; if ((data as any)[key] !== undefined) {
const dbKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
fields.push(`${dbKey} = $${paramIdx++}`);
params.push((data as any)[key]);
}
}
if (fields.length === 0) return existing;
const updateSql = `
UPDATE gpi.technical_data_sheets
SET ${fields.join(', ')}, updated_at = NOW()
WHERE id = $1
RETURNING *`;
const result = await query(updateSql, params);
return result.rows[0];
}; };
export const deleteDataSheet = async (id: string, organizationId?: string) => { export const deleteDataSheet = async (id: string, organizationId?: string) => {
const { error } = await supabase const checkResult = await query('SELECT * FROM gpi.technical_data_sheets WHERE id = $1', [id]);
.from('technical_data_sheets') if (checkResult.rows.length === 0) return false;
.delete() const existing = checkResult.rows[0];
.eq('id', id);
if (error && error.code !== '42P01') throw error; if (organizationId && existing.organization_id && existing.organization_id !== organizationId) {
return false;
}
await query('DELETE FROM gpi.technical_data_sheets WHERE id = $1', [id]);
return true; return true;
}; };
console.log('✅ DataSheetService loaded with Supabase Storage');

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